Repo:
element-hq/element-x-ios— iOS Matrix client (SwiftUI +matrix-rust-sdk).
PRs must meet these rules. Prefer Xcode MCP tools over terminal commands.
- Style enforced by SwiftLint (
.swiftlint.yml) and SwiftFormat (.swiftformat). - Whitespace-only lines: never strip indentation (Xcode's "Trim whitespace-only lines" is disabled). Adjusting indentation to match scope is fine; removing it causes PR rejection.
- Follow Swift API Design Guidelines everywhere, including Rust SDK wrappers (e.g.
IDnotId,URLnotUrl,configurationnotconfigorcfg). - File headers defined in
IDETemplateMacros.plist.
- Default:
MXLog.info; unexpected failures:.error; noisy dev logs:.verbose..failure/.debugrarely used. - Never log secrets, passwords, keys, or user content (e.g. message bodies).
- Action enums with secret-containing associated values must conform to
CustomStringConvertible(logs only case name). - Matrix IDs are safe to log.
- Default localisation:
en(en-GB strings), shared with Element X Android via Localazy. - Never edit
Localizable.strings— it is auto-overwritten. - New English strings go in
Untranslated.strings(plurals:Untranslated.stringsdict). Team imports to Localazy before merge. - Access strings via generated
L10ntypes (e.g.L10n.actionDone). - Key naming (see element-x-android README):
- Cross-screen verbs:
action_; nouns/other:common_; accessibility:a11y_. - Key matches the string, e.g.
action_copy_link→Copy link. - Screen-specific:
screen_<name>_<free>(e.g.screen_onboarding_welcome_title). - Errors:
error_prefix or_error_infix. - iOS-only:
_iossuffix; Android-only:_androidsuffix. - Placeholders: always use numbered form
%1$@,%1$d. Use%x$@in iOS source; add translator commentLocalazy: change %x$@ -> %x$s.
- Cross-screen verbs:
- Create previews for all main states.
- Use
PreviewProvider(not#Preview) — snapshot/accessibility tests are generated from it. - Add
TestablePreviewconformance to generate snapshot and accessibility tests.
- Use sentence-style commit/PR messages (no conventional commits).
- Apply exactly one
pr-label (see.github/release.yml). - PR title = changelog entry — make it descriptive; no "Fixes #…" prefixes.
- Include screenshots/videos for visual changes.
- Keep PRs under 1000 additions; split large changes.
Initial setup: swift run tools setup-project
Git hooks are installed by swift run tools setup-project and run SwiftLint/SwiftFormat on commit — if a hook fails, do not abandon your changes, fix the reported issues and recommit.
| Tool | Command | Notes |
|---|---|---|
| XcodeGen | xcodegen |
Generates Xcode project from project.yml (includes app.yml + target.yml files) |
| Sourcery | sourcery --config Tools/Sourcery/<config> |
Configs: AutoMockableConfig.yml, PreviewTestsConfig.yml, TestablePreviewsDictionary.yml, AccessibilityTests.yml. Auto-runs on ElementX build. |
| SwiftGen | swiftgen config run --config Tools/SwiftGen/swiftgen-config.yml |
Auto-runs on ElementX build. |
| SwiftLint | swiftlint |
Auto-runs on ElementX build |
| SwiftFormat | swiftformat . |
Run from project root only. Auto-runs in lint mode on ElementX build. |
CI test commands:
- Unit tests:
swift run tools ci unit-tests - CI help:
swift run tools ci --help - Fastlane:
bundle exec fastlane lanes
Key targets (each has a target.yml):
- ElementX — main app
- NSE — Notification Service Extension
- ShareExtension — Share Extension
Each target: Sources/, Resources/, SupportingFiles/.
ElementX/Sources/
├── Application/ # App lifecycle, settings, windowing, root coordinators
├── FlowCoordinators/ # Flow coordinators + state machines
├── Services/<Feature>/ # SDK proxies, app services, non-view logic
├── Screens/<Screen>/ # View, ViewModel, Coordinator, Models per screen
├── Other/ # Shared extensions, utilities, SwiftUI helpers
└── {Unit|UI|A11y}Tests/ # Testing infrastructure
Mocks, test helpers, and generated files live alongside sources in Generated/ directories. Test suites are in dedicated targets.
compound-ios/— Compound design system (local package, all UI styling).Tools/Sources/— Developer CLI helpers.
- matrix-rust-sdk source:
matrix-org/matrix-rust-sdk. - Binary builds (xcframework + Swift bindings):
element-hq/matrix-rust-components-swift, imported viaproject.yml. - SDK hash for a given build: cross-reference the components version in
project.ymlwith its tag in the components repo. Find hash in commit message.
Every screen follows MVVM-Coordinator. Template: Tools/Scripts/Templates/SimpleScreenExample/ + createScreen.sh.
| File | Purpose |
|---|---|
FooScreenModels.swift |
ViewState, ViewStateBindings, ViewAction enum, ViewModelAction enum |
FooScreenViewModelProtocol.swift |
Protocol: actionsPublisher, context |
FooScreenViewModel.swift |
Concrete VM subclassing StateStoreViewModelV2 |
FooScreen.swift (in View/) |
SwiftUI view taking @Bindable var context |
FooScreenCoordinator.swift |
Owns VM, subscribes to actions, exposes own actionsPublisher |
FooScreenViewModelTests.swift |
Unit tests (UnitTests target) |
View ──send(viewAction:)──► ViewModel ──actionsPublisher──► Coordinator ──actionsPublisher──► FlowCoordinator
◄──viewState────────── │
◄──$context.bindings──► StateMachine<State,Event>
Located at ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift (uses Swift Observation):
state— mutable state struct conforming toBindableStatecontext—@Observableclass passed to the view:context.viewState— read-only statecontext.send(viewAction:)— sends action to view model$context.<binding>— two-way SwiftUI bindingscontext.mediaProvider— optional media service
- Override
process(viewAction:)for incoming actions. - Use
PassthroughSubject<ViewModelAction, Never>to notify the coordinator.
Some screens still use the older
StateStoreViewModel.swift(Combine/ObservableObject).
final class FooScreenCoordinator: CoordinatorProtocol {
private let parameters: FooScreenCoordinatorParameters
private let viewModel: FooScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<FooScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<FooScreenCoordinatorAction, Never> { actionsSubject.eraseToAnyPublisher() }
init(parameters: FooScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = FooScreenViewModel(/* dependencies */)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in /* map to coordinator actions */ }
.store(in: &cancellables)
}
func toPresentable() -> AnyView { AnyView(FooScreen(context: viewModel.context)) }
}- SwiftUI Alerts — Add
AlertInfotobindings; present with a one-liner in the view. - User Indicators (via
UserIndicatorController):.toast— pill at top of screen (errors).modal— not for errors (or an actual model). Blocking overlay for waiting.
@MainActor protocol FlowCoordinatorProtocol {
func start(animated: Bool)
func handleAppRoute(_ appRoute: AppRoute, animated: Bool)
func clearRoute(animated: Bool)
}Uses StateMachine<State, Event> from ReactKit/SwiftState:
State/Eventenums defined inside the flow coordinator, conforming toStateType/EventType.- Simple transitions:
addRoutes(event:transitions:). Associated values:addRouteMapping. addErrorHandlershouldfatalErroron unexpected transitions.
| Type | SwiftUI Equivalent | Usage |
|---|---|---|
NavigationStackCoordinator |
NavigationStack |
Push/pop within a flow |
NavigationSplitCoordinator |
NavigationSplitView |
iPad split view |
NavigationTabCoordinator |
TabView |
Tab navigation |
NavigationRootCoordinator |
Root view | Switch app root (e.g. auth → session) |
Shared dependency bag for flow coordinators only. Never pass to screen coordinators or view models — they receive specific dependencies via their Parameters struct.
AppRoute enum represents deep-link destinations. Handled in handleAppRoute by rebuilding coordinators, clearing stack, or no-op.
| Layer | Example | Location |
|---|---|---|
| Protocol | ClientProxyProtocol |
defines interface |
| Proxy | ClientProxy |
wraps SDK type, in Services/<Feature>/ |
| Mock | ClientProxyMock |
Sourcery-generated |
Naming: SDK type name + Proxy suffix (e.g. Client → ClientProxy). Exceptions where specialisation is needed (e.g. JoinedRoomProxy, InvitedRoomProxy).
Map SDK types to app-owned Swift types (avoids importing MatrixRustSDK in views):
init(rustValue:)— from SDKvar rustValue— back to SDK
Result<T, E>with typed errors (no barethrows).asynconly when the Rust method is async.- Prefer computed
varover methods for simple properties. - Map FFI types:
String→URL, timestampUInt64→Date, etc. - Follow Swift API naming (
userIDnotuserId).
Located in ElementX/Sources/Services/<Feature>/.
- Pure app services (e.g.
AppLockService) — no SDK involvement. - SDK-wrapping services — compose proxies with app logic; keep view models simple/testable.
Services are where product-level opinions live (the Rust SDK stays spec-faithful).
- Inject via
initparameters. - Screen coordinators get a
Parametersstruct with specific dependencies. CommonFlowParametersis flow-coordinator-only.ServiceLocatoris deprecated — never use services directly; always inject from above.
- Most protocols are
@MainActor; conforming types inherit this. - Views, screens, coordinators: always
@MainActor. - Some services are
nonisolatedfor background work. actortypes are rare in the codebase.
compound-ios/ provides all UI styling. Use Compound; only deviate when no equivalent exists.
- Colours:
Color.compound.textPrimary,.bgCanvasDefault, … - Fonts:
Font.compound.bodyLG,.headingMDBold,.bodySMSemibold, … - Icons: key paths on
CompoundIcons(e.g.\.userProfile). Always useCompoundIcon(\.iconName)(handles Dynamic Type scaling).
| Component | Notes |
|---|---|
ListRow |
Primary list/form building block. Label styles: .default, .plain, .action, .centeredAction. Kinds: .label, .button, .textField, .toggle, etc. |
.compoundList() |
Styles a Form/List with Compound tokens |
.compoundListSectionHeader/Footer() |
Section header/footer styling |
CompoundButtonStyle |
Styles: .primary, .secondary, .tertiary, .super, .textLink. Sizes: .large, .medium, .small, .toolbarIcon |
CompoundToggleStyle |
.toggleStyle(.compound) |
CompoundIcon |
Sizes: .xSmall (16pt), .small (20pt), .medium (24pt), .custom(CGFloat) |
SendButton |
Specialised send button for message composition |
Label with icon keypaths |
Label("Title", icon: \.userProfile) uses CompoundIcon internally |
Form {
Section {
ListRow(label: .default(title: "Setting", icon: \.settings),
kind: .navigationLink { /* action */ })
ListRow(label: .default(title: "Toggle", icon: \.notifications),
details: .isWaiting(context.viewState.isLoading),
kind: .toggle($context.isEnabled))
}
}
.compoundList()
.navigationTitle("Settings")See compound-ios/Sources/Compound/ for the full component set.
Coverage target: 80% (includes SDK, Compound, Rich Text Editor). Project is migrating from XCTest to Swift Testing (see PR #5119).
| Type | Location | Purpose |
|---|---|---|
| Unit Tests | UnitTests/Sources/ |
VM logic, state machines, services |
| UI Tests | UITests/Sources/ |
Flow coordinator integration (snapshots) |
| Preview Tests | PreviewTests/Sources/ |
Auto-generated snapshots from previews |
| Accessibility Tests | AccessibilityTests/Sources/ |
Auto-generated Xcode Accessibility Audits |
No need for .serialized suite traits, tests aren't run in parallel.
Generated by Sourcery (configs in Tools/Sourcery/); files in Generated/ directories.
- Un-configured methods intentionally crash.
- Common mocks have a
Configuration-based convenienceinit:let mock = ClientProxyMock(.init(userID: "@alice:example.com"))
let deferred = deferFulfillment(context.observe(\.viewState.counter)) { $0 == 1 }
context.send(viewAction: .incrementCounter)
try await deferred.fulfill()
#expect(context.viewState.counter == 1)Same pattern for publishers.
- Stored in
<Target>/Sources/__Snapshots__/, tracked via Git LFS (git lfs install). - Re-record on the correct device/OS if UI changes.
- Powered by
pointfreeco/swift-snapshot-testing. Failures produce 3 images: reference, failure, diff.
| File | Purpose |
|---|---|
project.yml |
XcodeGen project (targets, packages, settings) |
app.yml |
App-level XcodeGen config |
.swiftlint.yml |
SwiftLint rules |
.swiftformat |
SwiftFormat rules |
Dangerfile.swift |
Danger PR checks |
Package.swift |
SPM manifest (Tools CLI) |
Gemfile |
Ruby deps (Fastlane, Danger) |
localazy.json |
Localazy translation config |
codecov.yml |
Codecov config |
.periphery.yml |
Periphery dead-code detection |