mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-09 07:20:07 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd2e34fb63 | |||
| bdf74e89f3 | |||
| 65cc9ef940 | |||
| a838a03a1f | |||
| 7587fd76ee | |||
| b475cc7e9a | |||
| 93149f1cbd | |||
| 352d25f2bb | |||
| 00f08ab2dc | |||
| 07918562dd | |||
| 33d04f0303 | |||
| 34523cf51a | |||
| 8bfd4868c0 | |||
| fa409739c8 | |||
| 4e8774a9a8 | |||
| 92f276e20f | |||
| 394d6f269c | |||
| 116f716d3a | |||
| 65d19d3274 | |||
| d5eace30ed | |||
| 6c9df17d12 | |||
| 44909589b8 | |||
| b00095aaa8 | |||
| d7ad2f1dad | |||
| 2c1270ec79 |
@@ -0,0 +1,298 @@
|
||||
---
|
||||
name: releasing-healthchat
|
||||
description: Cut a HealthChat TestFlight release of element-x-ios by tagging healthchat-v*. Use when the user asks to release, publish, tag, deploy, ship, or cut a new HealthChat iOS version, asks about the TestFlight or App Store release process, asks about renewing the Apple Distribution signing certificate, or asks about the required GitHub Actions secrets for the release pipeline.
|
||||
---
|
||||
|
||||
# Releasing HealthChat (element-x-ios)
|
||||
|
||||
Releases are driven by git tags of the form `healthchat-v<version>` pushed to
|
||||
`origin`. The tag triggers `.github/workflows/release-healthchat.yml`, which runs
|
||||
on the org's self-hosted macOS runner, builds a signed App Store IPA, and uploads
|
||||
it to **TestFlight only**. No GitHub Release is created.
|
||||
|
||||
**App Store submission is always a manual step** in App Store Connect after a
|
||||
TestFlight build has been validated — see "Promoting to App Store" below.
|
||||
|
||||
**HealthChat ships as an in-place update of the legacy element-ios app.** Same
|
||||
bundle id (`ch.healthchat.element`), same team (`6G37K72W82`), same App Store
|
||||
record. Existing users' keychain and app-group container survive the update. Read
|
||||
"The migration contract" before changing anything that looks like an identifier.
|
||||
|
||||
## Preflight checklist
|
||||
|
||||
Verify each item before tagging. If any fail, fix and commit first.
|
||||
|
||||
- [ ] On branch `release/healthchat` with a clean working tree (`git status`).
|
||||
- [ ] `HEALTHCHAT_CHANGES.md` has a section `## Changes in <new-version> (<date>)`
|
||||
at the top. The pipeline's awk extractor matches this exact heading format;
|
||||
no match ships a fallback placeholder as the TestFlight "What to Test" text.
|
||||
- [ ] The version is **numeric only** — see "Version rules". This is the single
|
||||
most common way to waste 30 minutes here.
|
||||
- [ ] `CHANGES.md` is **not** used for HealthChat releases — upstream's
|
||||
`release-to-github` owns it. Only edit `HEALTHCHAT_CHANGES.md`.
|
||||
|
||||
## Version rules
|
||||
|
||||
**Numeric only. One to three dot-separated integers.** `26.7.4`. Never `26.7.4-rc1`.
|
||||
|
||||
The marketing version lands verbatim in `CFBundleShortVersionString`, and Apple
|
||||
rejects anything that is not at most three period-separated integers. A marker
|
||||
version passes the tag regex and every local gate, then dies at the **upload
|
||||
step, after ~30 minutes of signing**, with an ITMS error indistinguishable from a
|
||||
signing failure.
|
||||
|
||||
The Fastfile guards this in `release_testflight`, before any build or signing work. Do not relax that guard.
|
||||
|
||||
The legacy element-ios skill documents `-rc1` / `-hotfix1` / `-renew-test` tag
|
||||
suffixes. **Do not carry that convention over.** The workflow derives the version
|
||||
by stripping only the `healthchat-v` prefix, so a suffix becomes part of the
|
||||
version and hits the wall above. Put markers in the release notes, and use the
|
||||
`workflow_dispatch` path for test builds.
|
||||
|
||||
`project.yml:56`'s `MARKETING_VERSION` is only a default — **the tag is the source
|
||||
of truth**, and the pipeline injects the version at build time without touching
|
||||
any tracked file. Rebases from upstream will drag `project.yml`'s version around
|
||||
(`automatic-calendar-version.yml` no-ops in this fork); ignore it.
|
||||
|
||||
## Cutting the release (normal path)
|
||||
|
||||
```bash
|
||||
# from release/healthchat, fully committed:
|
||||
git tag healthchat-v<MAJOR>.<MINOR>.<PATCH>
|
||||
git push origin healthchat-v<MAJOR>.<MINOR>.<PATCH>
|
||||
```
|
||||
|
||||
Example: `git tag healthchat-v26.7.4`
|
||||
|
||||
The `healthchat-` prefix keeps these tags clear of upstream's `release/<version>`
|
||||
and `nightly/*` namespaces.
|
||||
|
||||
The workflow triggers automatically. Watch it with `gh run watch` or the Actions
|
||||
UI. The TestFlight build appears in App Store Connect within a few minutes of the
|
||||
run finishing (Apple's processing queue).
|
||||
|
||||
**Build numbers are not encoded in tags.** `generate_build_number` in
|
||||
`fastlane/Fastfile` produces a `YYYYMMDDHHMMSS` timestamp, so ordering is
|
||||
monotonic and a retry never collides with a build number Apple has already seen.
|
||||
|
||||
## Manual / retry path (workflow_dispatch)
|
||||
|
||||
Use when retrying a failed run, testing the pipeline (e.g. cert-renewal
|
||||
verification), or rebuilding without moving tags.
|
||||
|
||||
1. GitHub → Actions → "Release HealthChat" → **Run workflow**.
|
||||
2. Fill in:
|
||||
- **version** (required): e.g. `26.7.99`. Numeric only — same rule as tags.
|
||||
- **release_notes_override** (optional): replaces the `HEALTHCHAT_CHANGES.md`
|
||||
lookup. Use this to mark test builds, since the version cannot carry a marker.
|
||||
3. Run. The IPA is built and uploaded to TestFlight exactly as the tag path would.
|
||||
|
||||
## Release notes
|
||||
|
||||
`HEALTHCHAT_CHANGES.md` is the single source. The workflow extracts the section
|
||||
matching the version being released. Format:
|
||||
|
||||
```markdown
|
||||
## Changes in 26.7.4 (2026-08-01)
|
||||
|
||||
🙌 Improvements
|
||||
|
||||
- Bullet one.
|
||||
- Bullet two.
|
||||
```
|
||||
|
||||
No matching section emits a workflow warning and falls back to
|
||||
`HealthChat <version> — automated build from <ref>.` Prefer fixing the changelog.
|
||||
A **missing file** is a hard error, not a fallback.
|
||||
|
||||
## After the release
|
||||
|
||||
- [ ] Workflow run is green in GitHub Actions.
|
||||
- [ ] Build appears under App Store Connect → TestFlight within ~10 minutes.
|
||||
- [ ] Internal testers are invited (the auto-distribution group may handle this).
|
||||
- [ ] Artifacts (IPA, dSYM, Gym logs) attached to the run, retained 90 days.
|
||||
|
||||
## Never commit the post-lane tree
|
||||
|
||||
A local lane run rewrites `ElementX.xcodeproj/project.pbxproj` — `xcodegen`
|
||||
regenerates it, then `disable_automatic_code_signing` and
|
||||
`update_project_provisioning` write Manual signing and profile UUIDs into it. That
|
||||
file is tracked.
|
||||
|
||||
```bash
|
||||
git checkout -- ElementX.xcodeproj
|
||||
```
|
||||
|
||||
Committing it pins every future build to one machine's profile UUIDs.
|
||||
|
||||
## Required GitHub Actions secrets
|
||||
|
||||
Five. Missing any fails the workflow at the signing step with a clear error.
|
||||
|
||||
| Secret | Source |
|
||||
|---|---|
|
||||
| `HEALTHCHAT_ASC_KEY_ID` | App Store Connect → Users and Access → Integrations → Team Keys (10-char Key ID) |
|
||||
| `HEALTHCHAT_ASC_KEY_ISSUER_ID` | Same page, UUID at the top |
|
||||
| `HEALTHCHAT_ASC_KEY_CONTENT` | `.p8` contents including the `-----BEGIN/END PRIVATE KEY-----` lines |
|
||||
| `HEALTHCHAT_CERTIFICATES_P12` | Base64 distribution cert (`base64 -i healthchat.p12 \| pbcopy`) |
|
||||
| `HEALTHCHAT_CERTIFICATES_P12_PASSWORD` | Password used when exporting the `.p12` |
|
||||
|
||||
**There is no `HEALTHCHAT_KEYCHAIN_PASSWORD`.** The legacy skill lists one and the
|
||||
legacy workflow references it, but it exists at neither repo nor org level and
|
||||
that pipeline runs green anyway — `import-codesign-certs` tolerates its absence.
|
||||
Do not add it back.
|
||||
|
||||
Managing these needs the `snowping` GitHub account; this repo is INTERNAL and
|
||||
invisible to the other account.
|
||||
|
||||
## Self-hosted runner requirements
|
||||
|
||||
Runs on `novaloop-mac-studio-001` — an **org-level** runner (`[self-hosted,
|
||||
macOS, ARM64]`) shared with the legacy fork, not registered per-repo.
|
||||
|
||||
- **One Xcode, globally selected.** A single install at `/Applications/Xcode.app`
|
||||
(**26.6**, build `17F113`, today) — no versioned `Xcode_*.app` siblings.
|
||||
**Nothing pins it.** The Fastfile builds with whatever `xcode-select` points at, so
|
||||
**whatever the runner has is what ships**. Upgrading the runner's Xcode silently
|
||||
changes the compiler every subsequent release is built with — no tag, no diff, no
|
||||
review. Treat that upgrade as a release-affecting change.
|
||||
- **Do not reintroduce a version pin.** Pinning a version the runner lacks sends
|
||||
`xcodes` into a multi-GB authenticated download that no CI secret can satisfy, and
|
||||
the run hangs. Change the runner's Xcode instead.
|
||||
- **Toolchain installed once by hand**, never by CI: `xcodegen swiftlint swiftformat
|
||||
xcbeautify xcresultparser pkl` (`xcresultparser` is not in homebrew-core — only
|
||||
`a7ex/homebrew-formulae/xcresultparser` ships it). `git-lfs`, `xcodes`, `brew` and
|
||||
rbenv `ruby`/`bundle` are already present. There is deliberately **no `brew bundle`
|
||||
step and no `Brewfile`**: this machine is shared with CI, and a per-run
|
||||
`brew update` could upgrade `xcodegen` underneath a running release. CI asserts the
|
||||
tools are present and fails loudly naming the fix; it never provisions.
|
||||
- **No passwordless sudo.** Nothing in CI may call `sudo` — that is why upstream's
|
||||
`setup_github_actions_environment` cannot run here.
|
||||
- **`APPLE_ID`** exported as a machine-level shell variable — the `Appfile` reads
|
||||
it and it is not carried in any secret.
|
||||
- Network access to `developer.apple.com`, `api.appstoreconnect.apple.com`,
|
||||
`github.com`, `rubygems.org`.
|
||||
|
||||
**A release's only Xcode provenance is its own log.** `release-healthchat.yml` runs
|
||||
`xcodebuild -version` (`Record toolchain`) before every build, so each run records the
|
||||
compiler that produced it. Nothing pins that version — upgrading the runner's Xcode
|
||||
silently changes the compiler every subsequent release is built with, and that log
|
||||
line is the only place it shows up. Do not reconstruct a past build's toolchain from
|
||||
the runner: it answers for today, not for the build you are asking about. Read the
|
||||
run's log.
|
||||
|
||||
## Renewing the Apple Distribution certificate (annual)
|
||||
|
||||
Apple Distribution certificates are valid for **exactly 1 year**. There is no
|
||||
longer-lived option. Last renewed **2026-04-07**, so the next is due around
|
||||
**April 2027**. Renew proactively.
|
||||
|
||||
1. **~4 weeks before expiry**, check on the Mac holding the private key:
|
||||
```bash
|
||||
security find-certificate -c "Apple Distribution: E-HealthSolutions GmbH" -p | openssl x509 -noout -enddate
|
||||
```
|
||||
2. **Generate a CSR** on whichever Mac will hold the new private key: Keychain
|
||||
Access → Certificate Assistant → Request a Certificate from a Certificate
|
||||
Authority… → "Saved to disk".
|
||||
3. **Apple Developer portal → Certificates** → **+** → Apple Distribution → upload
|
||||
the CSR → download the `.cer` → double-click to install.
|
||||
4. **Regenerate all 3 App Store provisioning profiles** — they reference the old
|
||||
cert and become invalid:
|
||||
- `HealthChat App Store`
|
||||
- `HealthChat NSE: App Store`
|
||||
- `HealthChat Share Extension: App Store`
|
||||
5. **Verify locally**: archive in Xcode → Distribute App → App Store Connect →
|
||||
Upload. TestFlight accepting the build proves the cert works.
|
||||
6. **Export as `.p12`**: Keychain Access → My Certificates → right-click the new
|
||||
`Apple Distribution: E-HealthSolutions GmbH` → Export → set a password.
|
||||
7. **Re-encode and update secrets**: `base64 -i healthchat-new.p12 | pbcopy`, then
|
||||
replace `HEALTHCHAT_CERTIFICATES_P12` and `HEALTHCHAT_CERTIFICATES_P12_PASSWORD`.
|
||||
8. **Run the workflow** via `workflow_dispatch` with a throwaway numeric version
|
||||
to verify end-to-end. Discard the resulting build.
|
||||
9. **Revoke the old cert** once the new one works in both Xcode and CI.
|
||||
|
||||
**Keep the expired `.p12` archived for 2 weeks** in case a rollback is needed.
|
||||
|
||||
## Promoting a TestFlight build to the App Store
|
||||
|
||||
TestFlight and App Store builds are the same binary. To release a validated one:
|
||||
|
||||
1. App Store Connect → My Apps → HealthChat → **TestFlight** → find the build.
|
||||
2. Confirm it passed internal testing.
|
||||
3. **Distribution** tab → **+** next to "iOS App" → "Prepare for Submission" →
|
||||
fill in metadata → **Add for Review**.
|
||||
4. Wait for review (24–48 hours typical).
|
||||
5. Release manually, or configure automatic release.
|
||||
|
||||
**Deliberately not automated.** Automating submission needs `fastlane/metadata/`
|
||||
plus HealthChat-branded screenshots (neither exists) and a `Deliverfile`
|
||||
refactored for API key auth.
|
||||
|
||||
**Before the first submission**, confirm the install base against the iOS floor:
|
||||
element-x requires **iOS 18.5**, the legacy required 15.1. Every user below 18.5
|
||||
stays on 1.4.1 forever — the App Store will not offer them the update, silently.
|
||||
That floor is upstream's and cannot be lowered.
|
||||
|
||||
## Rollback
|
||||
|
||||
- **Bad build, notes fine** → fix, commit, tag the next patch version. TestFlight
|
||||
supersedes the bad build. (Build numbers are timestamps, so no version games.)
|
||||
- **Bad release notes only** → edit `HEALTHCHAT_CHANGES.md`, then update the
|
||||
TestFlight "What to Test" text in App Store Connect by hand — the pipeline only
|
||||
writes it at upload time.
|
||||
- **Wrong version number** → treat as burned; tag a higher one.
|
||||
- **Rolling back to legacy 1.4.1 loses push** — element-x overwrites the user's
|
||||
sygnal pusher in place on first launch (same app id and pushkey).
|
||||
|
||||
## The migration contract
|
||||
|
||||
**Read this before "cleaning up" anything.** HealthChat replaces the legacy app in
|
||||
place, and these are what stop existing users being silently logged out.
|
||||
|
||||
- **`CLASSIC_APP_*` in `Variants/HealthChat/healthchat.yml` is load-bearing.**
|
||||
`ClassicAppManager` reads the legacy account and crypto-store keys through them.
|
||||
Upstream's defaults in `app.yml` point at Element's own `group.im.vector` /
|
||||
`im.vector.app.*`, which this team cannot provision. **Never restore upstream's
|
||||
values.** Failure is silent — `loadAccounts()` throws, and
|
||||
`AuthenticationService` logs it without telling the user or you.
|
||||
- **The classic keychain group in `ElementX/SupportingFiles/target.yml` must stay
|
||||
in the `keychain-access-groups` array.** Entitlement arrays live there, never in
|
||||
the variant — XcodeGen concatenates arrays on include-merge and only overrides
|
||||
scalars.
|
||||
- **The `.requiresBackup` fall-through in `AuthenticationStartScreenViewModel` is a
|
||||
deliberate upstream divergence.** Upstream sends those accounts to a sheet that
|
||||
tells the user to open Element Classic. That app no longer exists — this update
|
||||
replaced it — and the sheet's button opens a URL scheme nothing registers. A
|
||||
rebase that reverts this reintroduces a dead button.
|
||||
- **Both apps share the `group.ch.healthchat` UserDefaults suite**
|
||||
(`AppSettings.swift:56`). An unqualified `@UserPreference` name lands in a suite
|
||||
the legacy also writes to. Collisions are cheap to introduce.
|
||||
- **`hasSignedInBefore` voids migration testing.** It lives in that shared suite,
|
||||
survives app deletion, and short-circuits `loadAccounts()`. Any device that has
|
||||
run an element-x HealthChat build shows a plain login screen *while migration
|
||||
works perfectly*. Test on a device that has never run an element-x HealthChat
|
||||
build, or clear the `group.ch.healthchat` suite first;
|
||||
`"Already signed in to Element X before, skipping loadAccounts"` in the log
|
||||
means the run is **void, not failed**. A
|
||||
`"Classic App not configured, skipping loadAccounts"` line is the opposite —
|
||||
the `CLASSIC_APP_*` Info.plist keys are missing, `ClassicAppManager` never
|
||||
initialised, and migration is broken for every user. Investigate, do not
|
||||
dismiss.
|
||||
|
||||
## Known branch divergence on rebase
|
||||
|
||||
`fastlane/`, `.github/workflows/release-healthchat.yml`, `HEALTHCHAT_CHANGES.md`,
|
||||
`Gemfile` and this skill are **fork-only** — upstream element-x-ios has
|
||||
no `fastlane/` directory at all. A rebase conflict in any of them means something
|
||||
upstream has started to overlap; keep `--ours` and investigate.
|
||||
|
||||
What does need watching:
|
||||
|
||||
- **`project.yml`'s `MARKETING_VERSION`** — `automatic-calendar-version.yml` and
|
||||
`post-release.yml` are gated on `github.repository == 'element-hq/element-x-ios'`
|
||||
and silently no-op here, so rebases drag upstream's version along. Harmless: the
|
||||
tag is the source of truth.
|
||||
- **`AuthenticationStartScreenViewModel.login(classicAppAccount:)`** — the only
|
||||
upstream logic file this fork edits. See "The migration contract".
|
||||
- **`app.yml`'s `CLASSIC_APP_*`** — upstream owns these and they stay pointed at
|
||||
Element's ids. The variant overrides them. Do not "fix" `app.yml`.
|
||||
@@ -11,6 +11,11 @@ permissions: {}
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
# HealthChat: skip in this fork. It runs nightly Mon-Fri and fails at checkout, because
|
||||
# permissions: {} leaves a metadata-only token that cannot read an INTERNAL repo. Gated rather
|
||||
# than deleted so the file keeps rebasing cleanly. Run locally with
|
||||
# `swift run tools ci accessibility-tests`.
|
||||
if: github.repository == 'element-hq/element-x-ios'
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 150
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
# HealthChat: skip in this fork. It only triggers on changes under compound-ios/, which this fork
|
||||
# has never edited, so it would only ever fire on an upstream sync and go red at checkout —
|
||||
# permissions: {} leaves a metadata-only token that cannot read an INTERNAL repo. Gated rather
|
||||
# than deleted so the file keeps rebasing cleanly.
|
||||
if: github.repository == 'element-hq/element-x-ios'
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 15
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ permissions: {}
|
||||
jobs:
|
||||
integration_tests:
|
||||
name: Integration Tests
|
||||
# HealthChat: skip in this fork. It runs nightly Mon-Fri and fails at checkout, and it is the one
|
||||
# job that still calls out to Element's Codecov — its upload guard is true on schedule events, so
|
||||
# it POSTs slug/branch/SHA every night with continue-on-error hiding the failure. Fixing it would
|
||||
# need a real healthchat.ch test account and three new secrets, and would still not work:
|
||||
# HealthChatAppSettingsHook sets allowOtherAccountProviders: false, hiding the change-server UI
|
||||
# these tests drive. Gated rather than deleted so the file keeps rebasing cleanly.
|
||||
if: github.repository == 'element-hq/element-x-ios'
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 45
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: Release HealthChat
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'healthchat-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Marketing version (e.g. 26.7.4). Must be 1-3 dot-separated integers — Apple rejects anything else.'
|
||||
required: true
|
||||
type: string
|
||||
release_notes_override:
|
||||
description: 'Optional release notes. If empty, falls back to the HEALTHCHAT_CHANGES.md section for the given version.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
# Minimum needed for actions/checkout to clone an internal repo. The workflow does not push,
|
||||
# comment, or create releases — everything else authenticates with the ASC key and .p12.
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-healthchat-${{ github.event.inputs.version || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & upload to TestFlight
|
||||
runs-on: [self-hosted, macOS]
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# This repo's only submodule is element-hq's private Enterprise repo, which Novaloop
|
||||
# cannot clone. Checking out submodules fails the job on its very first step.
|
||||
submodules: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Record toolchain
|
||||
# HealthChat: nothing pins Xcode (the runner carries a single globally-selected one), so this
|
||||
# log line is the only record of which compiler produced a given TestFlight build.
|
||||
run: xcodebuild -version
|
||||
|
||||
- name: Resolve marketing version
|
||||
id: version
|
||||
env:
|
||||
# Read via env, never `${{ }}` inside the script: the input is attacker-controlled text
|
||||
# that GitHub would splice into the shell before the regex below could reject it.
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#healthchat-v}"
|
||||
fi
|
||||
# Apple caps CFBundleShortVersionString at three integers. The Fastfile guards this too,
|
||||
# but failing here costs seconds instead of a signing run.
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then
|
||||
echo "::error::Invalid marketing version '$VERSION' — expected 1-3 dot-separated integers, e.g. 26.7.4"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved marketing version: $VERSION"
|
||||
|
||||
- name: Extract release notes
|
||||
id: notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
OVERRIDE: ${{ github.event.inputs.release_notes_override }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$OVERRIDE" ]]; then
|
||||
NOTES="$OVERRIDE"
|
||||
echo "Using release notes from the workflow input"
|
||||
elif [[ ! -f HEALTHCHAT_CHANGES.md ]]; then
|
||||
echo "::error::HEALTHCHAT_CHANGES.md is missing — it is the release-notes source"
|
||||
exit 1
|
||||
else
|
||||
NOTES=$(awk -v ver="$VERSION" '
|
||||
/^## Changes in / {
|
||||
if (found) { exit }
|
||||
if ($0 ~ "## Changes in " ver "( |$|\\()") { found = 1; next }
|
||||
}
|
||||
found { print }
|
||||
' HEALTHCHAT_CHANGES.md | sed '/^[[:space:]]*$/{N;/^\n$/D;}')
|
||||
if [[ -z "$NOTES" ]]; then
|
||||
NOTES="HealthChat ${VERSION} — automated build from ${GITHUB_REF_NAME}."
|
||||
echo "::warning::No HEALTHCHAT_CHANGES.md section found for $VERSION — using fallback"
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo 'notes<<NOTES_EOF'
|
||||
echo "$NOTES"
|
||||
echo 'NOTES_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Bundle install
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Import signing certificate
|
||||
uses: apple-actions/import-codesign-certs@b610f78488812c1e56b20e6df63ec42d833f2d14 # v6.0.0
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.HEALTHCHAT_CERTIFICATES_P12 }}
|
||||
p12-password: ${{ secrets.HEALTHCHAT_CERTIFICATES_P12_PASSWORD }}
|
||||
|
||||
- name: Build & upload to TestFlight
|
||||
run: bundle exec fastlane release_testflight
|
||||
env:
|
||||
# Passed via env rather than lane options so multi-line notes containing quotes,
|
||||
# $, or backticks survive without shell escaping.
|
||||
HEALTHCHAT_MARKETING_VERSION: ${{ steps.version.outputs.version }}
|
||||
HEALTHCHAT_RELEASE_NOTES: ${{ steps.notes.outputs.notes }}
|
||||
APPSTORECONNECT_KEY_ID: ${{ secrets.HEALTHCHAT_ASC_KEY_ID }}
|
||||
APPSTORECONNECT_KEY_ISSUER_ID: ${{ secrets.HEALTHCHAT_ASC_KEY_ISSUER_ID }}
|
||||
APPSTORECONNECT_KEY_CONTENT: ${{ secrets.HEALTHCHAT_ASC_KEY_CONTENT }}
|
||||
TEAM_ID: '6G37K72W82'
|
||||
|
||||
- name: Upload artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: healthchat-${{ steps.version.outputs.version }}
|
||||
path: |
|
||||
out/ElementX.ipa
|
||||
out/ElementX.app.dSYM.zip
|
||||
build/Logs/Gym/*.log
|
||||
retention-days: 90
|
||||
if-no-files-found: warn
|
||||
@@ -15,6 +15,12 @@ permissions: {}
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
# HealthChat: skip in this fork. It runs nightly Mon-Fri and fails at checkout, because
|
||||
# permissions: {} leaves a metadata-only token that cannot read an INTERNAL repo. Fixing the
|
||||
# permission would not help: the rebrand changed the start screen without re-recording any
|
||||
# UITests snapshot, so it would fail on content instead. Gated rather than deleted so the file
|
||||
# keeps rebasing cleanly. Run locally with `swift run tools ci ui-tests`.
|
||||
if: github.repository == 'element-hq/element-x-ios'
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
|
||||
@@ -17,7 +17,14 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
# HealthChat: this job can never pass in this fork — it needs
|
||||
# secrets.ELEMENT_BOT_TOKEN (Element's bot, absent here) and recursively
|
||||
# checks out the private element-hq/element-ios-enterprise submodule, which
|
||||
# Novaloop cannot obtain. The guard below only asks whether the PR comes
|
||||
# FROM a fork, not whether this repo IS one, so it does not skip us. Gate on
|
||||
# the upstream repo as post-release.yml does. Gated rather than deleted so
|
||||
# the file keeps rebasing cleanly against upstream.
|
||||
if: ${{ github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
|
||||
concurrency:
|
||||
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.
|
||||
|
||||
@@ -13,21 +13,63 @@ permissions: {}
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: macos-26
|
||||
# HealthChat: GitHub-hosted macos-26 could not finish this job inside
|
||||
# timeout-minutes: 60 (two attempts: 51m42s, then killed at 60m44s), and macOS
|
||||
# minutes bill at 10x. novaloop-mac-studio-001 is the org's Mac Studio, already
|
||||
# used by the release pipeline; its labels are [self-hosted, macOS, ARM64].
|
||||
runs-on: [self-hosted, macOS]
|
||||
# Upstream's 60 is kept: with previews skipped (see Run tests) the build plus unit tests measured
|
||||
# ~16 min here. A run approaching this budget is a signal, not normal — the earlier 60m timeouts
|
||||
# were preview tests, not the build.
|
||||
timeout-minutes: 60
|
||||
|
||||
permissions:
|
||||
contents: read # HealthChat: this fork is INTERNAL, so a metadata-only token 404s on checkout (and on git lfs pull). Upstream is public, hence permissions: {} there.
|
||||
|
||||
concurrency:
|
||||
# Only allow a single run of this workflow on each branch, automatically cancelling older runs.
|
||||
group: ${{ format('unit-tests-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
# HealthChat: upstream passes persist-credentials: false here, which the
|
||||
# composite forwards to actions/checkout — stripping the auth header before
|
||||
# its separate "Git LFS Pull" step runs. Upstream can afford that because
|
||||
# its repo is public and git lfs pull works anonymously; this fork is
|
||||
# INTERNAL, so the LFS batch endpoint would 404. Keep the composite's own
|
||||
# default (true) so the pull is authenticated with GITHUB_TOKEN.
|
||||
- uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup environment
|
||||
run: source ci_scripts/ci_common.sh && setup_github_actions_environment
|
||||
# HealthChat: verify only — never provision. Upstream's
|
||||
# setup_github_actions_environment cannot run on this runner: it calls
|
||||
# `sudo xcode-select -s /Applications/Xcode_26.5.0.app`, but the runner has no
|
||||
# passwordless sudo and no such path (only /Applications/Xcode.app exists), and
|
||||
# it calls `brew update && brew install ...` — novaloop-mac-studio-001 is the
|
||||
# shared release machine, so a per-run `brew update` could upgrade xcodegen
|
||||
# underneath the release pipeline. These tools are installed once by hand; CI
|
||||
# only asserts they are present and fails loudly, naming the fix, if not.
|
||||
- name: Verify environment
|
||||
run: |
|
||||
set -eu
|
||||
missing=""
|
||||
formulae=""
|
||||
for tool in xcodegen swiftlint swiftformat xcbeautify xcresultparser pkl git-lfs; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing="$missing $tool"
|
||||
case "$tool" in
|
||||
# Not in homebrew-core; only this tap ships it.
|
||||
xcresultparser) formulae="$formulae a7ex/homebrew-formulae/xcresultparser" ;;
|
||||
*) formulae="$formulae $tool" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::Missing on self-hosted runner:${missing}. Install by hand on novaloop-mac-studio-001, then re-run: brew install${formulae}"
|
||||
exit 1
|
||||
fi
|
||||
# The Xcode version is no longer pinned anywhere, so this is the only record
|
||||
# of which Xcode built a given run.
|
||||
xcodebuild -version
|
||||
|
||||
- name: Restore compilation cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
@@ -37,7 +79,19 @@ jobs:
|
||||
restore-keys: compilation-cache-unit-tests-
|
||||
|
||||
- name: Run tests
|
||||
run: swift run -q tools ci unit-tests
|
||||
# HealthChat: previews are skipped. PreviewTests renders a snapshot for every preview in the
|
||||
# app and was the entire reason this job could not fit in an hour — it never once finished,
|
||||
# on either runner. Unit tests alone take ~16 min here. Upstream skips them the same way in
|
||||
# unit-tests-enterprise.yml. Cost: preview snapshot regressions are not caught in CI, so
|
||||
# re-record and check them locally when changing UI.
|
||||
env:
|
||||
# HealthChat: VoiceMessageCacheTests cannot write its URLFileProtection.complete cache file on
|
||||
# this runner's simulator (NSCocoaErrorDomain 513) — a runner-environment issue, not a code bug:
|
||||
# the suite passes locally and upstream, and neither recreating+booting the device nor clearing
|
||||
# state fixes it. Skip it here only, via -skip-testing. Drop this once the runner can write
|
||||
# data-protected files again.
|
||||
CI_SKIP_TESTS: VoiceMessageCacheTests
|
||||
run: swift run -q tools ci unit-tests --skip-previews
|
||||
|
||||
- name: Save compilation cache
|
||||
# Only develop populates the cache, all of the other runs simply read from it.
|
||||
@@ -59,7 +113,11 @@ jobs:
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
# Skip if not successful and in forks
|
||||
if: ${{ success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
# HealthChat: the existing guard only asks whether the PR comes FROM a
|
||||
# fork, not whether this repo IS one, so it is true here and the step
|
||||
# runs without a CODECOV_TOKEN — which fails the job via
|
||||
# fail_ci_if_error. Gate on the upstream repo as post-release.yml does.
|
||||
if: ${{ success() && github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
report_type: coverage
|
||||
@@ -71,7 +129,9 @@ jobs:
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
# Skip if cancelled and in forks
|
||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
# HealthChat: gated on the upstream repo for the same reason as the
|
||||
# coverage step above — no CODECOV_TOKEN exists in this fork.
|
||||
if: ${{ !cancelled() && github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
contents: read # HealthChat: this fork is INTERNAL, so a metadata-only token 404s on checkout. Upstream is public, hence permissions: {} there.
|
||||
actions: read # HealthChat: upload-sarif additionally needs this on private/internal repos; public upstream does not.
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -49,3 +49,10 @@ test_output
|
||||
# Only commit Pkl packages (there may be other caches in here).
|
||||
Components/Secrets/vendor/*
|
||||
!Components/Secrets/vendor/package-2
|
||||
|
||||
# fastlane release outputs
|
||||
out/
|
||||
provisioning_profiles/
|
||||
# Regenerated by every fastlane invocation, including on the runner. Ignored rather than
|
||||
# committed, matching element-ios.
|
||||
fastlane/README.md
|
||||
|
||||
@@ -67,6 +67,14 @@ PRs must follow rules. Prefer Xcode MCP tools over terminal commands.
|
||||
|
||||
---
|
||||
|
||||
## Releasing
|
||||
|
||||
HealthChat ship via tag `healthchat-v*` → TestFlight. Full runbook in
|
||||
`.agents/skills/releasing-healthchat/SKILL.md`. Read skill before release, cert
|
||||
renewal, or touching `fastlane/`. No duplicate runbook here.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Build System
|
||||
|
||||
@@ -195,6 +195,22 @@ extension AccessibilityTests {
|
||||
try await performAccessibilityAudit(named: "GalleryRoomTimelineView_Previews")
|
||||
}
|
||||
|
||||
func testHealthChatExtendedProfileSection() async throws {
|
||||
try await performAccessibilityAudit(named: "HealthChatExtendedProfileSection_Previews")
|
||||
}
|
||||
|
||||
func testHealthChatInviteMemberScreen() async throws {
|
||||
try await performAccessibilityAudit(named: "HealthChatInviteMemberScreen_Previews")
|
||||
}
|
||||
|
||||
func testHealthChatMemberDirectoryScreen() async throws {
|
||||
try await performAccessibilityAudit(named: "HealthChatMemberDirectoryScreen_Previews")
|
||||
}
|
||||
|
||||
func testHealthChatProfileEditScreen() async throws {
|
||||
try await performAccessibilityAudit(named: "HealthChatProfileEditScreen_Previews")
|
||||
}
|
||||
|
||||
func testHighlightedTimelineItemModifier() async throws {
|
||||
try await performAccessibilityAudit(named: "HighlightedTimelineItemModifier_Previews")
|
||||
}
|
||||
|
||||
+2545
-2077
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"originHash" : "792b23b07d60beec539354df3338b50d91cb38b47c8d2d9b3552f161843e0682",
|
||||
"originHash" : "f1791683f9f09408a7746ce0e163941b3ce7e83e84f1a73b2fecc2b5d2f2f4b6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "compound-design-tokens",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/element-hq/compound-design-tokens",
|
||||
"state" : {
|
||||
"revision" : "edc554adf951b0627ab68b197f7d86c786189e76",
|
||||
"version" : "10.2.4"
|
||||
"revision" : "86c15ef71798581d1e0cc39a7f99e598ad572419",
|
||||
"version" : "11.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 265 KiB |
@@ -1,15 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app-logo.pdf",
|
||||
"idiom" : "universal"
|
||||
"filename" : "app-logo.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "app-logo@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "app-logo@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,27 @@
|
||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||
"common_academic_title" = "Titel";
|
||||
"common_address" = "Adresse";
|
||||
"common_business_email" = "E-Mail (geschäftlich)";
|
||||
"common_business_tel" = "Telefon (geschäftlich)";
|
||||
"common_city" = "Ort";
|
||||
"common_first_name" = "Vorname";
|
||||
"common_further_info" = "Weitere Infos / Qualifikationen";
|
||||
"common_house_number" = "Nr.";
|
||||
"common_last_name" = "Nachname";
|
||||
"common_postal_code" = "PLZ";
|
||||
"common_practice_name" = "Praxisname / Firmenname";
|
||||
"common_specialty" = "Fachgebiet";
|
||||
"common_street" = "Strasse";
|
||||
"common_website" = "Webseite";
|
||||
"screen_invite_member_email_placeholder" = "E-Mail-Adresse";
|
||||
"screen_invite_member_note_footer" = "Die persönliche Nachricht muss mindestens 10 Zeichen lang sein.";
|
||||
"screen_invite_member_note_placeholder" = "Persönliche Nachricht";
|
||||
"screen_invite_member_success" = "Einladung wurde versendet";
|
||||
"screen_invite_member_title" = "Mitglied einladen";
|
||||
"screen_member_directory_pending_approval" = "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde.";
|
||||
"screen_member_directory_search_placeholder" = "Name, Praxis, Ort oder Fachgebiet";
|
||||
"screen_member_directory_title" = "Mitglieder";
|
||||
"screen_profile_edit_section_address" = "Praxisadresse";
|
||||
"screen_profile_edit_section_contact" = "Kontaktdaten";
|
||||
"screen_profile_edit_section_personal" = "Persönliche Angaben";
|
||||
"screen_profile_edit_section_professional" = "Berufliche Angaben";
|
||||
@@ -0,0 +1,27 @@
|
||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||
"common_academic_title" = "Title";
|
||||
"common_address" = "Address";
|
||||
"common_business_email" = "Business email";
|
||||
"common_business_tel" = "Business phone";
|
||||
"common_city" = "City";
|
||||
"common_first_name" = "First name";
|
||||
"common_further_info" = "Further infos / qualifications";
|
||||
"common_house_number" = "No.";
|
||||
"common_last_name" = "Last name";
|
||||
"common_postal_code" = "Postal code";
|
||||
"common_practice_name" = "Practice name";
|
||||
"common_specialty" = "Speciality";
|
||||
"common_street" = "Street";
|
||||
"common_website" = "Website";
|
||||
"screen_invite_member_email_placeholder" = "Email address";
|
||||
"screen_invite_member_note_footer" = "The personal message must be at least 10 characters long.";
|
||||
"screen_invite_member_note_placeholder" = "Personal message";
|
||||
"screen_invite_member_success" = "Invitation sent";
|
||||
"screen_invite_member_title" = "Invite a member";
|
||||
"screen_member_directory_pending_approval" = "Your account is being reviewed. You’ll get access to the member directory once it has been approved.";
|
||||
"screen_member_directory_search_placeholder" = "Name, practice, city or speciality";
|
||||
"screen_member_directory_title" = "Members";
|
||||
"screen_profile_edit_section_address" = "Practice address";
|
||||
"screen_profile_edit_section_contact" = "Contact details";
|
||||
"screen_profile_edit_section_personal" = "Personal details";
|
||||
"screen_profile_edit_section_professional" = "Professional details";
|
||||
@@ -1,4 +1,5 @@
|
||||
"screen_home_tab_search" = "Search";
|
||||
"screen_onboarding_welcome_title_ios" = "Welcome to HealthChat";
|
||||
"screen_search_empty_state_message" = "Search for chats and messages";
|
||||
"screen_search_empty_state_title" = "Start searching...";
|
||||
"screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term.";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// HealthChat fork strings. Not managed by Localazy — edit all three languages by hand.
|
||||
"common_academic_title" = "Titre";
|
||||
"common_address" = "Adresse";
|
||||
"common_business_email" = "Email (professionnel)";
|
||||
"common_business_tel" = "Téléphone (professionnel)";
|
||||
"common_city" = "Ville";
|
||||
"common_first_name" = "Prénom";
|
||||
"common_further_info" = "Informations supplémentaires / qualifications";
|
||||
"common_house_number" = "N°";
|
||||
"common_last_name" = "Nom";
|
||||
"common_postal_code" = "Code postal";
|
||||
"common_practice_name" = "Nom de l’entreprise";
|
||||
"common_specialty" = "Spécialisation";
|
||||
"common_street" = "Rue";
|
||||
"common_website" = "Site web";
|
||||
"screen_invite_member_email_placeholder" = "Adresse email";
|
||||
"screen_invite_member_note_footer" = "Le message personnel doit contenir au moins 10 caractères.";
|
||||
"screen_invite_member_note_placeholder" = "Message personnel";
|
||||
"screen_invite_member_success" = "Invitation envoyée";
|
||||
"screen_invite_member_title" = "Inviter un membre";
|
||||
"screen_member_directory_pending_approval" = "Votre compte est en cours de vérification. Vous aurez accès à l’annuaire des membres dès qu’il aura été approuvé.";
|
||||
"screen_member_directory_search_placeholder" = "Nom, cabinet, ville ou spécialisation";
|
||||
"screen_member_directory_title" = "Membres";
|
||||
"screen_profile_edit_section_address" = "Adresse du cabinet";
|
||||
"screen_profile_edit_section_contact" = "Coordonnées";
|
||||
"screen_profile_edit_section_personal" = "Informations personnelles";
|
||||
"screen_profile_edit_section_professional" = "Informations professionnelles";
|
||||
@@ -14,6 +14,7 @@ final nonisolated class AppHooks: AppHooksProtocol {
|
||||
#if IS_MAIN_APP
|
||||
func configure(with userSession: UserSessionProtocol?) async {
|
||||
await userSessionHook.configure(with: userSession)
|
||||
await extendedProfileSectionHook.configure(with: userSession)
|
||||
}
|
||||
|
||||
@AppHook(default: DefaultAppSettingsHook())
|
||||
@@ -43,6 +44,15 @@ final nonisolated class AppHooks: AppHooksProtocol {
|
||||
@AppHook(default: DefaultUserProfileScreenHook())
|
||||
var userProfileScreenHook: UserProfileScreenHookProtocol
|
||||
|
||||
@AppHook(default: DefaultExtendedProfileSectionHook())
|
||||
var extendedProfileSectionHook: ExtendedProfileSectionHookProtocol
|
||||
|
||||
@AppHook(default: DefaultHomeTabsHook())
|
||||
var homeTabsHook: HomeTabsHookProtocol
|
||||
|
||||
@AppHook(default: DefaultUserDetailsEditScreenHook())
|
||||
var userDetailsEditScreenHook: UserDetailsEditScreenHookProtocol
|
||||
|
||||
@AppHook(default: DefaultDeveloperOptionsScreenHook())
|
||||
var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
/// Registers the HealthChat hooks.
|
||||
///
|
||||
/// `AppHooks` is a concrete type and `AppCoordinator` calls `setUp()` on it directly, so this method
|
||||
/// wins over the empty `AppHooksProtocol` default via static dispatch — mirroring how Element's own
|
||||
/// Enterprise fork provides its `setUp()`. This file is compiled only into the main app; the NSE and
|
||||
/// Share Extension keep the empty default (they never drive authentication).
|
||||
extension AppHooks {
|
||||
func setUp() {
|
||||
#if IS_MAIN_APP
|
||||
registerAppSettingsHook(HealthChatAppSettingsHook())
|
||||
registerCompoundHook(HealthChatCompoundHook())
|
||||
registerExtendedProfileSectionHook(HealthChatExtendedProfileSectionHook())
|
||||
registerHomeTabsHook(HealthChatHomeTabsHook())
|
||||
registerUserDetailsEditScreenHook(HealthChatUserDetailsEditScreenHook())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Pins HealthChat's server, OIDC and push configuration by overriding `AppSettings` at startup.
|
||||
///
|
||||
/// - `accountProviders` / `allowOtherAccountProviders` lock every user to `healthchat.ch` (the server name;
|
||||
/// `.well-known` delegates to `matrix.healthchat.ch`) and hide the change-server / create-account UI.
|
||||
/// - `oAuthRedirectURL` uses the app's own URL scheme so the OIDC/MAS callback needs no associated domain.
|
||||
/// - Legacy `element.io` help/encryption/copyright URLs are passed through unchanged for now.
|
||||
struct HealthChatAppSettingsHook: AppSettingsHookProtocol {
|
||||
func configure(_ appSettings: AppSettings) -> AppSettings {
|
||||
appSettings.override(accountProviders: ["healthchat.ch"],
|
||||
allowOtherAccountProviders: false,
|
||||
hideBrandChrome: false,
|
||||
pushGatewayBaseURL: "https://sygnal.healthchat.ch",
|
||||
oAuthRedirectURL: "ch.healthchat.element:/oauth/callback",
|
||||
// nil keeps the registered client_uri at the bare website URL, as shipped before upstream's `apps/ios` default.
|
||||
oAuthClientURIPath: nil,
|
||||
websiteURL: "https://healthchat.ch",
|
||||
logoURL: "https://app.healthchat.ch/themes/element/img/logos/element-logo.png",
|
||||
copyrightURL: appSettings.copyrightURL,
|
||||
acceptableUseURL: "https://www.healthchat.ch/datenschutz",
|
||||
privacyURL: "https://www.healthchat.ch/datenschutz",
|
||||
encryptionURL: appSettings.encryptionURL,
|
||||
deviceVerificationURL: appSettings.deviceVerificationURL,
|
||||
chatBackupDetailsURL: appSettings.chatBackupDetailsURL,
|
||||
identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL,
|
||||
historySharingDetailsURL: appSettings.historySharingDetailsURL,
|
||||
elementWebHosts: ["app.healthchat.ch"],
|
||||
accountProvisioningHost: appSettings.accountProvisioningHost,
|
||||
bugReportApplicationID: appSettings.bugReportApplicationID,
|
||||
analyticsTermsURL: appSettings.analyticsTermsURL,
|
||||
mapTilerConfiguration: AppSettings.bundledMapTilerConfiguration)
|
||||
return appSettings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// HealthChat brand palette, ported from the legacy Element iOS client.
|
||||
/// These light/dark values are applied to Compound's accent token family by `HealthChatCompoundHook`.
|
||||
///
|
||||
/// **Note:** `nonisolated` is required. This target defaults to `MainActor` isolation, but UIKit resolves
|
||||
/// the dynamic colour providers below on SwiftUI's render thread, which traps on an isolated closure.
|
||||
nonisolated enum HealthChatBrandColors {
|
||||
/// Primary brand blue — light `#1F4D85`, dark `#376A91`.
|
||||
static let brand = dynamic(light: hex(0x1F4D85), dark: hex(0x376A91))
|
||||
/// Pressed state — a slightly darker brand blue.
|
||||
static let brandPressed = dynamic(light: hex(0x17385F), dark: hex(0x2A5171))
|
||||
/// Subtle accent background / tint — light `#EBF0F7`, dark `#1A2D47`.
|
||||
static let tint = dynamic(light: hex(0xEBF0F7), dark: hex(0x1A2D47))
|
||||
|
||||
private static func dynamic(light: UIColor, dark: UIColor) -> UIColor {
|
||||
UIColor { $0.userInterfaceStyle == .dark ? dark : light }
|
||||
}
|
||||
|
||||
private static func hex(_ value: Int, alpha: CGFloat = 1) -> UIColor {
|
||||
UIColor(red: CGFloat((value >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((value >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(value & 0xFF) / 255,
|
||||
alpha: alpha)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Recolours Compound's accent token family to the HealthChat brand.
|
||||
/// Applied once at startup from `AppCoordinator` via `AppHooks.compoundHook`. Because the override
|
||||
/// runs at app startup (not in previews), preview snapshots keep the default Compound colours.
|
||||
struct HealthChatCompoundHook: CompoundHookProtocol {
|
||||
func override(colors: CompoundColors, uiColors: CompoundUIColors) {
|
||||
let brand = HealthChatBrandColors.brand
|
||||
let tint = HealthChatBrandColors.tint
|
||||
|
||||
// Solid accent → brand blue (primary + send buttons, links, accent icons, badges, borders).
|
||||
// NB: bgAccentRest is a SOLID fill (behind a white icon on the send button), not a subtle tint.
|
||||
let solidColorTokens: [KeyPath<CompoundColorTokens, Color>] = [
|
||||
\.bgActionPrimaryRest,
|
||||
\.bgAccentRest,
|
||||
\.iconAccentPrimary,
|
||||
\.iconAccentTertiary,
|
||||
\.textActionPrimary,
|
||||
\.textActionAccent,
|
||||
\.textLinkExternal,
|
||||
\.borderInteractivePrimary,
|
||||
\.textBadgeAccent
|
||||
]
|
||||
let solidUIColorTokens: [KeyPath<CompoundUIColorTokens, UIColor> & Sendable] = [
|
||||
\.bgActionPrimaryRest,
|
||||
\.bgAccentRest,
|
||||
\.iconAccentPrimary,
|
||||
\.iconAccentTertiary,
|
||||
\.textActionPrimary,
|
||||
\.textActionAccent,
|
||||
\.textLinkExternal,
|
||||
\.borderInteractivePrimary,
|
||||
\.textBadgeAccent
|
||||
]
|
||||
for keyPath in solidColorTokens {
|
||||
colors.override(keyPath, with: Color(brand))
|
||||
}
|
||||
for keyPath in solidUIColorTokens {
|
||||
uiColors.override(keyPath, with: brand)
|
||||
}
|
||||
|
||||
// Primary button pressed state. The disabled state stays Compound's neutral grey.
|
||||
colors.override(\.bgActionPrimaryPressed, with: Color(HealthChatBrandColors.brandPressed))
|
||||
uiColors.override(\.bgActionPrimaryPressed, with: HealthChatBrandColors.brandPressed)
|
||||
|
||||
// Gradient "super"/action buttons → brand blue (SwiftUI-only concept).
|
||||
let gradientTokens: [KeyPath<CompoundColorTokens, Color>] = [
|
||||
\.gradientActionStop1,
|
||||
\.gradientActionStop2,
|
||||
\.gradientActionStop3,
|
||||
\.gradientActionStop4
|
||||
]
|
||||
for keyPath in gradientTokens {
|
||||
colors.override(keyPath, with: Color(brand))
|
||||
}
|
||||
|
||||
// Subtle accent backgrounds (paired with accent-coloured foregrounds) → brand tint.
|
||||
let tintColorTokens: [KeyPath<CompoundColorTokens, Color>] = [
|
||||
\.bgAccentSelected,
|
||||
\.bgBadgeAccent,
|
||||
\.borderAccentSubtle
|
||||
]
|
||||
let tintUIColorTokens: [KeyPath<CompoundUIColorTokens, UIColor> & Sendable] = [
|
||||
\.bgAccentSelected,
|
||||
\.bgBadgeAccent,
|
||||
\.borderAccentSubtle
|
||||
]
|
||||
for keyPath in tintColorTokens {
|
||||
colors.override(keyPath, with: Color(tint))
|
||||
}
|
||||
for keyPath in tintUIColorTokens {
|
||||
uiColors.override(keyPath, with: tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Loads a user's extended profile for display, hiding profiles without any HealthChat fields.
|
||||
@Observable
|
||||
final class HealthChatExtendedProfileLoader {
|
||||
private(set) var profile: ExtendedProfile?
|
||||
|
||||
/// The load kicked off on creation — await this in tests before asserting.
|
||||
@ObservationIgnored private(set) var loadingTask: Task<Void, Never>?
|
||||
|
||||
init(userID: String, service: ExtendedProfileServiceProtocol) {
|
||||
loadingTask = Task {
|
||||
guard case let .success(profile) = await service.profile(for: userID), profile.hasContent else {
|
||||
return
|
||||
}
|
||||
self.profile = profile
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a loader with a fixed result, for previews.
|
||||
init(profile: ExtendedProfile?) {
|
||||
self.profile = profile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
/// The HealthChat profile fields shown below the header on the profile screens.
|
||||
/// Renders nothing while loading and for profiles without any HealthChat fields.
|
||||
struct HealthChatExtendedProfileSection: View {
|
||||
@State private var loader: HealthChatExtendedProfileLoader
|
||||
|
||||
init(userID: String, service: ExtendedProfileServiceProtocol) {
|
||||
_loader = State(initialValue: HealthChatExtendedProfileLoader(userID: userID, service: service))
|
||||
}
|
||||
|
||||
init(loader: HealthChatExtendedProfileLoader) {
|
||||
_loader = State(initialValue: loader)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let profile = loader.profile {
|
||||
Section {
|
||||
if let title = profile.title {
|
||||
row(label: HealthChatL10n.commonAcademicTitle, value: title)
|
||||
}
|
||||
if let specialty = profile.specialty {
|
||||
row(label: HealthChatL10n.commonSpecialty, value: specialty)
|
||||
}
|
||||
if let practiceName = profile.practiceName {
|
||||
row(label: HealthChatL10n.commonPracticeName, value: practiceName)
|
||||
}
|
||||
if let address = profile.formattedAddress {
|
||||
row(label: HealthChatL10n.commonAddress, value: address)
|
||||
}
|
||||
if let businessEmail = profile.businessEmail {
|
||||
linkRow(label: HealthChatL10n.commonBusinessEmail,
|
||||
value: businessEmail,
|
||||
url: URL(string: "mailto:\(businessEmail)"))
|
||||
}
|
||||
if let businessTel = profile.businessTel {
|
||||
linkRow(label: HealthChatL10n.commonBusinessTel,
|
||||
value: businessTel,
|
||||
url: URL(string: "tel:\(businessTel.filter { !$0.isWhitespace })"))
|
||||
}
|
||||
if let website = profile.website {
|
||||
linkRow(label: HealthChatL10n.commonWebsite,
|
||||
value: website,
|
||||
url: websiteURL(for: website))
|
||||
}
|
||||
if let furtherInfo = profile.furtherInfo {
|
||||
row(label: HealthChatL10n.commonFurtherInfo, value: furtherInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func row(label: String, value: String) -> some View {
|
||||
ListRow(kind: .custom {
|
||||
rowContent(label: label, value: value, valueColor: .compound.textPrimary)
|
||||
})
|
||||
}
|
||||
|
||||
private func linkRow(label: String, value: String, url: URL?) -> some View {
|
||||
ListRow(kind: .custom {
|
||||
Button {
|
||||
guard let url else { return }
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
rowContent(label: label, value: value, valueColor: .compound.textLinkExternal)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func rowContent(label: String, value: String, valueColor: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
Text(value)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(valueColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, ListRowPadding.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func websiteURL(for website: String) -> URL? {
|
||||
guard let url = URL(string: website) else {
|
||||
return nil
|
||||
}
|
||||
return url.scheme == nil ? URL(string: "https://\(website)") : url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct HealthChatExtendedProfileSection_Previews: PreviewProvider, TestablePreview {
|
||||
static let fullProfile = try? JSONDecoder().decode(ExtendedProfile.self, from: Data("""
|
||||
{
|
||||
"displayname": "Dr. Alice Alpha",
|
||||
"io.element.profile.title": "Dr. med.",
|
||||
"io.element.profile.specialization": "Physiotherapie",
|
||||
"io.element.profile.practice_name": "Praxis Alpha",
|
||||
"io.element.profile.business_street": "Friedaustrasse",
|
||||
"io.element.profile.business_street_nr": "17",
|
||||
"io.element.profile.business_plz": "8003",
|
||||
"io.element.profile.business_city": "Zürich",
|
||||
"io.element.profile.business_email": "praxis@example.ch",
|
||||
"io.element.profile.business_tel": "+41 44 123 45 67",
|
||||
"io.element.profile.website": "https://example.ch",
|
||||
"io.element.profile.further_info": "Krankenkassen-anerkannt\\nHausbesuche möglich"
|
||||
}
|
||||
""".utf8))
|
||||
|
||||
static let contactOnlyProfile = try? JSONDecoder().decode(ExtendedProfile.self, from: Data("""
|
||||
{ "io.element.profile.business_tel": "+41 44 123 45 67" }
|
||||
""".utf8))
|
||||
|
||||
static var previews: some View {
|
||||
Form {
|
||||
HealthChatExtendedProfileSection(loader: .init(profile: fullProfile))
|
||||
}
|
||||
.compoundList()
|
||||
.previewDisplayName("Full profile")
|
||||
|
||||
Form {
|
||||
HealthChatExtendedProfileSection(loader: .init(profile: contactOnlyProfile))
|
||||
}
|
||||
.compoundList()
|
||||
.previewDisplayName("Contact only")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if IS_MAIN_APP
|
||||
// Conforming to the nonisolated hook protocol would infer nonisolated, but the
|
||||
// service is main-actor state — the hook's requirements are all @MainActor anyway.
|
||||
@MainActor
|
||||
final class HealthChatExtendedProfileSectionHook: ExtendedProfileSectionHookProtocol {
|
||||
private var extendedProfileService: ExtendedProfileServiceProtocol?
|
||||
|
||||
// Constructed during hook registration, before the main actor is reachable.
|
||||
nonisolated init() { }
|
||||
|
||||
func configure(with userSession: UserSessionProtocol?) async {
|
||||
extendedProfileService = userSession.map { ExtendedProfileService(clientProxy: $0.clientProxy) }
|
||||
}
|
||||
|
||||
func extendedProfileSection(userID: String) -> AnyView? {
|
||||
guard let extendedProfileService else {
|
||||
return nil
|
||||
}
|
||||
return AnyView(HealthChatExtendedProfileSection(userID: userID, service: extendedProfileService))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
#if IS_MAIN_APP
|
||||
// Conforming to the nonisolated hook protocol would infer nonisolated, but the
|
||||
// flow it retains is main-actor state — the hook's requirement is @MainActor anyway.
|
||||
@MainActor
|
||||
final class HealthChatHomeTabsHook: HomeTabsHookProtocol {
|
||||
private var membersFlowCoordinator: HealthChatMembersFlowCoordinator?
|
||||
|
||||
// Constructed during hook registration, before the main actor is reachable.
|
||||
nonisolated init() { }
|
||||
|
||||
func makeExtraTab(flowParameters: CommonFlowParameters)
|
||||
-> (tab: NavigationTabCoordinator<UserSessionFlowCoordinator.HomeTab>.Tab, actions: AnyPublisher<ExtraHomeTabAction, Never>)? {
|
||||
let flowCoordinator = HealthChatMembersFlowCoordinator(flowParameters: flowParameters)
|
||||
membersFlowCoordinator = flowCoordinator
|
||||
flowCoordinator.start()
|
||||
|
||||
let details = NavigationTabCoordinator<UserSessionFlowCoordinator.HomeTab>
|
||||
.TabDetails(tag: .members,
|
||||
title: HealthChatL10n.screenMemberDirectoryTitle,
|
||||
icon: \.userProfile,
|
||||
selectedIcon: \.userProfileSolid)
|
||||
|
||||
return (tab: .init(coordinator: flowCoordinator.navigationStackCoordinator, details: details),
|
||||
actions: flowCoordinator.actionsPublisher)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Fork-only constants for the HealthChat backend. Injected through service
|
||||
/// initialisers so tests can point them at a mock server.
|
||||
enum HealthChatSettings {
|
||||
static let apiBaseURL: URL = "https://api.healthchat.ch"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if IS_MAIN_APP
|
||||
/// Replaces the stock display-name/avatar editor with the HealthChat profile editor.
|
||||
/// The display name is derived server-side from first and last name, so the stock
|
||||
/// editor would let users create values the next profile update overwrites.
|
||||
struct HealthChatUserDetailsEditScreenHook: UserDetailsEditScreenHookProtocol {
|
||||
@MainActor func makeCoordinator(parameters: UserDetailsEditScreenCoordinatorParameters) -> any UserDetailsEditCoordinatorProtocol {
|
||||
let clientProxy = parameters.userSession.clientProxy
|
||||
let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: clientProxy))
|
||||
return HealthChatProfileEditScreenCoordinator(parameters: .init(orientationManager: parameters.orientationManager,
|
||||
userSession: parameters.userSession,
|
||||
apiService: apiService,
|
||||
mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor,
|
||||
navigationStackCoordinator: parameters.navigationStackCoordinator,
|
||||
userIndicatorController: parameters.userIndicatorController))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
nonisolated protocol ExtendedProfileSectionHookProtocol: Sendable {
|
||||
@MainActor func configure(with userSession: UserSessionProtocol?) async
|
||||
|
||||
/// An extra Form section rendered below the header on the user profile
|
||||
/// and room member details screens.
|
||||
@MainActor func extendedProfileSection(userID: String) -> AnyView?
|
||||
}
|
||||
|
||||
struct DefaultExtendedProfileSectionHook: ExtendedProfileSectionHookProtocol {
|
||||
func configure(with userSession: UserSessionProtocol?) async { }
|
||||
|
||||
func extendedProfileSection(userID: String) -> AnyView? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// Actions an extra tab can ask the session flow to perform on its behalf.
|
||||
enum ExtraHomeTabAction {
|
||||
case presentRoom(roomID: String)
|
||||
case presentCall(roomID: String, isVoiceCall: Bool)
|
||||
}
|
||||
|
||||
nonisolated protocol HomeTabsHookProtocol: Sendable {
|
||||
/// An extra tab appended after the standard tabs, or nil for none.
|
||||
/// The hook keeps ownership of the tab's flow.
|
||||
@MainActor func makeExtraTab(flowParameters: CommonFlowParameters)
|
||||
-> (tab: NavigationTabCoordinator<UserSessionFlowCoordinator.HomeTab>.Tab, actions: AnyPublisher<ExtraHomeTabAction, Never>)?
|
||||
}
|
||||
|
||||
struct DefaultHomeTabsHook: HomeTabsHookProtocol {
|
||||
func makeExtraTab(flowParameters: CommonFlowParameters)
|
||||
-> (tab: NavigationTabCoordinator<UserSessionFlowCoordinator.HomeTab>.Tab, actions: AnyPublisher<ExtraHomeTabAction, Never>)? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor protocol UserDetailsEditCoordinatorProtocol: CoordinatorProtocol {
|
||||
var actions: AnyPublisher<UserDetailsEditScreenCoordinatorAction, Never> { get }
|
||||
}
|
||||
|
||||
nonisolated protocol UserDetailsEditScreenHookProtocol: Sendable {
|
||||
@MainActor func makeCoordinator(parameters: UserDetailsEditScreenCoordinatorParameters) -> any UserDetailsEditCoordinatorProtocol
|
||||
}
|
||||
|
||||
struct DefaultUserDetailsEditScreenHook: UserDetailsEditScreenHookProtocol {
|
||||
@MainActor func makeCoordinator(parameters: UserDetailsEditScreenCoordinatorParameters) -> any UserDetailsEditCoordinatorProtocol {
|
||||
UserDetailsEditScreenCoordinator(parameters: parameters)
|
||||
}
|
||||
}
|
||||
|
||||
extension UserDetailsEditScreenCoordinator: UserDetailsEditCoordinatorProtocol { }
|
||||
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftState
|
||||
import SwiftUI
|
||||
|
||||
/// The flow behind the HealthChat "Members" tab: the member directory with pushed member
|
||||
/// profiles, the own-profile editor and the invitation sheet. Chats and calls are handed
|
||||
/// off to the session flow.
|
||||
final class HealthChatMembersFlowCoordinator {
|
||||
private let flowParameters: CommonFlowParameters
|
||||
private let apiService: HealthChatAPIServiceProtocol
|
||||
|
||||
let navigationStackCoordinator: NavigationStackCoordinator
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
enum State: StateType {
|
||||
/// The state machine hasn't started.
|
||||
case initial
|
||||
/// The member directory at the root of the tab.
|
||||
case memberDirectory
|
||||
/// A member's profile pushed on top of the directory.
|
||||
case memberProfile
|
||||
/// The own-profile editor pushed on top of the directory.
|
||||
case profileEditor
|
||||
/// The invitation sheet presented over the directory.
|
||||
case inviteSheet
|
||||
}
|
||||
|
||||
enum Event: EventType {
|
||||
/// The flow is being started.
|
||||
case start
|
||||
/// Request presentation of a member's profile.
|
||||
case showMemberProfile
|
||||
/// The member profile has been dismissed.
|
||||
case dismissedMemberProfile
|
||||
/// Request presentation of the own-profile editor.
|
||||
case showProfileEditor
|
||||
/// The profile editor has been dismissed.
|
||||
case dismissedProfileEditor
|
||||
/// Request presentation of the invitation sheet.
|
||||
case showInviteSheet
|
||||
/// The invitation sheet has been dismissed.
|
||||
case dismissedInviteSheet
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
|
||||
private let actionsSubject: PassthroughSubject<ExtraHomeTabAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<ExtraHomeTabAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(flowParameters: CommonFlowParameters) {
|
||||
self.flowParameters = flowParameters
|
||||
apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: flowParameters.userSession.clientProxy))
|
||||
|
||||
navigationStackCoordinator = NavigationStackCoordinator()
|
||||
stateMachine = StateMachine(state: .initial)
|
||||
|
||||
configureStateMachine()
|
||||
}
|
||||
|
||||
func start() {
|
||||
stateMachine.tryEvent(.start)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func configureStateMachine() {
|
||||
stateMachine.addRoutes(event: .start, transitions: [.initial => .memberDirectory]) { [weak self] _ in
|
||||
self?.presentMemberDirectory()
|
||||
}
|
||||
|
||||
stateMachine.addRoutes(event: .showMemberProfile, transitions: [.memberDirectory => .memberProfile])
|
||||
stateMachine.addRoutes(event: .dismissedMemberProfile, transitions: [.memberProfile => .memberDirectory])
|
||||
|
||||
stateMachine.addRoutes(event: .showProfileEditor, transitions: [.memberDirectory => .profileEditor])
|
||||
stateMachine.addRoutes(event: .dismissedProfileEditor, transitions: [.profileEditor => .memberDirectory])
|
||||
|
||||
stateMachine.addRoutes(event: .showInviteSheet, transitions: [.memberDirectory => .inviteSheet])
|
||||
stateMachine.addRoutes(event: .dismissedInviteSheet, transitions: [.inviteSheet => .memberDirectory])
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Unexpected transition from `\(context.fromState)` to `\(context.toState)` for event `\(String(describing: context.event))`.")
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMemberDirectory() {
|
||||
let userSession = flowParameters.userSession
|
||||
let coordinator = HealthChatMemberDirectoryScreenCoordinator(parameters: .init(apiService: apiService,
|
||||
clientProxy: userSession.clientProxy,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
userIndicatorController: flowParameters.userIndicatorController))
|
||||
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .selectMember(let userID):
|
||||
presentMemberProfile(userID: userID)
|
||||
case .presentRoom(let roomID):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
case .editProfile:
|
||||
presentProfileEditor()
|
||||
case .invite:
|
||||
presentInviteSheet()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
}
|
||||
|
||||
private func presentMemberProfile(userID: String) {
|
||||
let parameters = UserProfileScreenCoordinatorParameters(userID: userID,
|
||||
isPresentedModally: false,
|
||||
userSession: flowParameters.userSession,
|
||||
appHooks: flowParameters.appHooks,
|
||||
analytics: flowParameters.analytics,
|
||||
userIndicatorController: flowParameters.userIndicatorController)
|
||||
let coordinator = UserProfileScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .openDirectChat(let roomID):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
case .startCall(let roomProxy, let isVoiceCall):
|
||||
actionsSubject.send(.presentCall(roomID: roomProxy.id, isVoiceCall: isVoiceCall))
|
||||
case .dismiss:
|
||||
navigationStackCoordinator.pop()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
stateMachine.tryEvent(.showMemberProfile)
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissedMemberProfile)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentProfileEditor() {
|
||||
let parameters = UserDetailsEditScreenCoordinatorParameters(orientationManager: flowParameters.windowManager,
|
||||
userSession: flowParameters.userSession,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings),
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
userIndicatorController: flowParameters.userIndicatorController)
|
||||
let coordinator = flowParameters.appHooks.userDetailsEditScreenHook.makeCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self?.navigationStackCoordinator.pop()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
stateMachine.tryEvent(.showProfileEditor)
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissedProfileEditor)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentInviteSheet() {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
let coordinator = HealthChatInviteMemberScreenCoordinator(parameters: .init(apiService: apiService,
|
||||
userIndicatorController: flowParameters.userIndicatorController))
|
||||
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
stackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
stateMachine.tryEvent(.showInviteSheet)
|
||||
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissedInviteSheet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,11 +179,12 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
|
||||
private func presentUserDetailsEditScreen() {
|
||||
let coordinator = UserDetailsEditScreenCoordinator(parameters: .init(orientationManager: flowParameters.windowManager,
|
||||
userSession: flowParameters.userSession,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings),
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
userIndicatorController: flowParameters.userIndicatorController))
|
||||
let coordinator = flowParameters.appHooks.userDetailsEditScreenHook
|
||||
.makeCoordinator(parameters: .init(orientationManager: flowParameters.windowManager,
|
||||
userSession: flowParameters.userSession,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings),
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
userIndicatorController: flowParameters.userIndicatorController))
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
switch action {
|
||||
|
||||
@@ -20,7 +20,7 @@ enum UserSessionFlowCoordinatorAction {
|
||||
}
|
||||
|
||||
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
enum HomeTab: Hashable { case chats, spaces, search }
|
||||
enum HomeTab: Hashable { case chats, spaces, search, members }
|
||||
|
||||
private let navigationRootCoordinator: NavigationRootCoordinator
|
||||
private let navigationTabCoordinator: NavigationTabCoordinator<HomeTab>
|
||||
@@ -44,6 +44,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let searchTabNavigationStackCoordinator: NavigationStackCoordinator?
|
||||
private let searchTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails?
|
||||
|
||||
private let extraTabActions: AnyPublisher<ExtraHomeTabAction, Never>?
|
||||
|
||||
private var settingsFlowCoordinator: SettingsFlowCoordinator?
|
||||
|
||||
enum State: StateType {
|
||||
@@ -128,6 +130,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
if let searchTabNavigationStackCoordinator, let searchTabDetails {
|
||||
tabs.append(.init(coordinator: searchTabNavigationStackCoordinator, details: searchTabDetails))
|
||||
}
|
||||
|
||||
if let extraTab = flowParameters.appHooks.homeTabsHook.makeExtraTab(flowParameters: flowParameters) {
|
||||
tabs.append(extraTab.tab)
|
||||
extraTabActions = extraTab.actions
|
||||
} else {
|
||||
extraTabActions = nil
|
||||
}
|
||||
|
||||
navigationTabCoordinator.setTabs(tabs)
|
||||
|
||||
stateMachine = flowParameters.stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial)
|
||||
@@ -228,6 +238,17 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
private func setupObservers() {
|
||||
extraTabActions?
|
||||
.sink { [weak self] action in
|
||||
switch action {
|
||||
case .presentRoom(let roomID):
|
||||
self?.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
|
||||
case .presentCall(let roomID, let isVoiceCall):
|
||||
self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall), animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatsTabFlowCoordinator.actionsPublisher
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// swiftlint:disable all
|
||||
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
|
||||
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable superfluous_disable_command file_length implicit_return
|
||||
|
||||
// MARK: - Strings
|
||||
|
||||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
internal nonisolated enum HealthChatL10n {
|
||||
/// Title
|
||||
internal static var commonAcademicTitle: String { return HealthChatL10n.tr("HealthChat", "common_academic_title") }
|
||||
/// Address
|
||||
internal static var commonAddress: String { return HealthChatL10n.tr("HealthChat", "common_address") }
|
||||
/// Business email
|
||||
internal static var commonBusinessEmail: String { return HealthChatL10n.tr("HealthChat", "common_business_email") }
|
||||
/// Business phone
|
||||
internal static var commonBusinessTel: String { return HealthChatL10n.tr("HealthChat", "common_business_tel") }
|
||||
/// City
|
||||
internal static var commonCity: String { return HealthChatL10n.tr("HealthChat", "common_city") }
|
||||
/// First name
|
||||
internal static var commonFirstName: String { return HealthChatL10n.tr("HealthChat", "common_first_name") }
|
||||
/// Further infos / qualifications
|
||||
internal static var commonFurtherInfo: String { return HealthChatL10n.tr("HealthChat", "common_further_info") }
|
||||
/// No.
|
||||
internal static var commonHouseNumber: String { return HealthChatL10n.tr("HealthChat", "common_house_number") }
|
||||
/// Last name
|
||||
internal static var commonLastName: String { return HealthChatL10n.tr("HealthChat", "common_last_name") }
|
||||
/// Postal code
|
||||
internal static var commonPostalCode: String { return HealthChatL10n.tr("HealthChat", "common_postal_code") }
|
||||
/// Practice name
|
||||
internal static var commonPracticeName: String { return HealthChatL10n.tr("HealthChat", "common_practice_name") }
|
||||
/// Speciality
|
||||
internal static var commonSpecialty: String { return HealthChatL10n.tr("HealthChat", "common_specialty") }
|
||||
/// Street
|
||||
internal static var commonStreet: String { return HealthChatL10n.tr("HealthChat", "common_street") }
|
||||
/// Website
|
||||
internal static var commonWebsite: String { return HealthChatL10n.tr("HealthChat", "common_website") }
|
||||
/// Email address
|
||||
internal static var screenInviteMemberEmailPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_email_placeholder") }
|
||||
/// The personal message must be at least 10 characters long.
|
||||
internal static var screenInviteMemberNoteFooter: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_note_footer") }
|
||||
/// Personal message
|
||||
internal static var screenInviteMemberNotePlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_note_placeholder") }
|
||||
/// Invitation sent
|
||||
internal static var screenInviteMemberSuccess: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_success") }
|
||||
/// Invite a member
|
||||
internal static var screenInviteMemberTitle: String { return HealthChatL10n.tr("HealthChat", "screen_invite_member_title") }
|
||||
/// Your account is being reviewed. You’ll get access to the member directory once it has been approved.
|
||||
internal static var screenMemberDirectoryPendingApproval: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_pending_approval") }
|
||||
/// Name, practice, city or speciality
|
||||
internal static var screenMemberDirectorySearchPlaceholder: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_search_placeholder") }
|
||||
/// Members
|
||||
internal static var screenMemberDirectoryTitle: String { return HealthChatL10n.tr("HealthChat", "screen_member_directory_title") }
|
||||
/// Practice address
|
||||
internal static var screenProfileEditSectionAddress: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_address") }
|
||||
/// Contact details
|
||||
internal static var screenProfileEditSectionContact: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_contact") }
|
||||
/// Personal details
|
||||
internal static var screenProfileEditSectionPersonal: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_personal") }
|
||||
/// Professional details
|
||||
internal static var screenProfileEditSectionProfessional: String { return HealthChatL10n.tr("HealthChat", "screen_profile_edit_section_professional") }
|
||||
}
|
||||
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
|
||||
// MARK: - Implementation Details
|
||||
|
||||
nonisolated extension HealthChatL10n {
|
||||
static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
|
||||
// Use preferredLocalizations to get a language that is in the bundle and the user's preferred list of languages.
|
||||
let languages = Bundle.overrideLocalizations ?? Bundle.app.preferredLocalizations
|
||||
|
||||
for language in languages {
|
||||
if let translation = trIn(language, table, key, args) {
|
||||
return translation
|
||||
}
|
||||
}
|
||||
return Bundle.app.developmentLocalization.flatMap { trIn($0, table, key, args) } ?? key
|
||||
}
|
||||
|
||||
private static func trIn(_ language: String, _ table: String, _ key: String, _ args: CVarArg...) -> String? {
|
||||
guard let bundle = Bundle.lprojBundle(for: language) else { return nil }
|
||||
let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "")
|
||||
let translation = String(format: format, locale: Locale(identifier: language), arguments: args)
|
||||
guard translation != key,
|
||||
translation != "\(key) \(key)" // Handle double pseudo for tests
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return translation
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable all
|
||||
@@ -12,6 +12,8 @@ import Foundation
|
||||
internal nonisolated enum UntranslatedL10n {
|
||||
/// Search
|
||||
internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") }
|
||||
/// Welcome to HealthChat
|
||||
internal static var screenOnboardingWelcomeTitleIos: String { return UntranslatedL10n.tr("Untranslated", "screen_onboarding_welcome_title_ios") }
|
||||
/// Search for chats and messages
|
||||
internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") }
|
||||
/// Start searching...
|
||||
|
||||
@@ -3630,6 +3630,62 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
||||
return profileForReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - requestOpenIDToken
|
||||
|
||||
private let requestOpenIDTokenCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var requestOpenIDTokenUnderlyingCallsCount = 0
|
||||
var requestOpenIDTokenCallsCount: Int {
|
||||
get { requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount } }
|
||||
set { requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var requestOpenIDTokenCalled: Bool {
|
||||
return requestOpenIDTokenCallsCount > 0
|
||||
}
|
||||
|
||||
private let requestOpenIDTokenReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var requestOpenIDTokenUnderlyingReturnValue: Result<OpenIDToken, ClientProxyError>!
|
||||
var requestOpenIDTokenReturnValue: Result<OpenIDToken, ClientProxyError>! {
|
||||
get { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue } }
|
||||
set { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var requestOpenIDTokenClosure: (() async -> Result<OpenIDToken, ClientProxyError>)?
|
||||
|
||||
@concurrent func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError> {
|
||||
requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount += 1 }
|
||||
if let requestOpenIDTokenClosure = requestOpenIDTokenClosure {
|
||||
return await requestOpenIDTokenClosure()
|
||||
} else {
|
||||
return requestOpenIDTokenReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - homeserverCredentials
|
||||
|
||||
private let homeserverCredentialsCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var homeserverCredentialsUnderlyingCallsCount = 0
|
||||
var homeserverCredentialsCallsCount: Int {
|
||||
get { homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount } }
|
||||
set { homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var homeserverCredentialsCalled: Bool {
|
||||
return homeserverCredentialsCallsCount > 0
|
||||
}
|
||||
|
||||
private let homeserverCredentialsReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var homeserverCredentialsUnderlyingReturnValue: Result<HomeserverCredentials, ClientProxyError>!
|
||||
var homeserverCredentialsReturnValue: Result<HomeserverCredentials, ClientProxyError>! {
|
||||
get { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue } }
|
||||
set { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var homeserverCredentialsClosure: (() -> Result<HomeserverCredentials, ClientProxyError>)?
|
||||
|
||||
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError> {
|
||||
homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount += 1 }
|
||||
if let homeserverCredentialsClosure = homeserverCredentialsClosure {
|
||||
return homeserverCredentialsClosure()
|
||||
} else {
|
||||
return homeserverCredentialsReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - roomDirectorySearchProxy
|
||||
|
||||
private let roomDirectorySearchProxyCallsCountLock = NSLock()
|
||||
@@ -5052,6 +5108,284 @@ nonisolated class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol,
|
||||
}
|
||||
}
|
||||
}
|
||||
nonisolated class ExtendedProfileServiceMock: ExtendedProfileServiceProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - profile
|
||||
|
||||
private let profileForCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingCallsCount = 0
|
||||
var profileForCallsCount: Int {
|
||||
get { profileForCallsCountLock.withLock { profileForUnderlyingCallsCount } }
|
||||
set { profileForCallsCountLock.withLock { profileForUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var profileForCalled: Bool {
|
||||
return profileForCallsCount > 0
|
||||
}
|
||||
private let profileForReceivedUserIDLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingReceivedUserID: String?
|
||||
var profileForReceivedUserID: String? {
|
||||
get { profileForReceivedUserIDLock.withLock { profileForUnderlyingReceivedUserID } }
|
||||
set { profileForReceivedUserIDLock.withLock { profileForUnderlyingReceivedUserID = newValue } }
|
||||
}
|
||||
private let profileForReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingReceivedInvocations: [String] = []
|
||||
var profileForReceivedInvocations: [String] {
|
||||
get { profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations } }
|
||||
set { profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let profileForReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingReturnValue: Result<ExtendedProfile, ExtendedProfileServiceError>!
|
||||
var profileForReturnValue: Result<ExtendedProfile, ExtendedProfileServiceError>! {
|
||||
get { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue } }
|
||||
set { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var profileForClosure: ((String) async -> Result<ExtendedProfile, ExtendedProfileServiceError>)?
|
||||
|
||||
@concurrent func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError> {
|
||||
profileForCallsCountLock.withLock { profileForUnderlyingCallsCount += 1 }
|
||||
profileForReceivedUserID = userID
|
||||
profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations.append(userID) }
|
||||
if let profileForClosure = profileForClosure {
|
||||
return await profileForClosure(userID)
|
||||
} else {
|
||||
return profileForReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
nonisolated class HealthChatAPIServiceMock: HealthChatAPIServiceProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - members
|
||||
|
||||
private let membersSearchQueryCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingCallsCount = 0
|
||||
var membersSearchQueryCallsCount: Int {
|
||||
get { membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount } }
|
||||
set { membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var membersSearchQueryCalled: Bool {
|
||||
return membersSearchQueryCallsCount > 0
|
||||
}
|
||||
private let membersSearchQueryReceivedSearchQueryLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingReceivedSearchQuery: String?
|
||||
var membersSearchQueryReceivedSearchQuery: String? {
|
||||
get { membersSearchQueryReceivedSearchQueryLock.withLock { membersSearchQueryUnderlyingReceivedSearchQuery } }
|
||||
set { membersSearchQueryReceivedSearchQueryLock.withLock { membersSearchQueryUnderlyingReceivedSearchQuery = newValue } }
|
||||
}
|
||||
private let membersSearchQueryReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingReceivedInvocations: [String?] = []
|
||||
var membersSearchQueryReceivedInvocations: [String?] {
|
||||
get { membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations } }
|
||||
set { membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let membersSearchQueryReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingReturnValue: Result<HealthChatMembersResponse, HealthChatAPIError>!
|
||||
var membersSearchQueryReturnValue: Result<HealthChatMembersResponse, HealthChatAPIError>! {
|
||||
get { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue } }
|
||||
set { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var membersSearchQueryClosure: ((String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError> {
|
||||
membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount += 1 }
|
||||
membersSearchQueryReceivedSearchQuery = searchQuery
|
||||
membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations.append(searchQuery) }
|
||||
if let membersSearchQueryClosure = membersSearchQueryClosure {
|
||||
return await membersSearchQueryClosure(searchQuery)
|
||||
} else {
|
||||
return membersSearchQueryReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - ownProfile
|
||||
|
||||
private let ownProfileCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var ownProfileUnderlyingCallsCount = 0
|
||||
var ownProfileCallsCount: Int {
|
||||
get { ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount } }
|
||||
set { ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var ownProfileCalled: Bool {
|
||||
return ownProfileCallsCount > 0
|
||||
}
|
||||
|
||||
private let ownProfileReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var ownProfileUnderlyingReturnValue: Result<HealthChatMember, HealthChatAPIError>!
|
||||
var ownProfileReturnValue: Result<HealthChatMember, HealthChatAPIError>! {
|
||||
get { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue } }
|
||||
set { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var ownProfileClosure: (() async -> Result<HealthChatMember, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError> {
|
||||
ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount += 1 }
|
||||
if let ownProfileClosure = ownProfileClosure {
|
||||
return await ownProfileClosure()
|
||||
} else {
|
||||
return ownProfileReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - updateOwnProfile
|
||||
|
||||
private let updateOwnProfileCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingCallsCount = 0
|
||||
var updateOwnProfileCallsCount: Int {
|
||||
get { updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount } }
|
||||
set { updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var updateOwnProfileCalled: Bool {
|
||||
return updateOwnProfileCallsCount > 0
|
||||
}
|
||||
private let updateOwnProfileReceivedUpdateLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingReceivedUpdate: HealthChatProfileUpdate?
|
||||
var updateOwnProfileReceivedUpdate: HealthChatProfileUpdate? {
|
||||
get { updateOwnProfileReceivedUpdateLock.withLock { updateOwnProfileUnderlyingReceivedUpdate } }
|
||||
set { updateOwnProfileReceivedUpdateLock.withLock { updateOwnProfileUnderlyingReceivedUpdate = newValue } }
|
||||
}
|
||||
private let updateOwnProfileReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingReceivedInvocations: [HealthChatProfileUpdate] = []
|
||||
var updateOwnProfileReceivedInvocations: [HealthChatProfileUpdate] {
|
||||
get { updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations } }
|
||||
set { updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let updateOwnProfileReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingReturnValue: Result<Void, HealthChatAPIError>!
|
||||
var updateOwnProfileReturnValue: Result<Void, HealthChatAPIError>! {
|
||||
get { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue } }
|
||||
set { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var updateOwnProfileClosure: ((HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError> {
|
||||
updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount += 1 }
|
||||
updateOwnProfileReceivedUpdate = update
|
||||
updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations.append(update) }
|
||||
if let updateOwnProfileClosure = updateOwnProfileClosure {
|
||||
return await updateOwnProfileClosure(update)
|
||||
} else {
|
||||
return updateOwnProfileReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - profileCompleteness
|
||||
|
||||
private let profileCompletenessCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var profileCompletenessUnderlyingCallsCount = 0
|
||||
var profileCompletenessCallsCount: Int {
|
||||
get { profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount } }
|
||||
set { profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var profileCompletenessCalled: Bool {
|
||||
return profileCompletenessCallsCount > 0
|
||||
}
|
||||
|
||||
private let profileCompletenessReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var profileCompletenessUnderlyingReturnValue: Result<HealthChatProfileCompleteness, HealthChatAPIError>!
|
||||
var profileCompletenessReturnValue: Result<HealthChatProfileCompleteness, HealthChatAPIError>! {
|
||||
get { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue } }
|
||||
set { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var profileCompletenessClosure: (() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError> {
|
||||
profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount += 1 }
|
||||
if let profileCompletenessClosure = profileCompletenessClosure {
|
||||
return await profileCompletenessClosure()
|
||||
} else {
|
||||
return profileCompletenessReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - inviteMember
|
||||
|
||||
private let inviteMemberEmailPersonalNoteCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingCallsCount = 0
|
||||
var inviteMemberEmailPersonalNoteCallsCount: Int {
|
||||
get { inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount } }
|
||||
set { inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var inviteMemberEmailPersonalNoteCalled: Bool {
|
||||
return inviteMemberEmailPersonalNoteCallsCount > 0
|
||||
}
|
||||
private let inviteMemberEmailPersonalNoteReceivedArgumentsLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReceivedArguments: (email: String, personalNote: String)?
|
||||
var inviteMemberEmailPersonalNoteReceivedArguments: (email: String, personalNote: String)? {
|
||||
get { inviteMemberEmailPersonalNoteReceivedArgumentsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedArguments } }
|
||||
set { inviteMemberEmailPersonalNoteReceivedArgumentsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedArguments = newValue } }
|
||||
}
|
||||
private let inviteMemberEmailPersonalNoteReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations: [(email: String, personalNote: String)] = []
|
||||
var inviteMemberEmailPersonalNoteReceivedInvocations: [(email: String, personalNote: String)] {
|
||||
get { inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations } }
|
||||
set { inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let inviteMemberEmailPersonalNoteReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReturnValue: Result<Void, HealthChatAPIError>!
|
||||
var inviteMemberEmailPersonalNoteReturnValue: Result<Void, HealthChatAPIError>! {
|
||||
get { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue } }
|
||||
set { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var inviteMemberEmailPersonalNoteClosure: ((String, String) async -> Result<Void, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError> {
|
||||
inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount += 1 }
|
||||
inviteMemberEmailPersonalNoteReceivedArguments = (email: email, personalNote: personalNote)
|
||||
inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations.append((email: email, personalNote: personalNote)) }
|
||||
if let inviteMemberEmailPersonalNoteClosure = inviteMemberEmailPersonalNoteClosure {
|
||||
return await inviteMemberEmailPersonalNoteClosure(email, personalNote)
|
||||
} else {
|
||||
return inviteMemberEmailPersonalNoteReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
nonisolated class HealthChatOpenIDTokenProviderMock: HealthChatOpenIDTokenProviderProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - validToken
|
||||
|
||||
private let validTokenCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var validTokenUnderlyingCallsCount = 0
|
||||
var validTokenCallsCount: Int {
|
||||
get { validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount } }
|
||||
set { validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var validTokenCalled: Bool {
|
||||
return validTokenCallsCount > 0
|
||||
}
|
||||
|
||||
private let validTokenReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var validTokenUnderlyingReturnValue: Result<String, HealthChatAPIError>!
|
||||
var validTokenReturnValue: Result<String, HealthChatAPIError>! {
|
||||
get { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue } }
|
||||
set { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var validTokenClosure: (() async -> Result<String, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func validToken() async -> Result<String, HealthChatAPIError> {
|
||||
validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount += 1 }
|
||||
if let validTokenClosure = validTokenClosure {
|
||||
return await validTokenClosure()
|
||||
} else {
|
||||
return validTokenReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - invalidateToken
|
||||
|
||||
private let invalidateTokenCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var invalidateTokenUnderlyingCallsCount = 0
|
||||
var invalidateTokenCallsCount: Int {
|
||||
get { invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount } }
|
||||
set { invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var invalidateTokenCalled: Bool {
|
||||
return invalidateTokenCallsCount > 0
|
||||
}
|
||||
nonisolated(unsafe) var invalidateTokenClosure: (() -> Void)?
|
||||
|
||||
func invalidateToken() {
|
||||
invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount += 1 }
|
||||
invalidateTokenClosure?()
|
||||
}
|
||||
}
|
||||
nonisolated class HomeserverCapabilitiesProxyMock: HomeserverCapabilitiesProxyProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - refresh
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NetworkMonitorReachability {
|
||||
nonisolated enum NetworkMonitorReachability {
|
||||
case reachable
|
||||
case unreachable
|
||||
}
|
||||
|
||||
enum HomeserverReachability {
|
||||
nonisolated enum HomeserverReachability {
|
||||
/// The store is open and the homeserver is reachable.
|
||||
case reachable
|
||||
/// The homeserver can't be reached even though we're trying to sync (i.e. offline).
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RoundedCornerShape: Shape {
|
||||
nonisolated struct RoundedCornerShape: Shape {
|
||||
let radius: CGFloat
|
||||
let corners: UIRectCorner
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ enum TestablePreviewsDictionary {
|
||||
"FormattingToolbar_Previews" : FormattingToolbar_Previews.self,
|
||||
"FullscreenDialog_Previews" : FullscreenDialog_Previews.self,
|
||||
"GalleryRoomTimelineView_Previews" : GalleryRoomTimelineView_Previews.self,
|
||||
"HealthChatExtendedProfileSection_Previews" : HealthChatExtendedProfileSection_Previews.self,
|
||||
"HealthChatInviteMemberScreen_Previews" : HealthChatInviteMemberScreen_Previews.self,
|
||||
"HealthChatMemberDirectoryScreen_Previews" : HealthChatMemberDirectoryScreen_Previews.self,
|
||||
"HealthChatProfileEditScreen_Previews" : HealthChatProfileEditScreen_Previews.self,
|
||||
"HighlightedTimelineItemModifier_Previews" : HighlightedTimelineItemModifier_Previews.self,
|
||||
"HomeScreenEmptyStateView_Previews" : HomeScreenEmptyStateView_Previews.self,
|
||||
"HomeScreenInviteCell_Previews" : HomeScreenInviteCell_Previews.self,
|
||||
|
||||
@@ -84,7 +84,7 @@ private struct ViewSizeKey: PreferenceKey {
|
||||
}
|
||||
}
|
||||
|
||||
private struct WaveformShape: Shape {
|
||||
private nonisolated struct WaveformShape: Shape {
|
||||
let lineWidth: CGFloat
|
||||
let linePadding: CGFloat
|
||||
let waveformData: [Float]
|
||||
|
||||
+8
-7
@@ -111,13 +111,14 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
|
||||
|
||||
private func login(classicAppAccount: ClassicAppAccount? = nil) async {
|
||||
if let classicAppAccount {
|
||||
if classicAppAccount.state.availableSecrets == .requiresBackup {
|
||||
state.bindings.showClassicAppBackupInstructions = true
|
||||
} else {
|
||||
await configureAccountProvider(classicAppAccount.serverName,
|
||||
loginHint: "mxid:\(classicAppAccount.userID)",
|
||||
fallbackHomeserverURL: classicAppAccount.homeserverURL)
|
||||
}
|
||||
// HealthChat diverges from upstream here. Upstream sends `.requiresBackup` accounts to a sheet
|
||||
// asking the user to set up key backup in Element Classic first. HealthChat ships with the same
|
||||
// bundle id as the legacy app, so this update *replaces* it — there is no Classic app left to
|
||||
// open, and the sheet's only button resolves to nothing. Fall through to a normal sign-in
|
||||
// instead: the user re-verifies and loses only history that by definition has no backup.
|
||||
await configureAccountProvider(classicAppAccount.serverName,
|
||||
loginHint: "mxid:\(classicAppAccount.userID)",
|
||||
fallbackHomeserverURL: classicAppAccount.homeserverURL)
|
||||
} else if let serverName = state.serverName {
|
||||
await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ struct AuthenticationStartScreen: View {
|
||||
|
||||
if !context.viewState.hideBrandChrome {
|
||||
VStack(spacing: 8) {
|
||||
Text(L10n.screenOnboardingWelcomeTitle)
|
||||
Text(UntranslatedL10n.screenOnboardingWelcomeTitleIos)
|
||||
.font(.compound.headingLGBold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct HealthChatInviteMemberScreenCoordinatorParameters {
|
||||
let apiService: HealthChatAPIServiceProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
enum HealthChatInviteMemberScreenCoordinatorAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
final class HealthChatInviteMemberScreenCoordinator: CoordinatorProtocol {
|
||||
private let viewModel: HealthChatInviteMemberScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<HealthChatInviteMemberScreenCoordinatorAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<HealthChatInviteMemberScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: HealthChatInviteMemberScreenCoordinatorParameters) {
|
||||
viewModel = HealthChatInviteMemberScreenViewModel(apiService: parameters.apiService,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(HealthChatInviteMemberScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum HealthChatInviteMemberScreenViewModelAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
struct HealthChatInviteMemberScreenViewState: BindableState {
|
||||
/// The backend requires a personal note of at least this many characters.
|
||||
static let minimumNoteLength = 10
|
||||
|
||||
var bindings = HealthChatInviteMemberScreenViewStateBindings()
|
||||
|
||||
var canSend: Bool {
|
||||
isEmailValid && isNoteValid
|
||||
}
|
||||
|
||||
var isEmailValid: Bool {
|
||||
let email = bindings.email.trimmingCharacters(in: .whitespaces)
|
||||
let components = email.split(separator: "@")
|
||||
return components.count == 2 && components[1].contains(".")
|
||||
}
|
||||
|
||||
var isNoteValid: Bool {
|
||||
bindings.personalNote.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumNoteLength
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthChatInviteMemberScreenViewStateBindings {
|
||||
var email = ""
|
||||
var personalNote = ""
|
||||
|
||||
var alertInfo: AlertInfo<HealthChatInviteMemberScreenAlertType>?
|
||||
}
|
||||
|
||||
enum HealthChatInviteMemberScreenAlertType: Hashable {
|
||||
case sendFailed
|
||||
}
|
||||
|
||||
enum HealthChatInviteMemberScreenViewAction {
|
||||
case send
|
||||
case cancel
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias HealthChatInviteMemberScreenViewModelType = StateStoreViewModelV2<HealthChatInviteMemberScreenViewState, HealthChatInviteMemberScreenViewAction>
|
||||
|
||||
class HealthChatInviteMemberScreenViewModel: HealthChatInviteMemberScreenViewModelType, HealthChatInviteMemberScreenViewModelProtocol {
|
||||
private let apiService: HealthChatAPIServiceProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<HealthChatInviteMemberScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<HealthChatInviteMemberScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(apiService: HealthChatAPIServiceProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.apiService = apiService
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
super.init(initialViewState: HealthChatInviteMemberScreenViewState(), mediaProvider: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: HealthChatInviteMemberScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .send:
|
||||
Task { await sendInvitation() }
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func sendInvitation() async {
|
||||
let indicatorID = "\(Self.self)-Sending"
|
||||
defer { userIndicatorController.retractIndicatorWithId(indicatorID) }
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: indicatorID,
|
||||
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
|
||||
title: L10n.commonLoading,
|
||||
persistent: true),
|
||||
delay: .milliseconds(100))
|
||||
|
||||
let email = state.bindings.email.trimmingCharacters(in: .whitespaces)
|
||||
let personalNote = state.bindings.personalNote.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
switch await apiService.inviteMember(email: email, personalNote: personalNote) {
|
||||
case .success:
|
||||
userIndicatorController.submitIndicator(UserIndicator(title: HealthChatL10n.screenInviteMemberSuccess, icon: \.check))
|
||||
actionsSubject.send(.dismiss)
|
||||
case .failure:
|
||||
state.bindings.alertInfo = .init(id: .sendFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol HealthChatInviteMemberScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<HealthChatInviteMemberScreenViewModelAction, Never> { get }
|
||||
var context: HealthChatInviteMemberScreenViewModelType.Context { get }
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct HealthChatInviteMemberScreen: View {
|
||||
@Bindable var context: HealthChatInviteMemberScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
ListRow(label: .plain(title: HealthChatL10n.screenInviteMemberEmailPlaceholder),
|
||||
kind: .textField(text: $context.email, axis: .horizontal))
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
ListRow(label: .plain(title: HealthChatL10n.screenInviteMemberNotePlaceholder),
|
||||
kind: .textField(text: $context.personalNote, axis: .vertical))
|
||||
.lineLimit(4...)
|
||||
} footer: {
|
||||
Text(HealthChatL10n.screenInviteMemberNoteFooter)
|
||||
.compoundListSectionFooter()
|
||||
}
|
||||
}
|
||||
.compoundList()
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.navigationTitle(HealthChatL10n.screenInviteMemberTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L10n.actionSend) {
|
||||
context.send(viewAction: .send)
|
||||
}
|
||||
.disabled(!context.viewState.canSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct HealthChatInviteMemberScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = HealthChatInviteMemberScreenViewModel(apiService: HealthChatAPIServiceMock(),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
HealthChatInviteMemberScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("Invite")
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct HealthChatMemberDirectoryScreenCoordinatorParameters {
|
||||
let apiService: HealthChatAPIServiceProtocol
|
||||
let clientProxy: ClientProxyProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
enum HealthChatMemberDirectoryScreenCoordinatorAction {
|
||||
case selectMember(userID: String)
|
||||
case presentRoom(roomID: String)
|
||||
case editProfile
|
||||
case invite
|
||||
}
|
||||
|
||||
final class HealthChatMemberDirectoryScreenCoordinator: CoordinatorProtocol {
|
||||
private let viewModel: HealthChatMemberDirectoryScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<HealthChatMemberDirectoryScreenCoordinatorAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<HealthChatMemberDirectoryScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: HealthChatMemberDirectoryScreenCoordinatorParameters) {
|
||||
viewModel = HealthChatMemberDirectoryScreenViewModel(apiService: parameters.apiService,
|
||||
clientProxy: parameters.clientProxy,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .selectMember(let userID):
|
||||
actionsSubject.send(.selectMember(userID: userID))
|
||||
case .presentRoom(let roomID):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
case .editProfile:
|
||||
actionsSubject.send(.editProfile)
|
||||
case .invite:
|
||||
actionsSubject.send(.invite)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(HealthChatMemberDirectoryScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum HealthChatMemberDirectoryScreenViewModelAction {
|
||||
case selectMember(userID: String)
|
||||
case presentRoom(roomID: String)
|
||||
case editProfile
|
||||
case invite
|
||||
}
|
||||
|
||||
nonisolated enum HealthChatMemberDirectoryScreenContent: Equatable {
|
||||
case loading
|
||||
case members([HealthChatMember])
|
||||
case pendingApproval(message: String)
|
||||
case failure
|
||||
}
|
||||
|
||||
struct HealthChatMemberDirectoryScreenViewState: BindableState {
|
||||
var content: HealthChatMemberDirectoryScreenContent = .loading
|
||||
|
||||
/// Inviting others is only available to approved accounts.
|
||||
var isApproved = false
|
||||
|
||||
/// The hint shown while the own profile is missing required fields.
|
||||
var completenessMessage: String?
|
||||
|
||||
var bindings = HealthChatMemberDirectoryScreenViewStateBindings()
|
||||
}
|
||||
|
||||
struct HealthChatMemberDirectoryScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
var alertInfo: AlertInfo<HealthChatMemberDirectoryScreenAlertType>?
|
||||
}
|
||||
|
||||
enum HealthChatMemberDirectoryScreenAlertType: Hashable {
|
||||
case failedStartingChat
|
||||
}
|
||||
|
||||
enum HealthChatMemberDirectoryScreenViewAction {
|
||||
case retry
|
||||
case selectMember(HealthChatMember)
|
||||
case startChat(HealthChatMember)
|
||||
case editProfile
|
||||
case invite
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import AsyncAlgorithms
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias HealthChatMemberDirectoryScreenViewModelType = StateStoreViewModelV2<HealthChatMemberDirectoryScreenViewState, HealthChatMemberDirectoryScreenViewAction>
|
||||
|
||||
class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenViewModelType, HealthChatMemberDirectoryScreenViewModelProtocol {
|
||||
private let apiService: HealthChatAPIServiceProtocol
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private var searchQueryObservationTask: Task<Void, Never>?
|
||||
// periphery:ignore - auto cancels when reassigned
|
||||
@CancellableTask private var fetchTask: Task<Void, Never>?
|
||||
|
||||
private let actionsSubject: PassthroughSubject<HealthChatMemberDirectoryScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<HealthChatMemberDirectoryScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(apiService: HealthChatAPIServiceProtocol,
|
||||
clientProxy: ClientProxyProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.apiService = apiService
|
||||
self.clientProxy = clientProxy
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
super.init(initialViewState: HealthChatMemberDirectoryScreenViewState(), mediaProvider: mediaProvider)
|
||||
|
||||
fetchMembers()
|
||||
|
||||
// The stream replays the current query, which the initial fetch above already covers.
|
||||
let searchQueryStream = context.observe(\.viewState.bindings.searchQuery)
|
||||
.debounce(for: .milliseconds(300))
|
||||
.removeDuplicates()
|
||||
.dropFirst()
|
||||
searchQueryObservationTask = Task { [weak self] in
|
||||
for await _ in searchQueryStream {
|
||||
self?.fetchMembers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isolated deinit {
|
||||
searchQueryObservationTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: HealthChatMemberDirectoryScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .retry:
|
||||
state.content = .loading
|
||||
fetchMembers()
|
||||
case .selectMember(let member):
|
||||
actionsSubject.send(.selectMember(userID: member.userID))
|
||||
case .startChat(let member):
|
||||
Task { await startChat(with: member) }
|
||||
case .editProfile:
|
||||
actionsSubject.send(.editProfile)
|
||||
case .invite:
|
||||
actionsSubject.send(.invite)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func fetchMembers() {
|
||||
fetchTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let searchQuery = state.bindings.searchQuery
|
||||
let result = await apiService.members(searchQuery: searchQuery.isEmpty ? nil : searchQuery)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let response) where !response.isApproved:
|
||||
state.isApproved = false
|
||||
state.completenessMessage = nil
|
||||
state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval)
|
||||
case .success(let response):
|
||||
state.isApproved = true
|
||||
await updateCompletenessMessage()
|
||||
guard !Task.isCancelled else { return }
|
||||
state.content = .members(response.users)
|
||||
case .failure:
|
||||
state.content = .failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCompletenessMessage() async {
|
||||
// Best-effort hint — a failed check just hides it.
|
||||
guard case let .success(completeness) = await apiService.profileCompleteness() else {
|
||||
state.completenessMessage = nil
|
||||
return
|
||||
}
|
||||
state.completenessMessage = completeness.isComplete ? nil : completeness.message
|
||||
}
|
||||
|
||||
private func startChat(with member: HealthChatMember) async {
|
||||
switch clientProxy.directRoomForUserID(member.userID) {
|
||||
case .success(let roomID?):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
case .success(nil):
|
||||
showLoadingIndicator()
|
||||
defer { hideLoadingIndicator() }
|
||||
|
||||
switch await clientProxy.createDirectRoom(with: member.userID, expectedRoomName: member.displayName) {
|
||||
case .success(let roomID):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
case .failure:
|
||||
state.bindings.alertInfo = .init(id: .failedStartingChat,
|
||||
title: L10n.commonError,
|
||||
message: L10n.screenStartChatErrorStartingChat)
|
||||
}
|
||||
case .failure:
|
||||
state.bindings.alertInfo = .init(id: .failedStartingChat,
|
||||
title: L10n.commonError,
|
||||
message: L10n.screenStartChatErrorStartingChat)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: User Indicators
|
||||
|
||||
private var loadingIndicatorIdentifier: String {
|
||||
"\(Self.self)-Loading"
|
||||
}
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier,
|
||||
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: false),
|
||||
title: L10n.commonLoading,
|
||||
persistent: true),
|
||||
delay: .milliseconds(100))
|
||||
}
|
||||
|
||||
private func hideLoadingIndicator() {
|
||||
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol HealthChatMemberDirectoryScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<HealthChatMemberDirectoryScreenViewModelAction, Never> { get }
|
||||
var context: HealthChatMemberDirectoryScreenViewModelType.Context { get }
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct HealthChatMemberDirectoryScreen: View {
|
||||
@Bindable var context: HealthChatMemberDirectoryScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(HealthChatL10n.screenMemberDirectoryTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.searchController(query: $context.searchQuery,
|
||||
placeholder: HealthChatL10n.screenMemberDirectorySearchPlaceholder)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
context.send(viewAction: .editProfile)
|
||||
} label: {
|
||||
CompoundIcon(\.edit)
|
||||
}
|
||||
.accessibilityLabel(L10n.screenEditProfileTitle)
|
||||
}
|
||||
|
||||
if context.viewState.isApproved {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
context.send(viewAction: .invite)
|
||||
} label: {
|
||||
CompoundIcon(\.userAdd)
|
||||
}
|
||||
.accessibilityLabel(HealthChatL10n.screenInviteMemberTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch context.viewState.content {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .pendingApproval(let message):
|
||||
statusView(icon: \.time, message: message)
|
||||
case .failure:
|
||||
failureView
|
||||
case .members(let members) where members.isEmpty:
|
||||
statusView(icon: \.search, message: L10n.commonNoResults)
|
||||
case .members(let members):
|
||||
memberList(members)
|
||||
}
|
||||
}
|
||||
|
||||
private func memberList(_ members: [HealthChatMember]) -> some View {
|
||||
Form {
|
||||
if let completenessMessage = context.viewState.completenessMessage {
|
||||
Section {
|
||||
ListRow(label: .default(title: completenessMessage, icon: \.info),
|
||||
kind: .button {
|
||||
context.send(viewAction: .editProfile)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(members) { member in
|
||||
HealthChatMemberListRow(member: member, mediaProvider: context.mediaProvider) {
|
||||
context.send(viewAction: .selectMember(member))
|
||||
} startChatAction: {
|
||||
context.send(viewAction: .startChat(member))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.compoundList()
|
||||
}
|
||||
|
||||
private var failureView: some View {
|
||||
VStack(spacing: 16) {
|
||||
statusContent(icon: \.error, message: L10n.commonSomethingWentWrong)
|
||||
|
||||
Button(L10n.actionRetry) {
|
||||
context.send(viewAction: .retry)
|
||||
}
|
||||
.buttonStyle(.compound(.secondary, size: .medium))
|
||||
.fixedSize()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
private func statusView(icon: KeyPath<CompoundIcons, Image>, message: String) -> some View {
|
||||
statusContent(icon: icon, message: message)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
private func statusContent(icon: KeyPath<CompoundIcons, Image>, message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
BigIcon(icon: icon)
|
||||
|
||||
Text(message)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct HealthChatMemberDirectoryScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let loadedViewModel = makeViewModel(configuration: .members)
|
||||
static let incompleteProfileViewModel = makeViewModel(configuration: .incompleteProfile)
|
||||
static let pendingViewModel = makeViewModel(configuration: .pendingApproval)
|
||||
static let failureViewModel = makeViewModel(configuration: .failure)
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
HealthChatMemberDirectoryScreen(context: loadedViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: loadedViewModel.context.observe(\.viewState.content).map { $0 != .loading })
|
||||
.previewDisplayName("Members")
|
||||
|
||||
ElementNavigationStack {
|
||||
HealthChatMemberDirectoryScreen(context: incompleteProfileViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: incompleteProfileViewModel.context.observe(\.viewState.completenessMessage).map { $0 != nil })
|
||||
.previewDisplayName("Incomplete profile")
|
||||
|
||||
ElementNavigationStack {
|
||||
HealthChatMemberDirectoryScreen(context: pendingViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: pendingViewModel.context.observe(\.viewState.content).map { $0 != .loading })
|
||||
.previewDisplayName("Pending approval")
|
||||
|
||||
ElementNavigationStack {
|
||||
HealthChatMemberDirectoryScreen(context: failureViewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: failureViewModel.context.observe(\.viewState.content).map { $0 != .loading })
|
||||
.previewDisplayName("Failure")
|
||||
}
|
||||
|
||||
enum PreviewConfiguration {
|
||||
case members
|
||||
case incompleteProfile
|
||||
case pendingApproval
|
||||
case failure
|
||||
}
|
||||
|
||||
static func makeViewModel(configuration: PreviewConfiguration) -> HealthChatMemberDirectoryScreenViewModel {
|
||||
let apiService = HealthChatAPIServiceMock()
|
||||
|
||||
switch configuration {
|
||||
case .members:
|
||||
let members = try? JSONDecoder().decode([HealthChatMember].self, from: Data("""
|
||||
[
|
||||
{
|
||||
"userId": "@alice:healthchat.ch",
|
||||
"displayName": "Dr. Alice Alpha",
|
||||
"specialty": "Physiotherapie",
|
||||
"practiceName": "Praxis Alpha",
|
||||
"businessCity": "Zürich"
|
||||
},
|
||||
{
|
||||
"userId": "@bob:healthchat.ch",
|
||||
"displayName": "Bob Beta",
|
||||
"specialty": "Ergotherapie",
|
||||
"businessCity": "Basel"
|
||||
},
|
||||
{
|
||||
"userId": "@carol:healthchat.ch",
|
||||
"displayName": "Dr. Carol Gamma"
|
||||
}
|
||||
]
|
||||
""".utf8))
|
||||
apiService.membersSearchQueryReturnValue = .success(.init(users: members ?? [],
|
||||
isApproved: true,
|
||||
pendingMessage: nil))
|
||||
apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self,
|
||||
from: Data(#"{ "isComplete": true }"#.utf8)))
|
||||
case .incompleteProfile:
|
||||
let member = try? JSONDecoder().decode(HealthChatMember.self, from: Data("""
|
||||
{ "userId": "@alice:healthchat.ch", "displayName": "Dr. Alice Alpha", "specialty": "Physiotherapie" }
|
||||
""".utf8))
|
||||
apiService.membersSearchQueryReturnValue = .success(.init(users: member.map { [$0] } ?? [],
|
||||
isApproved: true,
|
||||
pendingMessage: nil))
|
||||
apiService.profileCompletenessReturnValue = try? .success(JSONDecoder().decode(HealthChatProfileCompleteness.self,
|
||||
from: Data(#"{ "isComplete": false, "message": "Profil unvollständig – Fehlende Angaben: Fachgebiet, Ort" }"#.utf8)))
|
||||
case .pendingApproval:
|
||||
apiService.membersSearchQueryReturnValue = .success(.init(users: [],
|
||||
isApproved: false,
|
||||
pendingMessage: "Ihr Konto wird derzeit geprüft. Sie erhalten Zugriff auf das Mitgliederverzeichnis, sobald Ihr Konto freigegeben wurde."))
|
||||
case .failure:
|
||||
apiService.membersSearchQueryReturnValue = .failure(.networkFailure)
|
||||
}
|
||||
|
||||
return HealthChatMemberDirectoryScreenViewModel(apiService: apiService,
|
||||
clientProxy: ClientProxyMock(.init()),
|
||||
mediaProvider: MediaProviderMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct HealthChatMemberListRow: View {
|
||||
let member: HealthChatMember
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let selectAction: () -> Void
|
||||
let startChatAction: () -> Void
|
||||
|
||||
private var subtitle: String {
|
||||
[member.specialty, member.practiceName, member.businessCity]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ListRow(kind: .custom {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: selectAction) {
|
||||
HStack(spacing: 12) {
|
||||
LoadableAvatarImage(url: member.avatarURL,
|
||||
name: member.displayName,
|
||||
contentID: member.userID,
|
||||
avatarSize: .user(on: .startChat),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(member.displayName ?? member.userID)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: startChatAction) {
|
||||
CompoundIcon(\.chat)
|
||||
.foregroundColor(.compound.iconAccentTertiary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel(L10n.actionStartChat)
|
||||
}
|
||||
.padding(.horizontal, ListRowPadding.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
})
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct HealthChatProfileEditScreenCoordinatorParameters {
|
||||
let orientationManager: OrientationManagerProtocol
|
||||
let userSession: UserSessionProtocol
|
||||
let apiService: HealthChatAPIServiceProtocol
|
||||
let mediaUploadingPreprocessor: MediaUploadingPreprocessor
|
||||
weak var navigationStackCoordinator: NavigationStackCoordinator?
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
}
|
||||
|
||||
final class HealthChatProfileEditScreenCoordinator: CoordinatorProtocol, UserDetailsEditCoordinatorProtocol {
|
||||
private let parameters: HealthChatProfileEditScreenCoordinatorParameters
|
||||
private let viewModel: HealthChatProfileEditScreenViewModelProtocol
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<UserDetailsEditScreenCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<UserDetailsEditScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: HealthChatProfileEditScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = HealthChatProfileEditScreenViewModel(userSession: parameters.userSession,
|
||||
apiService: parameters.apiService,
|
||||
mediaUploadingPreprocessor: parameters.mediaUploadingPreprocessor,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
}
|
||||
|
||||
func start() {
|
||||
viewModel.actionsPublisher
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .displayCameraPicker:
|
||||
displayMediaPickerWithMode(.init(source: .camera, selectionType: .single))
|
||||
case .displayMediaPicker:
|
||||
displayMediaPickerWithMode(.init(source: .photoLibrary, selectionType: .single))
|
||||
case .displayFilePicker:
|
||||
displayMediaPickerWithMode(.init(source: .documents(types: [.image]), selectionType: .single))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(HealthChatProfileEditScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func displayMediaPickerWithMode(_ mode: MediaPickerScreenMode) {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let mediaPickerCoordinator = MediaPickerScreenCoordinator(mode: mode,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
orientationManager: parameters.orientationManager) { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .cancel:
|
||||
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
|
||||
case .selectedMediaAtURLs(let urls):
|
||||
guard urls.count == 1,
|
||||
let url = urls.first else {
|
||||
fatalError("Received an invalid number of URLs")
|
||||
}
|
||||
|
||||
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
|
||||
viewModel.didSelectMediaURL(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
|
||||
parameters.navigationStackCoordinator?.setSheetCoordinator(stackCoordinator)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum HealthChatProfileEditScreenViewModelAction {
|
||||
case dismiss
|
||||
case displayCameraPicker
|
||||
case displayMediaPicker
|
||||
case displayFilePicker
|
||||
}
|
||||
|
||||
struct HealthChatProfileEditScreenViewState: BindableState {
|
||||
var initialProfile: HealthChatMember?
|
||||
var loadFailed = false
|
||||
|
||||
var canEditAvatar = false
|
||||
var selectedAvatarURL: URL?
|
||||
var localMedia: MediaInfo?
|
||||
|
||||
var bindings = HealthChatProfileEditScreenViewStateBindings()
|
||||
|
||||
var avatarDidChange: Bool {
|
||||
localMedia != nil || selectedAvatarURL != initialProfile?.avatarURL
|
||||
}
|
||||
|
||||
/// The changed text fields as a partial update, nil when nothing changed.
|
||||
var fieldsUpdate: HealthChatProfileUpdate? {
|
||||
guard let initialProfile else { return nil }
|
||||
|
||||
var update = HealthChatProfileUpdate()
|
||||
// nil leaves a field unchanged server-side, an empty string clears it.
|
||||
func diff(_ binding: String, _ initialValue: String?) -> String? {
|
||||
let value = binding.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value == (initialValue ?? "") ? nil : value
|
||||
}
|
||||
|
||||
update.title = diff(bindings.title, initialProfile.title)
|
||||
update.firstName = diff(bindings.firstName, initialProfile.firstName)
|
||||
update.lastName = diff(bindings.lastName, initialProfile.lastName)
|
||||
update.specialty = diff(bindings.specialty, initialProfile.specialty)
|
||||
update.practiceName = diff(bindings.practiceName, initialProfile.practiceName)
|
||||
update.businessCity = diff(bindings.businessCity, initialProfile.businessCity)
|
||||
update.businessEmail = diff(bindings.businessEmail, initialProfile.businessEmail)
|
||||
update.businessTel = diff(bindings.businessTel, initialProfile.businessTel)
|
||||
update.furtherInfo = diff(bindings.furtherInfo, initialProfile.furtherInfo)
|
||||
update.street = diff(bindings.street, initialProfile.street)
|
||||
update.houseNumber = diff(bindings.houseNumber, initialProfile.houseNumber)
|
||||
update.postalCode = diff(bindings.postalCode, initialProfile.postalCode)
|
||||
update.website = diff(bindings.website, initialProfile.website)
|
||||
|
||||
return update == HealthChatProfileUpdate() ? nil : update
|
||||
}
|
||||
|
||||
/// First name, last name, speciality, practice and city are required (web client parity).
|
||||
var requiredFieldsAreValid: Bool {
|
||||
let requiredFields = [bindings.firstName, bindings.lastName, bindings.specialty,
|
||||
bindings.practiceName, bindings.businessCity]
|
||||
guard requiredFields.allSatisfy({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let website = bindings.website.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return website.isEmpty || URL(string: website) != nil
|
||||
}
|
||||
|
||||
var canSave: Bool {
|
||||
initialProfile != nil && requiredFieldsAreValid && (avatarDidChange || fieldsUpdate != nil)
|
||||
}
|
||||
|
||||
var showDeleteImageAction: Bool {
|
||||
localMedia != nil || selectedAvatarURL != nil
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthChatProfileEditScreenViewStateBindings {
|
||||
var title = ""
|
||||
var firstName = ""
|
||||
var lastName = ""
|
||||
var specialty = ""
|
||||
var practiceName = ""
|
||||
var businessCity = ""
|
||||
var businessEmail = ""
|
||||
var businessTel = ""
|
||||
var furtherInfo = ""
|
||||
var street = ""
|
||||
var houseNumber = ""
|
||||
var postalCode = ""
|
||||
var website = ""
|
||||
|
||||
var showMediaSheet = false
|
||||
|
||||
var alertInfo: AlertInfo<HealthChatProfileEditScreenAlertType>?
|
||||
}
|
||||
|
||||
enum HealthChatProfileEditScreenAlertType {
|
||||
case failedProcessingMedia
|
||||
case unsavedChanges
|
||||
case saveError
|
||||
case unknown
|
||||
}
|
||||
|
||||
enum HealthChatProfileEditScreenViewAction {
|
||||
case cancel
|
||||
case save
|
||||
case retry
|
||||
case presentMediaSource
|
||||
case displayCameraPicker
|
||||
case displayMediaPicker
|
||||
case displayFilePicker
|
||||
case removeImage
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias HealthChatProfileEditScreenViewModelType = StateStoreViewModelV2<HealthChatProfileEditScreenViewState, HealthChatProfileEditScreenViewAction>
|
||||
|
||||
class HealthChatProfileEditScreenViewModel: HealthChatProfileEditScreenViewModelType, HealthChatProfileEditScreenViewModelProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let apiService: HealthChatAPIServiceProtocol
|
||||
private let mediaUploadingPreprocessor: MediaUploadingPreprocessor
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<HealthChatProfileEditScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<HealthChatProfileEditScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
apiService: HealthChatAPIServiceProtocol,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
clientProxy = userSession.clientProxy
|
||||
self.apiService = apiService
|
||||
self.mediaUploadingPreprocessor = mediaUploadingPreprocessor
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
super.init(initialViewState: HealthChatProfileEditScreenViewState(), mediaProvider: userSession.mediaProvider)
|
||||
|
||||
Task { await loadProfile() }
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: HealthChatProfileEditScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
showUnsavedChangesAlert() // The cancel button is only shown when there are unsaved changes.
|
||||
case .save:
|
||||
Task { await saveProfile() }
|
||||
case .retry:
|
||||
Task { await loadProfile() }
|
||||
case .presentMediaSource:
|
||||
state.bindings.showMediaSheet = true
|
||||
case .displayCameraPicker:
|
||||
actionsSubject.send(.displayCameraPicker)
|
||||
case .displayMediaPicker:
|
||||
actionsSubject.send(.displayMediaPicker)
|
||||
case .displayFilePicker:
|
||||
actionsSubject.send(.displayFilePicker)
|
||||
case .removeImage:
|
||||
state.localMedia = nil
|
||||
state.selectedAvatarURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
func didSelectMediaURL(url: URL) {
|
||||
Task {
|
||||
let userIndicatorID = UUID().uuidString
|
||||
defer { userIndicatorController.retractIndicatorWithId(userIndicatorID) }
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID,
|
||||
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
|
||||
title: L10n.commonLoading,
|
||||
persistent: true))
|
||||
|
||||
guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else {
|
||||
MXLog.error("Failed to get max upload size")
|
||||
state.bindings.alertInfo = .init(id: .unknown)
|
||||
return
|
||||
}
|
||||
let mediaResult = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize)
|
||||
|
||||
switch mediaResult {
|
||||
case .success(.image):
|
||||
state.localMedia = try? mediaResult.get()
|
||||
case .failure, .success:
|
||||
state.bindings.alertInfo = .init(id: .failedProcessingMedia)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadProfile() async {
|
||||
state.loadFailed = false
|
||||
|
||||
state.canEditAvatar = await clientProxy.capabilities.canChangeAvatar()
|
||||
|
||||
switch await apiService.ownProfile() {
|
||||
case .success(let profile):
|
||||
state.selectedAvatarURL = profile.avatarURL
|
||||
state.bindings.title = profile.title ?? ""
|
||||
state.bindings.firstName = profile.firstName ?? ""
|
||||
state.bindings.lastName = profile.lastName ?? ""
|
||||
state.bindings.specialty = profile.specialty ?? ""
|
||||
state.bindings.practiceName = profile.practiceName ?? ""
|
||||
state.bindings.businessCity = profile.businessCity ?? ""
|
||||
state.bindings.businessEmail = profile.businessEmail ?? ""
|
||||
state.bindings.businessTel = profile.businessTel ?? ""
|
||||
state.bindings.furtherInfo = profile.furtherInfo ?? ""
|
||||
state.bindings.street = profile.street ?? ""
|
||||
state.bindings.houseNumber = profile.houseNumber ?? ""
|
||||
state.bindings.postalCode = profile.postalCode ?? ""
|
||||
state.bindings.website = profile.website ?? ""
|
||||
state.initialProfile = profile
|
||||
case .failure:
|
||||
state.loadFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
private func showUnsavedChangesAlert() {
|
||||
state.bindings.alertInfo = .init(id: .unsavedChanges,
|
||||
title: L10n.dialogUnsavedChangesTitle,
|
||||
message: L10n.dialogUnsavedChangesDescription,
|
||||
primaryButton: .init(title: L10n.actionSave) { Task { await self.saveProfile() } },
|
||||
secondaryButton: .init(title: L10n.actionDiscard, role: .cancel) { self.actionsSubject.send(.dismiss) })
|
||||
}
|
||||
|
||||
private func saveProfile() async {
|
||||
let userIndicatorID = UUID().uuidString
|
||||
defer { userIndicatorController.retractIndicatorWithId(userIndicatorID) }
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID,
|
||||
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
|
||||
title: L10n.screenEditProfileUpdatingDetails,
|
||||
persistent: true))
|
||||
|
||||
do {
|
||||
if state.avatarDidChange {
|
||||
try await saveAvatar()
|
||||
}
|
||||
|
||||
// The fields go second so the derived display name reflects the latest names.
|
||||
if let update = state.fieldsUpdate {
|
||||
try await apiService.updateOwnProfile(update).get()
|
||||
}
|
||||
|
||||
actionsSubject.send(.dismiss)
|
||||
} catch {
|
||||
MXLog.error("Failed saving the HealthChat profile with error: \(error)")
|
||||
state.bindings.alertInfo = .init(id: .saveError,
|
||||
title: L10n.screenEditProfileErrorTitle,
|
||||
message: L10n.screenEditProfileError)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAvatar() async throws {
|
||||
if let localMedia = state.localMedia {
|
||||
try await clientProxy.setUserAvatar(media: localMedia).get()
|
||||
} else if state.selectedAvatarURL == nil {
|
||||
try await clientProxy.removeUserAvatar().get()
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol HealthChatProfileEditScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<HealthChatProfileEditScreenViewModelAction, Never> { get }
|
||||
var context: HealthChatProfileEditScreenViewModelType.Context { get }
|
||||
|
||||
func didSelectMediaURL(url: URL)
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
//
|
||||
// Copyright 2026 Novaloop AG
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct HealthChatProfileEditScreen: View {
|
||||
@Bindable var context: HealthChatProfileEditScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(L10n.screenEditProfileTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(context.viewState.canSave)
|
||||
.toolbar { toolbar }
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if context.viewState.loadFailed {
|
||||
failureView
|
||||
} else {
|
||||
form
|
||||
}
|
||||
}
|
||||
|
||||
private var form: some View {
|
||||
Form {
|
||||
Section {
|
||||
avatar
|
||||
}
|
||||
|
||||
Section {
|
||||
textFieldRow(HealthChatL10n.commonAcademicTitle, text: $context.title)
|
||||
textFieldRow(requiredLabel(HealthChatL10n.commonFirstName), text: $context.firstName)
|
||||
textFieldRow(requiredLabel(HealthChatL10n.commonLastName), text: $context.lastName)
|
||||
} header: {
|
||||
Text(HealthChatL10n.screenProfileEditSectionPersonal)
|
||||
.compoundListSectionHeader()
|
||||
}
|
||||
|
||||
Section {
|
||||
textFieldRow(requiredLabel(HealthChatL10n.commonSpecialty), text: $context.specialty)
|
||||
textFieldRow(requiredLabel(HealthChatL10n.commonPracticeName), text: $context.practiceName)
|
||||
textFieldRow(requiredLabel(HealthChatL10n.commonCity), text: $context.businessCity)
|
||||
textFieldRow(HealthChatL10n.commonFurtherInfo, text: $context.furtherInfo, axis: .vertical)
|
||||
} header: {
|
||||
Text(HealthChatL10n.screenProfileEditSectionProfessional)
|
||||
.compoundListSectionHeader()
|
||||
}
|
||||
|
||||
Section {
|
||||
textFieldRow(HealthChatL10n.commonBusinessEmail, text: $context.businessEmail)
|
||||
textFieldRow(HealthChatL10n.commonBusinessTel, text: $context.businessTel)
|
||||
} header: {
|
||||
Text(HealthChatL10n.screenProfileEditSectionContact)
|
||||
.compoundListSectionHeader()
|
||||
}
|
||||
|
||||
Section {
|
||||
textFieldRow(HealthChatL10n.commonStreet, text: $context.street)
|
||||
textFieldRow(HealthChatL10n.commonHouseNumber, text: $context.houseNumber)
|
||||
textFieldRow(HealthChatL10n.commonPostalCode, text: $context.postalCode)
|
||||
textFieldRow(HealthChatL10n.commonWebsite, text: $context.website)
|
||||
} header: {
|
||||
Text(HealthChatL10n.screenProfileEditSectionAddress)
|
||||
.compoundListSectionHeader()
|
||||
}
|
||||
}
|
||||
.compoundList()
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.disabled(context.viewState.initialProfile == nil)
|
||||
}
|
||||
|
||||
private var failureView: some View {
|
||||
VStack(spacing: 16) {
|
||||
BigIcon(icon: \.error)
|
||||
|
||||
Text(L10n.commonSomethingWentWrong)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button(L10n.actionRetry) {
|
||||
context.send(viewAction: .retry)
|
||||
}
|
||||
.buttonStyle(.compound(.secondary, size: .medium))
|
||||
.fixedSize()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
if context.viewState.canSave {
|
||||
Button(L10n.actionCancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L10n.actionSave) {
|
||||
context.send(viewAction: .save)
|
||||
}
|
||||
.disabled(!context.viewState.canSave)
|
||||
}
|
||||
}
|
||||
|
||||
private var avatar: some View {
|
||||
Button {
|
||||
context.send(viewAction: .presentMediaSource)
|
||||
} label: {
|
||||
OverridableAvatarImage(overrideURL: context.viewState.localMedia?.thumbnailURL,
|
||||
url: context.viewState.selectedAvatarURL,
|
||||
name: context.viewState.initialProfile?.displayName,
|
||||
contentID: context.viewState.initialProfile?.userID ?? "",
|
||||
shape: .circle,
|
||||
avatarSize: .user(on: .editUserDetails),
|
||||
mediaProvider: context.mediaProvider)
|
||||
.confirmationDialog("", isPresented: $context.showMediaSheet) {
|
||||
mediaActionSheet
|
||||
}
|
||||
}
|
||||
.buttonStyle(EditAvatarButtonStyle())
|
||||
.disabled(!context.viewState.canEditAvatar)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private func textFieldRow(_ label: String, text: Binding<String>, axis: Axis = .horizontal) -> some View {
|
||||
ListRow(label: .plain(title: label),
|
||||
kind: .textField(text: text, axis: axis))
|
||||
}
|
||||
|
||||
private func requiredLabel(_ label: String) -> String {
|
||||
"\(label) *"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaActionSheet: some View {
|
||||
Button {
|
||||
context.send(viewAction: .displayCameraPicker)
|
||||
} label: {
|
||||
Text(L10n.actionTakePhoto)
|
||||
}
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .displayMediaPicker)
|
||||
} label: {
|
||||
Text(L10n.actionChoosePhoto)
|
||||
}
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .displayFilePicker)
|
||||
} label: {
|
||||
Text(L10n.actionChooseFile)
|
||||
}
|
||||
|
||||
if context.viewState.showDeleteImageAction {
|
||||
Button(role: .destructive) {
|
||||
context.send(viewAction: .removeImage)
|
||||
} label: {
|
||||
Text(L10n.actionRemove)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct HealthChatProfileEditScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
ElementNavigationStack {
|
||||
HealthChatProfileEditScreen(context: viewModel.context)
|
||||
}
|
||||
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.initialProfile).map { $0 != nil })
|
||||
.previewDisplayName("Profile")
|
||||
}
|
||||
|
||||
static func makeViewModel() -> HealthChatProfileEditScreenViewModel {
|
||||
let apiService = HealthChatAPIServiceMock()
|
||||
let member = try? JSONDecoder().decode(HealthChatMember.self, from: Data("""
|
||||
{
|
||||
"userId": "@alice:healthchat.ch",
|
||||
"displayName": "Dr. Alice Alpha",
|
||||
"title": "Dr. med.",
|
||||
"firstName": "Alice",
|
||||
"lastName": "Alpha",
|
||||
"specialty": "Physiotherapie",
|
||||
"practiceName": "Praxis Alpha",
|
||||
"businessCity": "Zürich",
|
||||
"businessEmail": "praxis@example.ch",
|
||||
"businessTel": "+41 44 123 45 67",
|
||||
"street": "Friedaustrasse",
|
||||
"houseNumber": "17",
|
||||
"postalCode": "8003",
|
||||
"website": "https://example.ch"
|
||||
}
|
||||
""".utf8))
|
||||
if let member {
|
||||
apiService.ownProfileReturnValue = .success(member)
|
||||
}
|
||||
|
||||
return HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))),
|
||||
apiService: apiService,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ enum RoomMemberDetailsScreenViewModelAction {
|
||||
|
||||
struct RoomMemberDetailsScreenViewState: BindableState {
|
||||
let userID: String
|
||||
let appHooks: AppHooks
|
||||
var memberDetails: RoomMemberDetails?
|
||||
var verificationState: UserIdentityVerificationState?
|
||||
var isOwnMemberDetails = false
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.analytics = analytics
|
||||
|
||||
let initialViewState = RoomMemberDetailsScreenViewState(userID: userID, bindings: .init())
|
||||
let initialViewState = RoomMemberDetailsScreenViewState(userID: userID, appHooks: appHooks, bindings: .init())
|
||||
|
||||
super.init(initialViewState: appHooks.roomMemberDetailsScreenHook.update(initialViewState),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
@@ -16,6 +16,8 @@ struct RoomMemberDetailsScreen: View {
|
||||
Form {
|
||||
headerSection
|
||||
|
||||
context.viewState.appHooks.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
|
||||
|
||||
if context.viewState.showVerifyIdentitySection {
|
||||
verificationSection
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct UserProfileScreenViewState: BindableState {
|
||||
let userID: String
|
||||
let isOwnUser: Bool
|
||||
let isPresentedModally: Bool
|
||||
let appHooks: AppHooks
|
||||
|
||||
var userProfile: UserProfile?
|
||||
var isIdentityKnown = false
|
||||
|
||||
@@ -35,6 +35,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
||||
let initialViewState = UserProfileScreenViewState(userID: userID,
|
||||
isOwnUser: userID == userSession.clientProxy.userID,
|
||||
isPresentedModally: isPresentedModally,
|
||||
appHooks: appHooks,
|
||||
bindings: .init())
|
||||
|
||||
super.init(initialViewState: appHooks.userProfileScreenHook.update(initialViewState),
|
||||
|
||||
@@ -15,6 +15,8 @@ struct UserProfileScreen: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
headerSection
|
||||
|
||||
context.viewState.appHooks.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
|
||||
}
|
||||
.compoundList()
|
||||
.navigationTitle(L10n.screenRoomMemberDetailsTitle)
|
||||
|
||||
@@ -159,7 +159,7 @@ nonisolated struct ClientFactory: ClientFactoryProtocol {
|
||||
private func build(_ builder: ClientBuilder,
|
||||
for mode: BuildMode,
|
||||
appHooks: AppHooks) async throws -> ClientProtocol {
|
||||
var client: ClientProtocol = try await appHooks.clientFactoryHook.configure(builder, toRestore: mode.session).build()
|
||||
let client: ClientProtocol = try await appHooks.clientFactoryHook.configure(builder, toRestore: mode.session).build()
|
||||
|
||||
switch mode {
|
||||
case .authentication:
|
||||
|
||||
@@ -838,6 +838,28 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError> {
|
||||
do {
|
||||
return try await .success(.init(rustValue: client.requestOpenidToken()))
|
||||
} catch {
|
||||
MXLog.error("Failed requesting OpenID token with error: \(error)")
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
|
||||
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError> {
|
||||
do {
|
||||
let session = try client.session()
|
||||
guard let homeserverURL = URL(string: session.homeserverUrl) else {
|
||||
return .failure(.invalidServerName)
|
||||
}
|
||||
return .success(.init(homeserverURL: homeserverURL, accessToken: session.accessToken))
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving the session's credentials with error: \(error)")
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
|
||||
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol {
|
||||
RoomDirectorySearchProxy(roomDirectorySearch: client.roomDirectorySearch())
|
||||
}
|
||||
|
||||
@@ -244,6 +244,14 @@ protocol ClientProxyProtocol: AnyObject {
|
||||
|
||||
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError>
|
||||
|
||||
/// An OpenID token for authenticating against services that trust the homeserver (MSC1961).
|
||||
/// Short-lived — cache per call site.
|
||||
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError>
|
||||
|
||||
/// The session's REST credentials for client-server API calls the SDK doesn't cover.
|
||||
/// The access token can rotate after a refresh, so re-read for every request.
|
||||
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError>
|
||||
|
||||
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol
|
||||
|
||||
func resolveRoomAlias(_ alias: String) async -> Result<ResolvedRoomAlias, ClientProxyError>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The session's REST credentials for direct client-server API calls that the SDK doesn't cover.
|
||||
/// The access token can rotate after a refresh, so re-read these for every request and never persist them.
|
||||
nonisolated struct HomeserverCredentials: Equatable {
|
||||
let homeserverURL: URL
|
||||
let accessToken: String
|
||||
}
|
||||
|
||||
extension HomeserverCredentials: CustomStringConvertible {
|
||||
var description: String {
|
||||
"HomeserverCredentials(homeserverURL: \(homeserverURL), accessToken: <redacted>)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
/// An OpenID token that third-party services can use to verify the user's identity
|
||||
/// against the homeserver (MSC1961). Short-lived — cache per call site.
|
||||
nonisolated struct OpenIDToken: Equatable {
|
||||
let accessToken: String
|
||||
let matrixServerName: String
|
||||
/// The interval after which the token expires, relative to when it was requested.
|
||||
let expiresIn: TimeInterval
|
||||
|
||||
init(accessToken: String, matrixServerName: String, expiresIn: TimeInterval) {
|
||||
self.accessToken = accessToken
|
||||
self.matrixServerName = matrixServerName
|
||||
self.expiresIn = expiresIn
|
||||
}
|
||||
|
||||
init(rustValue: MatrixRustSDK.OpenIdToken) {
|
||||
accessToken = rustValue.accessToken
|
||||
matrixServerName = rustValue.matrixServerName
|
||||
expiresIn = TimeInterval(rustValue.expiresInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenIDToken: CustomStringConvertible {
|
||||
var description: String {
|
||||
"OpenIDToken(matrixServerName: \(matrixServerName), accessToken: <redacted>)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A user's profile including the HealthChat extended profile fields (MSC4133),
|
||||
/// as returned by `GET /_matrix/client/v3/profile/{userId}`.
|
||||
nonisolated struct ExtendedProfile: Equatable {
|
||||
let displayName: String?
|
||||
let avatarURL: URL?
|
||||
let title: String?
|
||||
let firstName: String?
|
||||
let lastName: String?
|
||||
let specialty: String?
|
||||
let practiceName: String?
|
||||
let furtherInfo: String?
|
||||
let businessEmail: String?
|
||||
let businessTel: String?
|
||||
let street: String?
|
||||
let houseNumber: String?
|
||||
let postalCode: String?
|
||||
let city: String?
|
||||
let website: String?
|
||||
|
||||
/// Whether any of the HealthChat-specific fields carry a value.
|
||||
var hasContent: Bool {
|
||||
[title, firstName, lastName, specialty, practiceName, furtherInfo,
|
||||
businessEmail, businessTel, street, houseNumber, postalCode, city, website]
|
||||
.contains { $0 != nil }
|
||||
}
|
||||
|
||||
/// The postal address in the same "street nr, plz city" format the web client uses.
|
||||
var formattedAddress: String? {
|
||||
let streetLine = [street, houseNumber].compactMap { $0 }.joined(separator: " ")
|
||||
let cityLine = [postalCode, city].compactMap { $0 }.joined(separator: " ")
|
||||
let lines = [streetLine, cityLine].filter { !$0.isEmpty }
|
||||
return lines.isEmpty ? nil : lines.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
extension ExtendedProfile: Decodable {
|
||||
private struct StringKey: CodingKey {
|
||||
let stringValue: String
|
||||
var intValue: Int? {
|
||||
nil
|
||||
}
|
||||
|
||||
init(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: StringKey.self)
|
||||
|
||||
// Presence of the canonical key suppresses the legacy key even when its value is
|
||||
// empty, so a field the user cleared isn't resurrected by stale legacy data.
|
||||
// Empty and non-string values read as nil.
|
||||
func value(_ canonicalKey: String, legacyKey: String? = nil) -> String? {
|
||||
for key in [canonicalKey, legacyKey].compactMap({ $0 }) {
|
||||
guard container.contains(StringKey(stringValue: key)) else { continue }
|
||||
let raw = try? container.decode(String.self, forKey: .init(stringValue: key))
|
||||
return raw.flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
displayName = value("displayname")
|
||||
avatarURL = value("avatar_url").flatMap(URL.init(string:))
|
||||
title = value("io.element.profile.title")
|
||||
firstName = value("io.element.profile.first_name")
|
||||
lastName = value("io.element.profile.last_name")
|
||||
specialty = value("io.element.profile.specialization")
|
||||
practiceName = value("io.element.profile.practice_name")
|
||||
furtherInfo = value("io.element.profile.further_info")
|
||||
businessEmail = value("io.element.profile.business_email")
|
||||
businessTel = value("io.element.profile.business_tel")
|
||||
street = value("io.element.profile.business_street", legacyKey: "io.element.profile.street")
|
||||
houseNumber = value("io.element.profile.business_street_nr", legacyKey: "io.element.profile.house_number")
|
||||
postalCode = value("io.element.profile.business_plz", legacyKey: "io.element.profile.postal_code")
|
||||
city = value("io.element.profile.business_city")
|
||||
website = value("io.element.profile.website")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Reads profiles straight from the homeserver's profile endpoint, which — unlike the
|
||||
/// Rust SDK's `getProfile` — includes the extended profile fields (MSC4133).
|
||||
final class ExtendedProfileService: ExtendedProfileServiceProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let urlSession: URLSession
|
||||
|
||||
init(clientProxy: ClientProxyProtocol, urlSession: URLSession = .shared) {
|
||||
self.clientProxy = clientProxy
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError> {
|
||||
guard case let .success(credentials) = clientProxy.homeserverCredentials() else {
|
||||
return .failure(.missingCredentials)
|
||||
}
|
||||
|
||||
let url = credentials.homeserverURL.appending(path: "_matrix/client/v3/profile/\(userID)")
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
let (responseData, response) = try await urlSession.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
MXLog.error("Failed fetching the extended profile for \(userID) with status: \(httpResponse.statusCode)")
|
||||
return .failure(.httpStatus(httpResponse.statusCode))
|
||||
}
|
||||
|
||||
data = responseData
|
||||
} catch {
|
||||
MXLog.error("Failed fetching the extended profile for \(userID) with error: \(error)")
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
|
||||
do {
|
||||
return try .success(JSONDecoder().decode(ExtendedProfile.self, from: data))
|
||||
} catch {
|
||||
MXLog.error("Failed decoding the extended profile for \(userID) with error: \(error)")
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ExtendedProfileServiceError: Error, Equatable {
|
||||
case missingCredentials
|
||||
case networkFailure
|
||||
case decodingFailure
|
||||
case httpStatus(Int)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol ExtendedProfileServiceProtocol {
|
||||
func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError>
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A member record as returned by the HealthChat API (`MatrixUser` in the backend).
|
||||
/// Every field except the user ID is optional so additive backend changes never break decoding.
|
||||
nonisolated struct HealthChatMember: Identifiable, Equatable {
|
||||
let userID: String
|
||||
let displayName: String?
|
||||
let avatarURL: URL?
|
||||
let title: String?
|
||||
let firstName: String?
|
||||
let lastName: String?
|
||||
let specialty: String?
|
||||
let practiceName: String?
|
||||
let businessCity: String?
|
||||
let businessEmail: String?
|
||||
let businessTel: String?
|
||||
let furtherInfo: String?
|
||||
let street: String?
|
||||
let houseNumber: String?
|
||||
let postalCode: String?
|
||||
let website: String?
|
||||
let isApproved: Bool
|
||||
|
||||
var id: String {
|
||||
userID
|
||||
}
|
||||
}
|
||||
|
||||
extension HealthChatMember: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case userID = "userId"
|
||||
case avatarURL = "avatarMxcUrl"
|
||||
case displayName, title, firstName, lastName, specialty, practiceName
|
||||
case businessCity, businessEmail, businessTel, furtherInfo
|
||||
case street, houseNumber, postalCode, website, isApproved
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
func value(_ key: CodingKeys) -> String? {
|
||||
let raw = try? container.decodeIfPresent(String.self, forKey: key)
|
||||
return (raw ?? nil).flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
|
||||
userID = try container.decode(String.self, forKey: .userID)
|
||||
displayName = value(.displayName)
|
||||
avatarURL = value(.avatarURL).flatMap(URL.init(string:))
|
||||
title = value(.title)
|
||||
firstName = value(.firstName)
|
||||
lastName = value(.lastName)
|
||||
specialty = value(.specialty)
|
||||
practiceName = value(.practiceName)
|
||||
businessCity = value(.businessCity)
|
||||
businessEmail = value(.businessEmail)
|
||||
businessTel = value(.businessTel)
|
||||
furtherInfo = value(.furtherInfo)
|
||||
street = value(.street)
|
||||
houseNumber = value(.houseNumber)
|
||||
postalCode = value(.postalCode)
|
||||
website = value(.website)
|
||||
isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
/// The response of `GET /api/usermatrix/users` (`MatrixUserSearchResult` in the backend).
|
||||
nonisolated struct HealthChatMembersResponse: Equatable {
|
||||
let users: [HealthChatMember]
|
||||
let isApproved: Bool
|
||||
/// The hint shown to accounts that haven't been approved yet.
|
||||
let pendingMessage: String?
|
||||
}
|
||||
|
||||
extension HealthChatMembersResponse: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case users, isApproved, pendingMessage
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
users = try container.decodeIfPresent([HealthChatMember].self, forKey: .users) ?? []
|
||||
isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true
|
||||
let message = try container.decodeIfPresent(String.self, forKey: .pendingMessage)
|
||||
pendingMessage = message.flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A partial profile update for `PUT /api/usermatrix/profile` (`UpdateProfileRequest` in the backend).
|
||||
/// `nil` leaves a field unchanged, an empty string clears it.
|
||||
nonisolated struct HealthChatProfileUpdate: Encodable, Equatable {
|
||||
var firstName: String?
|
||||
var lastName: String?
|
||||
var title: String?
|
||||
var specialty: String?
|
||||
var practiceName: String?
|
||||
var businessCity: String?
|
||||
var businessEmail: String?
|
||||
var businessTel: String?
|
||||
var furtherInfo: String?
|
||||
var street: String?
|
||||
var houseNumber: String?
|
||||
var postalCode: String?
|
||||
var website: String?
|
||||
}
|
||||
|
||||
/// The response of `GET /api/usermatrix/profile/completeness`.
|
||||
nonisolated struct HealthChatProfileCompleteness: Equatable {
|
||||
let isComplete: Bool
|
||||
let missingFields: [String]
|
||||
let message: String?
|
||||
let hasProfilePhoto: Bool
|
||||
}
|
||||
|
||||
extension HealthChatProfileCompleteness: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case isComplete, missingFields, message, hasProfilePhoto
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false
|
||||
missingFields = try container.decodeIfPresent([String].self, forKey: .missingFields) ?? []
|
||||
let rawMessage = try container.decodeIfPresent(String.self, forKey: .message)
|
||||
message = rawMessage.flatMap { $0.isEmpty ? nil : $0 }
|
||||
hasProfilePhoto = try container.decodeIfPresent(Bool.self, forKey: .hasProfilePhoto) ?? false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The client for the HealthChat backend (member directory, own profile, invitations).
|
||||
/// Authenticates with an OpenID token that the backend verifies against the homeserver.
|
||||
final class HealthChatAPIService: HealthChatAPIServiceProtocol {
|
||||
private enum Method: String {
|
||||
case get = "GET"
|
||||
case put = "PUT"
|
||||
case post = "POST"
|
||||
}
|
||||
|
||||
private let baseURL: URL
|
||||
private let tokenProvider: HealthChatOpenIDTokenProviderProtocol
|
||||
private let urlSession: URLSession
|
||||
|
||||
init(baseURL: URL = HealthChatSettings.apiBaseURL,
|
||||
tokenProvider: HealthChatOpenIDTokenProviderProtocol,
|
||||
urlSession: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.tokenProvider = tokenProvider
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError> {
|
||||
var queryItems = [URLQueryItem]()
|
||||
if let searchQuery, !searchQuery.isEmpty {
|
||||
queryItems.append(.init(name: "search", value: searchQuery))
|
||||
}
|
||||
return await request(.get, path: "api/usermatrix/users", queryItems: queryItems)
|
||||
}
|
||||
|
||||
func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile")
|
||||
}
|
||||
|
||||
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError> {
|
||||
guard let body = try? JSONEncoder().encode(update) else {
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
return await send(.put, path: "api/usermatrix/profile", body: body).map { _ in }
|
||||
}
|
||||
|
||||
func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile/completeness")
|
||||
}
|
||||
|
||||
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError> {
|
||||
struct InvitationRequest: Encodable {
|
||||
let email: String
|
||||
let personalNote: String
|
||||
}
|
||||
|
||||
guard let body = try? JSONEncoder().encode(InvitationRequest(email: email, personalNote: personalNote)) else {
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
return await send(.post, path: "api/usermatrix/invite", body: body).map { _ in }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func request<Response: Decodable>(_ method: Method,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Data? = nil) async -> Result<Response, HealthChatAPIError> {
|
||||
switch await send(method, path: path, queryItems: queryItems, body: body) {
|
||||
case .success(let data):
|
||||
do {
|
||||
return try .success(JSONDecoder().decode(Response.self, from: data))
|
||||
} catch {
|
||||
MXLog.error("Failed decoding the HealthChat API response from \(path) with error: \(error)")
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ method: Method,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Data? = nil) async -> Result<Data, HealthChatAPIError> {
|
||||
for attempt in 0...1 {
|
||||
let token: String
|
||||
switch await tokenProvider.validToken() {
|
||||
case .success(let validToken):
|
||||
token = validToken
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
var url = baseURL.appending(path: path)
|
||||
if !queryItems.isEmpty {
|
||||
url.append(queryItems: queryItems)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await urlSession.data(for: request)
|
||||
} catch {
|
||||
MXLog.error("The HealthChat API request to \(path) failed with error: \(error)")
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200...299:
|
||||
return .success(data)
|
||||
case 401 where attempt == 0:
|
||||
// The cached OpenID token may have expired server-side — retry once with a fresh one.
|
||||
tokenProvider.invalidateToken()
|
||||
case 401:
|
||||
return .failure(.unauthorized)
|
||||
default:
|
||||
MXLog.error("The HealthChat API request to \(path) failed with status: \(httpResponse.statusCode)")
|
||||
return .failure(.httpStatus(httpResponse.statusCode))
|
||||
}
|
||||
}
|
||||
|
||||
return .failure(.unauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum HealthChatAPIError: Error, Equatable {
|
||||
case tokenAcquisitionFailed
|
||||
case unauthorized
|
||||
case networkFailure
|
||||
case decodingFailure
|
||||
case httpStatus(Int)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol HealthChatAPIServiceProtocol {
|
||||
func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError>
|
||||
|
||||
func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError>
|
||||
|
||||
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError>
|
||||
|
||||
func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError>
|
||||
|
||||
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError>
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol HealthChatOpenIDTokenProviderProtocol {
|
||||
/// A valid OpenID access token, from cache when possible.
|
||||
func validToken() async -> Result<String, HealthChatAPIError>
|
||||
|
||||
/// Drops the cached token, e.g. after the API rejected it.
|
||||
func invalidateToken()
|
||||
}
|
||||
|
||||
final class HealthChatOpenIDTokenProvider: HealthChatOpenIDTokenProviderProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let dateProvider: () -> Date
|
||||
|
||||
/// Tokens this close to expiry are refreshed early, matching the web client's buffer.
|
||||
private static let expiryBuffer: TimeInterval = 60
|
||||
|
||||
private var cachedToken: (token: String, expiry: Date)?
|
||||
|
||||
init(clientProxy: ClientProxyProtocol, dateProvider: @escaping () -> Date = Date.init) {
|
||||
self.clientProxy = clientProxy
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
func validToken() async -> Result<String, HealthChatAPIError> {
|
||||
if let cachedToken, dateProvider().addingTimeInterval(Self.expiryBuffer) < cachedToken.expiry {
|
||||
return .success(cachedToken.token)
|
||||
}
|
||||
|
||||
switch await clientProxy.requestOpenIDToken() {
|
||||
case .success(let token):
|
||||
cachedToken = (token.accessToken, dateProvider().addingTimeInterval(token.expiresIn))
|
||||
return .success(token.accessToken)
|
||||
case .failure:
|
||||
return .failure(.tokenAcquisitionFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateToken() {
|
||||
cachedToken = nil
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,9 @@
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:element.io</string>
|
||||
<string>applinks:app.element.io</string>
|
||||
<string>applinks:staging.element.io</string>
|
||||
<string>applinks:develop.element.io</string>
|
||||
<string>applinks:mobile.element.io</string>
|
||||
<string>applinks:app.healthchat.ch</string>
|
||||
<string>applinks:matrix.to</string>
|
||||
<string>webcredentials:*.element.io</string>
|
||||
<string>webcredentials:*.healthchat.ch</string>
|
||||
</array>
|
||||
<key>com.apple.developer.usernotifications.communication</key>
|
||||
<true/>
|
||||
@@ -21,7 +17,6 @@
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
<string>$(CLASSIC_APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<string>$(APP_DISPLAY_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -51,6 +51,9 @@ targets:
|
||||
UILaunchScreen:
|
||||
UIColorName: colors/background-color
|
||||
CFBundleDisplayName: $(APP_DISPLAY_NAME)
|
||||
# HealthChat: without this CFBundleName defaults to $(PRODUCT_NAME) (ElementX, kept for the module
|
||||
# name), which is what the web authentication session's sign-in alert shows — not CFBundleDisplayName.
|
||||
CFBundleName: $(APP_DISPLAY_NAME)
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
CFBundleURLTypes: [
|
||||
@@ -118,22 +121,23 @@ targets:
|
||||
properties:
|
||||
aps-environment: development
|
||||
com.apple.developer.associated-domains:
|
||||
- applinks:element.io
|
||||
- applinks:app.element.io
|
||||
- applinks:staging.element.io
|
||||
- applinks:develop.element.io
|
||||
- applinks:mobile.element.io
|
||||
# HealthChat: app.healthchat.ch universal links + webcredentials (OAuth/autofill). matrix.to kept for permalinks.
|
||||
- applinks:app.healthchat.ch
|
||||
- applinks:matrix.to
|
||||
# - applinks:localhost?mode=developer # developer mode only works with https (but self-signed is fine).
|
||||
- webcredentials:*.element.io
|
||||
- webcredentials:*.healthchat.ch
|
||||
com.apple.developer.usernotifications.communication: true
|
||||
com.apple.security.app-sandbox: true
|
||||
com.apple.security.application-groups:
|
||||
# HealthChat: dropped $(CLASSIC_APP_GROUP_IDENTIFIER) — not because it is un-provisionable,
|
||||
# but because it resolves to group.ch.healthchat, the same value as $(APP_GROUP_IDENTIFIER)
|
||||
# below. The legacy container is already entitled; listing it twice would duplicate the entry.
|
||||
- $(APP_GROUP_IDENTIFIER)
|
||||
- $(CLASSIC_APP_GROUP_IDENTIFIER)
|
||||
com.apple.security.network.client: true
|
||||
keychain-access-groups:
|
||||
- $(KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
|
||||
# HealthChat: the legacy app's keychain group. ClassicAppManager reads the account and
|
||||
# crypto-store keys from here to migrate existing users. Same team prefix, so it is
|
||||
# provisionable — unlike Element's im.vector.app.* default in app.yml.
|
||||
- $(CLASSIC_APP_KEYCHAIN_ACCESS_GROUP_IDENTIFIER)
|
||||
|
||||
settings:
|
||||
|
||||
+1
-1
Submodule Enterprise updated: 30d2ad29a8...adca548afe
@@ -0,0 +1,6 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1269.0)
|
||||
aws-sdk-core (3.254.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.130.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.227.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (1.5.0)
|
||||
logger
|
||||
faraday (1.10.6)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.237.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.3.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-plugin-brew (0.1.1)
|
||||
fastlane-plugin-xcodegen (1.1.0)
|
||||
fastlane-plugin-brew (~> 0.1.1)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.104.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.64.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.2.2)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.8.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.22.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-25
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
fastlane-plugin-xcodegen
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1269.0) sha256=80beca6c8a35ddfc11b0b529424ea6167ace3da92cd7368847ce694679dedea5
|
||||
aws-sdk-core (3.254.0) sha256=ee3e3220b8468a3c9e59daba18e6ec897bf5c7ce8adcc0670cfa2f1f092112fe
|
||||
aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749
|
||||
aws-sdk-s3 (1.227.0) sha256=552b23bf9a37c7db4957e9291543e61c8de886cf31d1d45ea3b429df0feea939
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50
|
||||
faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689
|
||||
faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750
|
||||
faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940
|
||||
faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682
|
||||
faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335
|
||||
faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7
|
||||
faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0
|
||||
faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa
|
||||
faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9
|
||||
fastlane-plugin-brew (0.1.1) sha256=c2d1c98ead82f78818621b5b1eb123e0bf9da7e1a7b3efeaac77a776725a7fbf
|
||||
fastlane-plugin-xcodegen (1.1.0) sha256=9bbed6c2ea21ca6869567e350d0b62cf41770949d15e40869058d689f1a6d06d
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff
|
||||
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
|
||||
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
|
||||
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
|
||||
google-apis-storage_v1 (0.64.0) sha256=75b11afa2edcee859b84c7a6972ee4456314eeef5f762827fd6cf5c5ffaf93f2
|
||||
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
|
||||
google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7
|
||||
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
|
||||
google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330
|
||||
google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b
|
||||
googleauth (1.17.1) sha256=0f7e6fc70e204cee1b2d71f1e1de2d3b349d432404197fe68ebf7fa23d0821b9
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
|
||||
httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583
|
||||
jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
|
||||
retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
||||
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
|
||||
security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
|
||||
signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
|
||||
terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.3
|
||||
@@ -0,0 +1,16 @@
|
||||
# HealthChat Changes
|
||||
|
||||
Release notes for HealthChat builds of Element X. Each release adds a section at the
|
||||
top; the release workflow extracts the one matching the version being released and
|
||||
uses it as the TestFlight "What to Test" text.
|
||||
|
||||
The heading format is load-bearing — `.github/workflows/release-healthchat.yml`
|
||||
matches `## Changes in <version>` exactly.
|
||||
|
||||
Upstream's `CHANGES.md` tracks element-hq's own releases and must not be edited here.
|
||||
|
||||
## Changes in 26.07.3 (2026-07-15)
|
||||
|
||||
🙌 Improvements
|
||||
|
||||
- First HealthChat release built from Element X.
|
||||
@@ -2,8 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.usernotifications.filtering</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
|
||||
@@ -62,7 +62,9 @@ targets:
|
||||
entitlements:
|
||||
path: NSE.entitlements
|
||||
properties:
|
||||
com.apple.developer.usernotifications.filtering: true
|
||||
# usernotifications.filtering deliberately omitted: Apple has not granted it for ch.healthchat.element.nse
|
||||
# (request pending since 2026-07-30) and a profile without it cannot sign. Discarded notifications show
|
||||
# the raw push payload instead. Restore the key and NSE Filtering in the profile once approved.
|
||||
com.apple.security.application-groups:
|
||||
- $(APP_GROUP_IDENTIFIER)
|
||||
keychain-access-groups:
|
||||
|
||||
@@ -340,6 +340,34 @@ extension PreviewTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func healthChatExtendedProfileSection() async throws {
|
||||
for (index, preview) in HealthChatExtendedProfileSection_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func healthChatInviteMemberScreen() async throws {
|
||||
for (index, preview) in HealthChatInviteMemberScreen_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func healthChatMemberDirectoryScreen() async throws {
|
||||
for (index, preview) in HealthChatMemberDirectoryScreen_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func healthChatProfileEditScreen() async throws {
|
||||
for (index, preview) in HealthChatProfileEditScreen_Previews._allPreviews.enumerated() {
|
||||
try await assertSnapshots(matching: preview, step: index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func highlightedTimelineItemModifier() async throws {
|
||||
for (index, preview) in HighlightedTimelineItemModifier_Previews._allPreviews.enumerated() {
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3d3588ff03cab4ae26b509697c581527eb9fcac8c576975f9159dc30cf2f3e56
|
||||
size 83868
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f8ca33d1e3f081db2d1d781e22882fbf782bfbe4d9ad9b96cd381a7077a54ac
|
||||
size 84983
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b4a8af0283cffdb0512442a856f77c7e26e2018c578dd042675011f7d3946131
|
||||
size 37865
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ded4f7151b2a76ec08b682f7301fac1fbe604db78e61dbb350a6d966a0079bed
|
||||
size 38216
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9529bf7156b2a534ceef90635f2cf0a9e384ca528ad18822d40ca9a380b29f90
|
||||
size 141126
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user