diff --git a/.gitignore b/.gitignore
index b706f63..2a704c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.context/
+DerivedData/
# Xcode user-specific files
*.xcuserstate
diff --git a/.swiftlint-autofix.yml b/.swiftlint-autofix.yml
new file mode 100644
index 0000000..f707d0e
--- /dev/null
+++ b/.swiftlint-autofix.yml
@@ -0,0 +1,43 @@
+excluded:
+ - DerivedData
+ - .context
+
+only_rules:
+ - attribute_name_spacing
+ - closing_brace
+ - closure_end_indentation
+ - closure_spacing
+ - colon
+ - comma
+ - comma_inheritance
+ - comment_spacing
+ - control_statement
+ - duplicate_imports
+ - empty_enum_arguments
+ - empty_parameters
+ - empty_parentheses_with_trailing_closure
+ - function_name_whitespace
+ - implicit_optional_initialization
+ - leading_whitespace
+ - literal_expression_end_indentation
+ - modifier_order
+ - no_space_in_method_call
+ - opening_brace
+ - operator_usage_whitespace
+ - period_spacing
+ - protocol_property_accessors_order
+ - redundant_discardable_let
+ - redundant_void_return
+ - return_arrow_whitespace
+ - sorted_imports
+ - statement_position
+ - trailing_comma
+ - trailing_newline
+ - trailing_semicolon
+ - trailing_whitespace
+ - unneeded_parentheses_in_closure_argument
+ - vertical_whitespace
+ - vertical_whitespace_between_cases
+ - vertical_whitespace_closing_braces
+ - vertical_whitespace_opening_braces
+ - void_return
diff --git a/.swiftlint-build.yml b/.swiftlint-build.yml
new file mode 100644
index 0000000..61b17cb
--- /dev/null
+++ b/.swiftlint-build.yml
@@ -0,0 +1 @@
+parent_config: .swiftlint.yml
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..e17ccd7
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,61 @@
+reporter: xcode
+strict: true
+
+included:
+ - solipsistweets
+ - orion
+
+excluded:
+ - DerivedData
+ - .context
+
+disabled_rules:
+ # Broad shape limits are intentionally disabled so safety checks stay useful.
+ - cyclomatic_complexity
+ - file_length
+ - function_body_length
+ - function_parameter_count
+ - identifier_name
+ - large_tuple
+ - line_length
+ - nesting
+ - type_body_length
+
+ # Keep style-only noise out of strict build gates unless existing code is
+ # baselined or fixed separately.
+ - colon
+ - comma
+ - function_name_whitespace
+ - no_space_in_method_call
+ - opening_brace
+ - statement_position
+ - switch_case_alignment
+ - trailing_comma
+ - trailing_newline
+ - trailing_whitespace
+ - vertical_whitespace
+
+opt_in_rules:
+ - contains_over_filter_count
+ - contains_over_filter_is_empty
+ - discarded_notification_center_observer
+ - empty_count
+ - empty_string
+ - fallthrough
+ - fatal_error_message
+ - force_unwrapping
+ - implicitly_unwrapped_optional
+ - identical_operands
+ - no_empty_block
+ - private_action
+ - private_outlet
+ - private_subject
+ - private_swiftui_state
+ - redundant_nil_coalescing
+ - toggle_bool
+ - unhandled_throwing_task
+ - unneeded_escaping
+ - unneeded_throws_rethrows
+ - unowned_variable_capture
+ - weak_delegate
+ - yoda_condition
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..f6f16c4
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,65 @@
+# AGENTS.md
+
+## Build And Verification
+
+Use the fastest verification script that covers the files you touched.
+
+Use Verify Fast (`scripts/verify-fast.sh`) for changes isolated to one app target. Set `SCHEME=orion` when the change only needs the Orion target; otherwise the script defaults to `solipsistweets`.
+
+Use Verify Full (`scripts/verify-full.sh`) when a change touches shared Swift files under `solipsistweets/`, project settings, build scripts, assets used by both apps, or anything that should compile for both app schemes. It builds the shared `Verify Full` scheme, which compiles both app targets in one Xcode invocation. This repository has two app schemes, `solipsistweets` and `orion`, and no watch target.
+
+For production-scheme validation such as app packaging, project wiring, signing-sensitive behavior, platform support, or release-only settings, run the affected shared Xcode scheme directly with the needed destination/configuration. There is no separate watch/full verification script in this repo.
+
+All verification scripts use repo-local `DerivedData/`, pass `-disableAutomaticPackageResolution`, disable the compiler index store with `COMPILER_INDEX_STORE_ENABLE=NO`, disable debug dylib generation with `ENABLE_DEBUG_DYLIB=NO`, disable testability with `ENABLE_TESTABILITY=NO`, disable previews/string-symbol/localized-string generation, and use simulator-only no-signing settings `CODE_SIGNING_ALLOWED=NO` plus `CODE_SIGN_STYLE=Manual`. This repo currently has no SwiftPM package dependencies; do not add package-resolution/cache infrastructure unless dependencies are introduced.
+
+For simulator builds, the app-only and fast verification scripts constrain `ARCHS` to the host architecture by default to avoid building unused simulator slices. Override with `BUILD_ARCHS=...` only when broader simulator architecture coverage is intentional.
+
+For build timing, use `scripts/time-build.sh`. It keeps timing build products under `.context/build-timing/` and enables Xcode's build timing summary. By default it times a clean `build` of the `Verify Full` scheme; set `SCHEME=orion`, `BUILD_ACTION=build-for-testing`, or `CLEAN=0` when needed.
+
+## Tests
+
+This repo does not maintain unit-test or UI-test targets, and tests are not part of routine development here. Do not add test targets, test schemes, test plans, XCTest/Testing dependencies, or test source folders as part of normal changes.
+
+New code still needs verification. Use the fastest verification script that covers the files touched, and broaden to Verify Full (`scripts/verify-full.sh`) when the change affects shared code, project settings, or both app targets.
+
+If a future change truly requires executable tests, first document why build, lint, and manual verification are insufficient and why the behavior can be tested with lower risk than leaving it untested. Keep that testing infrastructure scoped to the need and update this guidance in the same change.
+
+## Project Settings
+
+Keep app targets on Swift 6 language mode with warnings as errors, complete strict concurrency, strict memory safety, and explicit `any` existential checking. Do not weaken these settings to make a change compile; fix the source issue or document why a temporary exception is required.
+
+Keep fast-build overhead low without bypassing lint: Xcode target lint phases run `scripts/swiftlint.sh build` with explicit config/script/source-directory inputs and stamp outputs so Xcode controls dependency analysis. Verification scripts apply build-test overrides to disable previews, testability, generated string catalog symbols, and localized string emission; real app schemes keep the Moods-style real-build settings.
+
+Keep launch schemes useful for debugging. Shared launch schemes should reduce system log noise and make invalid geometry reports actionable when possible.
+
+Release app products should validate products during build. Keep `VALIDATE_PRODUCT = YES` for production release targets.
+
+Avoid broad platform expansion unless the product intentionally supports it. This repo supports iOS/iPadOS only; do not add Mac Catalyst, Designed for iPhone/iPad on Mac, visionOS, or XR support by accident while editing target settings.
+
+Keep `ENABLE_USER_SCRIPT_SANDBOXING = NO`; project build phases may need access that Xcode's user script sandbox blocks.
+
+## DerivedData
+
+Keep DerivedData local to the worktree at `DerivedData/`. It is ignored by Git.
+
+For a new worktree, `scripts/seed-derived-data.sh` can copy a warm sibling `DerivedData/` using APFS clone-copy semantics when available, then removes path-sensitive build state. Install the optional best-effort hooks with `scripts/install-git-hooks.sh`; the post-checkout hook seeds DerivedData if absent without blocking checkout on failure.
+
+## Lint
+
+SwiftLint is configured with focused safety/correctness rules in `.swiftlint.yml` and runs in strict mode. Broad size/name/shape rules and current style-only noise are disabled so formatting preferences do not drown out safety checks or block routine builds.
+
+Run build-time lint with `scripts/swiftlint.sh build`; Xcode target phases run the same command during verification builds. Missing SwiftLint is a local warning but a CI error. Run the base config directly with `scripts/swiftlint.sh lint`. Run autofix-only style cleanup with `scripts/swiftlint.sh fix`.
+
+Install the optional pre-commit hook with `scripts/install-git-hooks.sh`; it sets `core.hooksPath` to `scripts/git-hooks`, runs the separate `.swiftlint-autofix.yml` path with SwiftLint `--fix --format` on staged Swift files, re-stages fixes, and aborts if a staged Swift file also has unstaged edits. Keep broad style gates out of strict lint unless existing code is baselined or fixed separately.
+
+## Source Practices
+
+Prefer typed boundary models over raw dictionaries or half-decoded payloads. Use `String?` only when absence has meaning, keep one source of truth for mutable state, and prefer structs/enums unless identity, shared mutable state, UIKit inheritance, or Objective-C interop requires a class.
+
+Use weak captures in long-lived closures, Combine sinks, timers, animation completions, and tasks that capture UI or store objects. Keep UI mutation on the main actor with `await MainActor.run { ... }` or `Task { @MainActor [weak self] in ... }`.
+
+Treat app-owned static assets and parser setup as invariants. Fail fast when required named assets or static regular expressions are missing or invalid; handle user and server data as recoverable input.
+
+For manual UIKit layout, do size-dependent work in `layoutSubviews()` or after `viewDidLayoutSubviews()`, ask subviews for size with `sizeThatFits(_:)`, prefer `bounds.size` plus `center`, and centralize padding and spacing constants.
+
+Treat stale documentation as a correctness bug. When a change alters architecture, ownership, build behavior, invariants, or platform constraints, update the relevant repo documentation in the same change.
diff --git a/orion/OrionApp.swift b/orion/OrionApp.swift
index c755ef0..2379840 100644
--- a/orion/OrionApp.swift
+++ b/orion/OrionApp.swift
@@ -5,7 +5,7 @@ struct OrionApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var requestedURL: URL = RedditSiteProfile().startURL
@StateObject private var screenTimeTracker = OnScreenTimeTracker()
- private let profile: SiteProfile = RedditSiteProfile()
+ private let profile: any SiteProfile = RedditSiteProfile()
var body: some Scene {
WindowGroup {
diff --git a/scripts/git-hooks/post-checkout b/scripts/git-hooks/post-checkout
new file mode 100755
index 0000000..5510d0f
--- /dev/null
+++ b/scripts/git-hooks/post-checkout
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
+if [[ -z "$repo_root" ]]; then
+ exit 0
+fi
+
+cd "$repo_root"
+
+if [[ ! -d "$repo_root/DerivedData" && -x "$repo_root/scripts/seed-derived-data.sh" ]]; then
+ "$repo_root/scripts/seed-derived-data.sh" >/dev/null 2>&1 || true
+fi
diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit
new file mode 100755
index 0000000..b71ac4c
--- /dev/null
+++ b/scripts/git-hooks/pre-commit
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+staged_swift_files=()
+while IFS= read -r -d '' staged; do
+ staged_swift_files+=("$staged")
+done < <(git diff --cached --name-only -z --diff-filter=ACMR -- '*.swift')
+
+if [[ "${#staged_swift_files[@]}" -eq 0 ]]; then
+ exit 0
+fi
+
+unstaged_swift_files=()
+while IFS= read -r -d '' unstaged; do
+ unstaged_swift_files+=("$unstaged")
+done < <(git diff --name-only -z -- '*.swift')
+
+for ((staged_index = 0; staged_index < ${#staged_swift_files[@]}; staged_index++)); do
+ staged="${staged_swift_files[$staged_index]}"
+ for ((unstaged_index = 0; unstaged_index < ${#unstaged_swift_files[@]}; unstaged_index++)); do
+ unstaged="${unstaged_swift_files[$unstaged_index]}"
+ if [[ "$staged" == "$unstaged" ]]; then
+ echo "error: $staged has both staged and unstaged edits; stage or stash one side before committing." >&2
+ exit 1
+ fi
+ done
+done
+
+"$repo_root/scripts/swiftlint.sh" autofix "${staged_swift_files[@]}"
+git add -- "${staged_swift_files[@]}"
+
+"$repo_root/scripts/swiftlint.sh" lint-autofix "${staged_swift_files[@]}"
+"$repo_root/scripts/swiftlint.sh" build "${staged_swift_files[@]}"
diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh
new file mode 100755
index 0000000..7af9a6f
--- /dev/null
+++ b/scripts/install-git-hooks.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+cd "$repo_root"
+
+chmod +x "$repo_root/scripts/git-hooks/post-checkout" "$repo_root/scripts/git-hooks/pre-commit"
+
+if [[ "$(git config --get extensions.worktreeConfig || true)" == "true" ]]; then
+ git config --worktree core.hooksPath scripts/git-hooks
+else
+ git config core.hooksPath scripts/git-hooks
+fi
+
+echo "Installed repo-local git hooks via core.hooksPath=scripts/git-hooks."
diff --git a/scripts/seed-derived-data.sh b/scripts/seed-derived-data.sh
new file mode 100755
index 0000000..6f32be5
--- /dev/null
+++ b/scripts/seed-derived-data.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+target_dd="${DERIVED_DATA_PATH:-$repo_root/DerivedData}"
+warm_dd="${1:-}"
+
+if [[ -d "$target_dd" ]]; then
+ echo "DerivedData already exists: $target_dd"
+ exit 0
+fi
+
+if [[ -z "$warm_dd" ]]; then
+ sibling_root="$(dirname "$repo_root")"
+ warm_dd="$(find "$sibling_root" -maxdepth 3 -type d -name DerivedData -not -path "$target_dd" -print -quit 2>/dev/null || true)"
+fi
+
+if [[ -z "$warm_dd" || ! -d "$warm_dd" ]]; then
+ echo "No warm DerivedData found. Run a build to create $target_dd."
+ exit 0
+fi
+
+mkdir -p "$(dirname "$target_dd")"
+if ! cp -cR "$warm_dd" "$target_dd" 2>/dev/null; then
+ cp -R "$warm_dd" "$target_dd"
+fi
+
+rm -rf \
+ "$target_dd/Build" \
+ "$target_dd/Index.noindex/Build" \
+ "$target_dd"/*/Build \
+ "$target_dd"/*/Index.noindex/Build
+
+echo "Seeded DerivedData from $warm_dd"
diff --git a/scripts/swiftlint.sh b/scripts/swiftlint.sh
new file mode 100755
index 0000000..6f625a7
--- /dev/null
+++ b/scripts/swiftlint.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
+
+MODE="${1:-build}"
+if [[ $# -gt 0 ]]; then
+ shift
+fi
+
+if [[ $# -eq 0 ]]; then
+ set -- solipsistweets orion
+fi
+
+if ! command -v swiftlint >/dev/null 2>&1; then
+ message="SwiftLint is not installed; install it with 'brew install swiftlint'."
+ if [[ "$MODE" == "build" && -z "${CI:-}" ]]; then
+ echo "warning: $message" >&2
+ exit 0
+ fi
+ if [[ "${CI:-}" == "true" || "${CI:-}" == "1" ]]; then
+ echo "error: $message" >&2
+ exit 1
+ fi
+ echo "error: $message" >&2
+ exit 127
+fi
+
+case "$MODE" in
+ build)
+ swiftlint lint \
+ --config "$ROOT_DIR/.swiftlint-build.yml" \
+ --working-directory "$ROOT_DIR" \
+ --strict \
+ --quiet \
+ --reporter xcode \
+ "$@"
+ ;;
+ lint)
+ swiftlint lint \
+ --config "$ROOT_DIR/.swiftlint.yml" \
+ --working-directory "$ROOT_DIR" \
+ --strict \
+ --quiet \
+ --reporter xcode \
+ "$@"
+ ;;
+ fix|autofix)
+ swiftlint lint \
+ --fix \
+ --format \
+ --config "$ROOT_DIR/.swiftlint-autofix.yml" \
+ --working-directory "$ROOT_DIR" \
+ --quiet \
+ "$@"
+ ;;
+ lint-autofix)
+ swiftlint lint \
+ --config "$ROOT_DIR/.swiftlint-autofix.yml" \
+ --working-directory "$ROOT_DIR" \
+ --strict \
+ --quiet \
+ --reporter xcode \
+ "$@"
+ ;;
+ *)
+ echo "usage: $0 [build|lint|fix|autofix|lint-autofix] [paths...]" >&2
+ exit 64
+ ;;
+esac
diff --git a/scripts/time-build.sh b/scripts/time-build.sh
new file mode 100755
index 0000000..46d9b07
--- /dev/null
+++ b/scripts/time-build.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+project="${PROJECT:-$repo_root/solipsistweets.xcodeproj}"
+scheme="${SCHEME:-Verify Full}"
+configuration="${CONFIGURATION:-Debug}"
+destination="${DESTINATION:-generic/platform=iOS Simulator}"
+timing_derived_data="${TIMING_DERIVED_DATA:-$repo_root/.context/build-timing/$scheme}"
+build_action="${BUILD_ACTION:-build}"
+
+args=(
+ -disableAutomaticPackageResolution
+ -project "$project"
+ -scheme "$scheme"
+ -configuration "$configuration"
+ -destination "$destination"
+ -derivedDataPath "$timing_derived_data"
+ COMPILER_INDEX_STORE_ENABLE="${COMPILER_INDEX_STORE_ENABLE:-NO}"
+ ENABLE_DEBUG_DYLIB="${ENABLE_DEBUG_DYLIB:-NO}"
+ ENABLE_TESTABILITY="${ENABLE_TESTABILITY:-NO}"
+ ENABLE_PREVIEWS="${ENABLE_PREVIEWS:-NO}"
+ STRING_CATALOG_GENERATE_SYMBOLS="${STRING_CATALOG_GENERATE_SYMBOLS:-NO}"
+ SWIFT_EMIT_LOC_STRINGS="${SWIFT_EMIT_LOC_STRINGS:-NO}"
+)
+
+if [[ "$destination" == *Simulator* ]]; then
+ args+=(
+ ARCHS="${BUILD_ARCHS:-$(uname -m)}"
+ CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}"
+ CODE_SIGN_STYLE="${CODE_SIGN_STYLE:-Manual}"
+ )
+fi
+
+actions=()
+if [[ "${CLEAN:-1}" != "0" ]]; then
+ actions+=(clean)
+fi
+actions+=("$build_action")
+
+xcodebuild "${args[@]}" \
+ "${actions[@]}" \
+ -showBuildTimingSummary
diff --git a/scripts/verify-fast.sh b/scripts/verify-fast.sh
new file mode 100755
index 0000000..dd3fdbb
--- /dev/null
+++ b/scripts/verify-fast.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+project="${PROJECT:-$repo_root/solipsistweets.xcodeproj}"
+scheme="${SCHEME:-solipsistweets}"
+configuration="${CONFIGURATION:-Debug}"
+destination="${DESTINATION:-generic/platform=iOS Simulator}"
+derived_data_path="${DERIVED_DATA_PATH:-$repo_root/DerivedData}"
+
+args=(
+ -disableAutomaticPackageResolution
+ -project "$project"
+ -scheme "$scheme"
+ -configuration "$configuration"
+ -destination "$destination"
+ -derivedDataPath "$derived_data_path"
+ COMPILER_INDEX_STORE_ENABLE="${COMPILER_INDEX_STORE_ENABLE:-NO}"
+ ENABLE_DEBUG_DYLIB="${ENABLE_DEBUG_DYLIB:-NO}"
+ ENABLE_TESTABILITY="${ENABLE_TESTABILITY:-NO}"
+ ENABLE_PREVIEWS="${ENABLE_PREVIEWS:-NO}"
+ STRING_CATALOG_GENERATE_SYMBOLS="${STRING_CATALOG_GENERATE_SYMBOLS:-NO}"
+ SWIFT_EMIT_LOC_STRINGS="${SWIFT_EMIT_LOC_STRINGS:-NO}"
+)
+
+if [[ "$destination" == *Simulator* ]]; then
+ args+=(
+ ARCHS="${BUILD_ARCHS:-$(uname -m)}"
+ CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}"
+ CODE_SIGN_STYLE="${CODE_SIGN_STYLE:-Manual}"
+ )
+fi
+
+xcodebuild "${args[@]}" \
+ build-for-testing
diff --git a/scripts/verify-full.sh b/scripts/verify-full.sh
new file mode 100755
index 0000000..b779664
--- /dev/null
+++ b/scripts/verify-full.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+project="${PROJECT:-$repo_root/solipsistweets.xcodeproj}"
+scheme="${SCHEME:-Verify Full}"
+configuration="${CONFIGURATION:-Debug}"
+destination="${DESTINATION:-generic/platform=iOS Simulator}"
+derived_data_path="${DERIVED_DATA_PATH:-$repo_root/DerivedData}"
+
+args=(
+ -disableAutomaticPackageResolution
+ -project "$project"
+ -scheme "$scheme"
+ -configuration "$configuration"
+ -destination "$destination"
+ -derivedDataPath "$derived_data_path"
+ COMPILER_INDEX_STORE_ENABLE="${COMPILER_INDEX_STORE_ENABLE:-NO}"
+ ENABLE_DEBUG_DYLIB="${ENABLE_DEBUG_DYLIB:-NO}"
+ ENABLE_TESTABILITY="${ENABLE_TESTABILITY:-NO}"
+ ENABLE_PREVIEWS="${ENABLE_PREVIEWS:-NO}"
+ STRING_CATALOG_GENERATE_SYMBOLS="${STRING_CATALOG_GENERATE_SYMBOLS:-NO}"
+ SWIFT_EMIT_LOC_STRINGS="${SWIFT_EMIT_LOC_STRINGS:-NO}"
+)
+
+if [[ "$destination" == *Simulator* ]]; then
+ args+=(
+ ARCHS="${BUILD_ARCHS:-$(uname -m)}"
+ CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}"
+ CODE_SIGN_STYLE="${CODE_SIGN_STYLE:-Manual}"
+ )
+fi
+
+xcodebuild "${args[@]}" \
+ build
diff --git a/solipsistweets.xcodeproj/project.pbxproj b/solipsistweets.xcodeproj/project.pbxproj
index 718b7d1..02b657c 100644
--- a/solipsistweets.xcodeproj/project.pbxproj
+++ b/solipsistweets.xcodeproj/project.pbxproj
@@ -90,6 +90,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 160494112E6507440073407B /* Build configuration list for PBXNativeTarget "solipsistweets" */;
buildPhases = (
+ 160494142E6507440073407B /* SwiftLint */,
160494022E6507430073407B /* Sources */,
160494032E6507430073407B /* Frameworks */,
160494042E6507430073407B /* Resources */,
@@ -112,6 +113,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 16EE1E3F2E7E546800B03BC8 /* Build configuration list for PBXNativeTarget "orion" */;
buildPhases = (
+ 16EE1E422E7E546800B03BC8 /* SwiftLint */,
16EE1E332E7E546700B03BC8 /* Sources */,
16EE1E342E7E546700B03BC8 /* Frameworks */,
16EE1E352E7E546700B03BC8 /* Resources */,
@@ -185,6 +187,56 @@
};
/* End PBXResourcesBuildPhase section */
+/* Begin PBXShellScriptBuildPhase section */
+ 160494142E6507440073407B /* SwiftLint */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "$(SRCROOT)/.swiftlint.yml",
+ "$(SRCROOT)/.swiftlint-build.yml",
+ "$(SRCROOT)/scripts/swiftlint.sh",
+ "$(SRCROOT)/solipsistweets",
+ );
+ name = SwiftLint;
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/SwiftLint-solipsistweets.stamp",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "set -e\n\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n";
+ };
+ 16EE1E422E7E546800B03BC8 /* SwiftLint */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "$(SRCROOT)/.swiftlint.yml",
+ "$(SRCROOT)/.swiftlint-build.yml",
+ "$(SRCROOT)/scripts/swiftlint.sh",
+ "$(SRCROOT)/solipsistweets",
+ "$(SRCROOT)/orion",
+ );
+ name = SwiftLint;
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/SwiftLint-orion.stamp",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "set -e\n\"$SRCROOT/scripts/swiftlint.sh\" build \"$SRCROOT/orion\" \"$SRCROOT/solipsistweets\"\ntouch \"$SCRIPT_OUTPUT_FILE_0\"\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
/* Begin PBXSourcesBuildPhase section */
160494022E6507430073407B /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -207,7 +259,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -236,13 +288,14 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = NFZL2NT288;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -271,7 +324,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
- ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -300,13 +353,14 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = NFZL2NT288;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
- ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -330,6 +384,9 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO;
+ GCC_TREAT_WARNINGS_AS_ERRORS = YES;
+ RUN_CLANG_STATIC_ANALYZER = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = NFZL2NT288;
ENABLE_APP_SANDBOX = YES;
@@ -342,8 +399,6 @@
INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
"INFOPLIST_KEY_CFBundleURLTypes[sdk=iphoneos*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
"INFOPLIST_KEY_CFBundleURLTypes[sdk=iphonesimulator*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
- "INFOPLIST_KEY_CFBundleURLTypes[sdk=macosx*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
- "INFOPLIST_KEY_CFBundleURLTypes[sdk=xros*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
INFOPLIST_KEY_NSCameraUsageDescription = "Allow camera access for web pages when you choose to capture photos or video.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access for web pages when you choose to share your location.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Allow microphone access for web pages when you choose to record audio or video.";
@@ -360,22 +415,23 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
- "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.04;
PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.solipsistweets;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
- SDKROOT = auto;
+ SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
+ OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
+ SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2,7";
- XROS_DEPLOYMENT_TARGET = 26.0;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
@@ -386,6 +442,9 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO;
+ GCC_TREAT_WARNINGS_AS_ERRORS = YES;
+ RUN_CLANG_STATIC_ANALYZER = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = NFZL2NT288;
ENABLE_APP_SANDBOX = YES;
@@ -398,8 +457,6 @@
INFOPLIST_KEY_CFBundleURLTypes = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
"INFOPLIST_KEY_CFBundleURLTypes[sdk=iphoneos*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
"INFOPLIST_KEY_CFBundleURLTypes[sdk=iphonesimulator*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
- "INFOPLIST_KEY_CFBundleURLTypes[sdk=macosx*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
- "INFOPLIST_KEY_CFBundleURLTypes[sdk=xros*]" = "{\n CFBundleTypeRole = Editor;\n CFBundleURLName = echodotapp;\n CFBundleURLSchemes = (\n echodotapp\n );\n}";
INFOPLIST_KEY_NSCameraUsageDescription = "Allow camera access for web pages when you choose to capture photos or video.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Allow location access for web pages when you choose to share your location.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Allow microphone access for web pages when you choose to record audio or video.";
@@ -416,22 +473,23 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
- "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
- MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.04;
PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.solipsistweets;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
- SDKROOT = auto;
+ SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
+ OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
+ SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
- TARGETED_DEVICE_FAMILY = "1,2,7";
- XROS_DEPLOYMENT_TARGET = 26.0;
+ SWIFT_VERSION = 6.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
@@ -442,6 +500,9 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO;
+ GCC_TREAT_WARNINGS_AS_ERRORS = YES;
+ RUN_CLANG_STATIC_ANALYZER = YES;
DEVELOPMENT_TEAM = NFZL2NT288;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -464,12 +525,16 @@
PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.orion;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
+ OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
+ SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -481,6 +546,9 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO;
+ GCC_TREAT_WARNINGS_AS_ERRORS = YES;
+ RUN_CLANG_STATIC_ANALYZER = YES;
DEVELOPMENT_TEAM = NFZL2NT288;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -503,12 +571,16 @@
PRODUCT_BUNDLE_IDENTIFIER = me.whydontyoulove.orion;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
+ OTHER_SWIFT_FLAGS = "$(inherited) -strict-memory-safety";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
+ SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
diff --git a/solipsistweets.xcodeproj/xcshareddata/xcschemes/Verify Full.xcscheme b/solipsistweets.xcodeproj/xcshareddata/xcschemes/Verify Full.xcscheme
new file mode 100644
index 0000000..b56a8ec
--- /dev/null
+++ b/solipsistweets.xcodeproj/xcshareddata/xcschemes/Verify Full.xcscheme
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/solipsistweets.xcodeproj/xcshareddata/xcschemes/orion.xcscheme b/solipsistweets.xcodeproj/xcshareddata/xcschemes/orion.xcscheme
new file mode 100644
index 0000000..fbfff24
--- /dev/null
+++ b/solipsistweets.xcodeproj/xcshareddata/xcschemes/orion.xcscheme
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/solipsistweets.xcodeproj/xcshareddata/xcschemes/solipsistweets.xcscheme b/solipsistweets.xcodeproj/xcshareddata/xcschemes/solipsistweets.xcscheme
new file mode 100644
index 0000000..182dd29
--- /dev/null
+++ b/solipsistweets.xcodeproj/xcshareddata/xcschemes/solipsistweets.xcscheme
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist b/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist
index f106088..c6ffe87 100644
--- a/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/solipsistweets.xcodeproj/xcuserdata/chrisbeiser.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -4,16 +4,34 @@
SchemeUserState
- orion.xcscheme_^#shared#^_
+ Verify Full.xcscheme_^#shared#^_
orderHint
0
- solipsistweets.xcscheme_^#shared#^_
+ orion.xcscheme_^#shared#^_
orderHint
1
+ solipsistweets.xcscheme_^#shared#^_
+
+ orderHint
+ 2
+
+
+ SuppressBuildableAutocreation
+
+ 160494052E6507430073407B
+
+ primary
+
+
+ 16EE1E362E7E546700B03BC8
+
+ primary
+
+
diff --git a/solipsistweets/ContentView.swift b/solipsistweets/ContentView.swift
index f785898..254357a 100644
--- a/solipsistweets/ContentView.swift
+++ b/solipsistweets/ContentView.swift
@@ -11,17 +11,45 @@ import Combine
struct ContentView: View {
private let screenTimeBadgeThreshold: TimeInterval = 20 * 60
- private static let testFlightURL = URL(string: "https://testflight.apple.com/join/N3DtJcgD")!
+ private static let testFlightURL = URL.required(string: "https://testflight.apple.com/join/N3DtJcgD")
@Binding var requestedURL: URL
@State private var isLoading: Bool = true
- @State private var lastErrorDescription: String? = nil
+ @State private var lastErrorDescription: String?
@StateObject private var shakeDetector = ShakeDetector()
@State private var showShareBanner = false
@State private var isShareSheetPresented = false
@State private var hideShareBannerTask: Task?
+ @State private var showRemoveCurrentTabConfirmation = false
+ @State private var isSwitcherPressed = false
@EnvironmentObject private var screenTimeTracker: OnScreenTimeTracker
@Environment(\.colorScheme) private var colorScheme
- let profile: SiteProfile
+ let profile: any SiteProfile
+ var switcherIcon: String?
+ var removableTabs: [SocialTab] = []
+ var setupTabs: [SocialTab] = []
+ var onSwitchTab: (() -> Void)?
+ var onRemoveTab: ((SocialTab) -> Void)?
+ var onSetupTab: ((SocialTab) -> Void)?
+
+ init(
+ requestedURL: Binding,
+ profile: any SiteProfile,
+ switcherIcon: String? = nil,
+ removableTabs: [SocialTab] = [],
+ setupTabs: [SocialTab] = [],
+ onSwitchTab: (() -> Void)? = nil,
+ onRemoveTab: ((SocialTab) -> Void)? = nil,
+ onSetupTab: ((SocialTab) -> Void)? = nil
+ ) {
+ _requestedURL = requestedURL
+ self.profile = profile
+ self.switcherIcon = switcherIcon
+ self.removableTabs = removableTabs
+ self.setupTabs = setupTabs
+ self.onSwitchTab = onSwitchTab
+ self.onRemoveTab = onRemoveTab
+ self.onSetupTab = onSetupTab
+ }
var body: some View {
ZStack {
@@ -67,27 +95,69 @@ struct ContentView: View {
}
.overlay(alignment: .top) {
if showShareBanner {
- Button {
- isShareSheetPresented = true
- dismissShareBanner()
- } label: {
- HStack(spacing: 8) {
- Image(systemName: "square.and.arrow.up")
- .font(.subheadline.weight(.semibold))
- Text("Share TestFlight")
- .font(.subheadline.weight(.semibold))
+ VStack(spacing: 8) {
+ Button {
+ isShareSheetPresented = true
+ dismissShareBanner()
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: "square.and.arrow.up")
+ .font(.subheadline.weight(.semibold))
+ Text("Share TestFlight")
+ .font(.subheadline.weight(.semibold))
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .foregroundStyle(.primary)
+ .background(.regularMaterial, in: Capsule())
+ }
+ .buttonStyle(.plain)
+
+ ForEach(setupTabs) { tab in
+ Button {
+ onSetupTab?(tab)
+ dismissShareBanner()
+ } label: {
+ HStack(spacing: 8) {
+ Text(tab.emoji)
+ Text(tab.setupTitle)
+ .font(.subheadline.weight(.semibold))
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ .foregroundStyle(.primary)
+ .background(.regularMaterial, in: Capsule())
+ }
+ .buttonStyle(.plain)
}
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
- .foregroundStyle(.primary)
- .background(.regularMaterial, in: Capsule())
}
- .buttonStyle(.plain)
.padding(.top, 8)
.padding(.horizontal, 12)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
+ .overlay(alignment: .topTrailing) {
+ if let switcherIcon {
+ Text(switcherIcon)
+ .font(.title3)
+ .frame(width: 44, height: 44)
+ .liquidGlass(in: Circle())
+ .scaleEffect(isSwitcherPressed ? 0.9 : 1)
+ .padding(.top, 4)
+ .padding(.trailing, 50)
+ .onTapGesture {
+ onSwitchTab?()
+ }
+ .onLongPressGesture(
+ minimumDuration: 0.7,
+ maximumDistance: 44,
+ pressing: updateSwitcherPressState,
+ perform: presentRemoveTabChoices
+ )
+ .accessibilityLabel("Switch account")
+ .accessibilityAddTraits(.isButton)
+ }
+ }
.onAppear {
shakeDetector.start()
}
@@ -101,6 +171,20 @@ struct ContentView: View {
.sheet(isPresented: $isShareSheetPresented) {
ShareSheet(activityItems: [Self.testFlightURL])
}
+ .confirmationDialog("Remove a tab?", isPresented: $showRemoveCurrentTabConfirmation, titleVisibility: .visible) {
+ ForEach(removeTabChoices) { tab in
+ Button(tab.removeActionTitle, role: .destructive) {
+ onRemoveTab?(tab)
+ }
+ }
+ Button("Cancel", role: .cancel) {
+ showRemoveCurrentTabConfirmation = false
+ }
+ }
+ }
+
+ private var removeTabChoices: [SocialTab] {
+ [.bluesky, .x].filter { removableTabs.contains($0) }
}
private var shouldShowScreenTimeBadge: Bool {
@@ -143,6 +227,41 @@ struct ContentView: View {
}
}
}
+
+ private func updateSwitcherPressState(_ isPressing: Bool) {
+ withAnimation(.spring(response: 0.18, dampingFraction: 0.72)) {
+ isSwitcherPressed = isPressing
+ }
+ }
+
+ private func presentRemoveTabChoices() {
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ withAnimation(.bouncy(duration: 0.42, extraBounce: 0.22)) {
+ isSwitcherPressed = false
+ }
+ showRemoveCurrentTabConfirmation = true
+ }
+}
+
+
+private extension View {
+ @ViewBuilder
+ func liquidGlass(in shape: S) -> some View {
+ if #available(iOS 26.0, *) {
+ self.glassEffect(.regular, in: shape)
+ } else {
+ self.background(.regularMaterial, in: shape)
+ }
+ }
+}
+
+private extension SocialTab {
+ var removeActionTitle: String {
+ switch self {
+ case .x: return "Remove X / Twitter Tab"
+ case .bluesky: return "Remove Bluesky Tab"
+ }
+ }
}
// MARK: - Duration formatting
@@ -152,15 +271,21 @@ private func formatDuration(_ seconds: TimeInterval) -> String {
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let secs = totalSeconds % 60
+ let paddedMinutes = twoDigitString(minutes)
+ let paddedSeconds = twoDigitString(secs)
if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, secs)
+ return "\(hours):\(paddedMinutes):\(paddedSeconds)"
} else {
- return String(format: "%d:%02d", minutes, secs)
+ return "\(minutes):\(paddedSeconds)"
}
}
+private func twoDigitString(_ value: Int) -> String {
+ value < 10 ? "0\(value)" : "\(value)"
+}
+
#Preview {
- ContentView(requestedURL: .constant(URL(string: "https://x.com/notifications")!), profile: XSiteProfile())
+ ContentView(requestedURL: .constant(URL.required(string: "https://x.com/notifications")), profile: XSiteProfile())
.environmentObject(OnScreenTimeTracker())
}
@@ -171,7 +296,10 @@ private struct ShareSheet: UIViewControllerRepresentable {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
- func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
+ func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
+ _ = uiViewController
+ _ = context
+ }
}
final class ShakeDetector: ObservableObject {
@@ -205,7 +333,9 @@ final class ShakeDetector: ObservableObject {
}
deinit {
- motionManager.stopDeviceMotionUpdates()
+ MainActor.assumeIsolated {
+ motionManager.stopDeviceMotionUpdates()
+ }
}
}
@@ -216,7 +346,7 @@ struct WebView: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
@Binding var lastErrorDescription: String?
- let profile: SiteProfile
+ let profile: any SiteProfile
typealias UIViewType = WKWebView
@@ -261,29 +391,33 @@ final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
private static let safeInlineSchemes: Set = ["about", "data"]
private var parent: WebView
- private let profile: SiteProfile
+ private let profile: any SiteProfile
private var didInstallContentRules: Bool = false
private var lastProgrammaticRequestURL: URL?
- init(parent: WebView, profile: SiteProfile) {
+ init(parent: WebView, profile: any SiteProfile) {
self.parent = parent
self.profile = profile
}
+ // swiftlint:disable:next implicitly_unwrapped_optional
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
DispatchQueue.main.async { [weak self] in
self?.parent.isLoading = true
}
}
- func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ // swiftlint:disable:next implicitly_unwrapped_optional
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) {
handleNavigationFailure(error, prefix: "Navigation failed")
}
- func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ // swiftlint:disable:next implicitly_unwrapped_optional
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) {
handleNavigationFailure(error, prefix: "Provisional navigation failed")
}
+ // swiftlint:disable:next implicitly_unwrapped_optional
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
#if DEBUG
print("Navigation finished: \(webView.url?.absoluteString ?? "")")
@@ -299,7 +433,7 @@ final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
}
}
- func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+ func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() else {
decisionHandler(.allow)
return
@@ -382,7 +516,7 @@ private extension Coordinator {
lastProgrammaticRequestURL = url
}
- func handleNavigationFailure(_ error: Error, prefix: String) {
+ func handleNavigationFailure(_ error: any Error, prefix: String) {
if Self.shouldIgnoreNavigationError(error) {
#if DEBUG
print("Ignoring expected navigation cancellation: \(error.localizedDescription)")
@@ -398,7 +532,7 @@ private extension Coordinator {
}
}
- func handleHTTPNavigation(_ url: URL, action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+ func handleHTTPNavigation(_ url: URL, action: WKNavigationAction, decisionHandler: @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
let isUserTap = action.navigationType == .linkActivated
let isMainFrame = action.targetFrame?.isMainFrame ?? true
@@ -434,7 +568,7 @@ private extension Coordinator {
return String(lowercased.dropLast())
}
- static func shouldIgnoreNavigationError(_ error: Error) -> Bool {
+ static func shouldIgnoreNavigationError(_ error: any Error) -> Bool {
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
return true
@@ -507,7 +641,7 @@ extension Coordinator {
if host == "user", let screenName = valueFor("screen_name"), !screenName.isEmpty {
return URL(string: "https://x.com/\(screenName)")
}
- if (host == "status" || host == "tweet"), let id = valueFor("id"), !id.isEmpty {
+ if host == "status" || host == "tweet", let id = valueFor("id"), !id.isEmpty {
return URL(string: "https://x.com/i/web/status/\(id)")
}
if host == "messages" || path == "/messages" {
@@ -593,6 +727,28 @@ enum ContentBlocker {
}
}
+ // Keep Bluesky rules separate from the original X/Twitter rules.
+ static let blueskyRulesJSON = """
+ [
+ {
+ "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] },
+ "action": { "type": "css-display-none", "selector": "[data-testid='followingFeedPage']" }
+ },
+ {
+ "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] },
+ "action": { "type": "css-display-none", "selector": "[aria-label='Home']" }
+ },
+ {
+ "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] },
+ "action": { "type": "css-display-none", "selector": "[aria-label='Lists']" }
+ },
+ {
+ "trigger": { "url-filter": ".*", "if-domain": ["cope.works", "www.cope.works"] },
+ "action": { "type": "css-display-none", "selector": "[aria-label='Feeds']" }
+ }
+ ]
+ """
+
// Keep the original X/Twitter rules available for XSiteProfile
static let defaultRulesJSON = """
[
diff --git a/solipsistweets/OnScreenTimeTracker.swift b/solipsistweets/OnScreenTimeTracker.swift
index 3f225f7..669098b 100644
--- a/solipsistweets/OnScreenTimeTracker.swift
+++ b/solipsistweets/OnScreenTimeTracker.swift
@@ -1,6 +1,7 @@
import Foundation
import Combine
+@MainActor
final class OnScreenTimeTracker: ObservableObject {
private static let keyPrefix = "onScreenSeconds_"
@@ -17,7 +18,9 @@ final class OnScreenTimeTracker: ObservableObject {
}
deinit {
- timer?.invalidate()
+ MainActor.assumeIsolated {
+ timer?.invalidate()
+ }
}
func start() {
@@ -26,7 +29,9 @@ final class OnScreenTimeTracker: ObservableObject {
secondsToday = userDefaults.double(forKey: Self.key(for: Date()))
lastTickDate = Date()
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
- self?.tick()
+ Task { @MainActor [weak self] in
+ self?.tick()
+ }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
diff --git a/solipsistweets/SiteProfile.swift b/solipsistweets/SiteProfile.swift
index f12b260..b44504f 100644
--- a/solipsistweets/SiteProfile.swift
+++ b/solipsistweets/SiteProfile.swift
@@ -2,6 +2,15 @@ import Foundation
import WebKit
import UIKit
+extension URL {
+ static func required(string: String) -> URL {
+ guard let url = URL(string: string) else {
+ fatalError("Invalid required URL: \(string)")
+ }
+ return url
+ }
+}
+
private enum SharedUserAgent {
static var mobileSafariCurrentDevice: String {
let osVersion = UIDevice.current.systemVersion
@@ -29,6 +38,42 @@ private enum SharedUserAgent {
}
}
+
+enum SocialTab: String, CaseIterable, Identifiable {
+ case x
+ case bluesky
+
+ var id: String { rawValue }
+
+ var displayName: String {
+ switch self {
+ case .x: return "Twitter / X"
+ case .bluesky: return "Bluesky"
+ }
+ }
+
+ var setupTitle: String {
+ switch self {
+ case .x: return "Set Up X"
+ case .bluesky: return "Set Up Bluesky"
+ }
+ }
+
+ var emoji: String {
+ switch self {
+ case .x: return "🐦"
+ case .bluesky: return "🦋"
+ }
+ }
+
+ var profile: any SiteProfile {
+ switch self {
+ case .x: return XSiteProfile()
+ case .bluesky: return BlueskySiteProfile()
+ }
+ }
+}
+
protocol SiteProfile {
// Hosts that are considered “internal” and should open in-app
var canonicalHosts: Set { get }
@@ -47,7 +92,7 @@ protocol SiteProfile {
struct XSiteProfile: SiteProfile {
let canonicalHosts: Set = ["x.com", "www.x.com", "mobile.x.com", "twitter.com", "www.twitter.com"]
- var startURL: URL { URL(string: "https://x.com/notifications")! }
+ var startURL: URL { URL.required(string: "https://x.com/notifications") }
var userAgent: String { SharedUserAgent.mobileSafariCurrentDevice }
func mapDeepLinkToHTTPS(_ url: URL) -> URL? {
Coordinator.mapTwitterDeepLinkToHTTPS(url: url)
@@ -57,9 +102,19 @@ struct XSiteProfile: SiteProfile {
var contentBlockerRulesJSON: String { ContentBlocker.defaultRulesJSON }
}
+struct BlueskySiteProfile: SiteProfile {
+ let canonicalHosts: Set = ["cope.works", "www.cope.works"]
+ var startURL: URL { URL.required(string: "https://cope.works/notifications") }
+ var userAgent: String { SharedUserAgent.mobileSafariCurrentDevice }
+ func mapDeepLinkToHTTPS(_ url: URL) -> URL? { nil }
+ func mapEchoDotAppToHTTPS(_ url: URL) -> URL? { nil }
+ var contentBlockerIdentifier: String { "com.solipsistweets.ContentBlocker.bluesky.rules.v4" }
+ var contentBlockerRulesJSON: String { ContentBlocker.blueskyRulesJSON }
+}
+
struct RedditSiteProfile: SiteProfile {
let canonicalHosts: Set = ["reddit.com", "www.reddit.com", "old.reddit.com", "m.reddit.com"]
- var startURL: URL { URL(string: "https://www.reddit.com/")! }
+ var startURL: URL { URL.required(string: "https://www.reddit.com/") }
var userAgent: String { SharedUserAgent.mobileSafariCurrentDevice }
func mapDeepLinkToHTTPS(_ url: URL) -> URL? {
// No known reddit:// scheme mapping needed; return nil to cancel deep links
diff --git a/solipsistweets/solipsistweetsApp.swift b/solipsistweets/solipsistweetsApp.swift
index 3cb3de1..09f5926 100644
--- a/solipsistweets/solipsistweetsApp.swift
+++ b/solipsistweets/solipsistweetsApp.swift
@@ -4,21 +4,148 @@
//
import SwiftUI
+import Combine
+
+@MainActor
+final class SocialAccountStore: ObservableObject {
+ private static let configuredTabsKey = "configuredSocialTabs.v1"
+ private static let activeTabKey = "activeSocialTab.v1"
+ private static let didHandleBlueskyUpgradeKey = "didHandleBlueskyUpgradePrompt.v1"
+
+ @Published private(set) var configuredTabs: [SocialTab]
+ @Published var activeTab: SocialTab
+ @Published var isPresentingInitialChoice: Bool
+ @Published var isPresentingUpgradePrompt: Bool
+
+ private let userDefaults: UserDefaults
+
+ init(userDefaults: UserDefaults = .standard) {
+ self.userDefaults = userDefaults
+ let rawConfiguredTabs = userDefaults.stringArray(forKey: Self.configuredTabsKey) ?? []
+ var configuredTabs = rawConfiguredTabs.compactMap(SocialTab.init(rawValue:))
+ let isExistingInstall = configuredTabs.isEmpty && userDefaults.dictionaryRepresentation().keys.contains { $0.hasPrefix("onScreenSeconds_") }
+ if isExistingInstall {
+ configuredTabs = [.x]
+ userDefaults.set(configuredTabs.map(\.rawValue), forKey: Self.configuredTabsKey)
+ }
+ let activeTab = SocialTab(rawValue: userDefaults.string(forKey: Self.activeTabKey) ?? "") ?? configuredTabs.first ?? .x
+
+ self.configuredTabs = configuredTabs
+ self.activeTab = activeTab
+ self.isPresentingInitialChoice = configuredTabs.isEmpty
+ self.isPresentingUpgradePrompt = false
+
+ guard !configuredTabs.isEmpty else { return }
+ if !configuredTabs.contains(activeTab), let fallback = configuredTabs.first {
+ self.activeTab = fallback
+ userDefaults.set(fallback.rawValue, forKey: Self.activeTabKey)
+ }
+ if !configuredTabs.contains(.bluesky), !userDefaults.bool(forKey: Self.didHandleBlueskyUpgradeKey) {
+ self.isPresentingUpgradePrompt = true
+ }
+ }
+
+ var hasMultipleTabs: Bool {
+ configuredTabs.count > 1
+ }
+
+ var nextTab: SocialTab? {
+ guard let currentIndex = configuredTabs.firstIndex(of: activeTab), configuredTabs.count > 1 else { return nil }
+ let nextIndex = configuredTabs.index(after: currentIndex)
+ return configuredTabs[nextIndex == configuredTabs.endIndex ? configuredTabs.startIndex : nextIndex]
+ }
+
+ var missingTabs: [SocialTab] {
+ SocialTab.allCases.filter { !configuredTabs.contains($0) }
+ }
+
+ var activeProfile: any SiteProfile {
+ activeTab.profile
+ }
+
+ func completeInitialChoice(_ tabs: [SocialTab]) {
+ let selectedTabs = normalized(tabs)
+ configuredTabs = selectedTabs
+ activeTab = selectedTabs.first ?? .x
+ isPresentingInitialChoice = false
+ persistTabs()
+ userDefaults.set(activeTab.rawValue, forKey: Self.activeTabKey)
+ userDefaults.set(configuredTabs.contains(.bluesky), forKey: Self.didHandleBlueskyUpgradeKey)
+ }
+
+ func add(_ tab: SocialTab) {
+ guard !configuredTabs.contains(tab) else { return }
+ configuredTabs = normalized(configuredTabs + [tab])
+ activeTab = tab
+ isPresentingInitialChoice = false
+ persistTabs()
+ userDefaults.set(activeTab.rawValue, forKey: Self.activeTabKey)
+ if tab == .bluesky {
+ userDefaults.set(true, forKey: Self.didHandleBlueskyUpgradeKey)
+ isPresentingUpgradePrompt = false
+ }
+ }
+
+ func remove(_ tab: SocialTab) {
+ guard configuredTabs.count > 1 else { return }
+ configuredTabs.removeAll { $0 == tab }
+ if activeTab == tab, let fallback = configuredTabs.first {
+ activeTab = fallback
+ userDefaults.set(fallback.rawValue, forKey: Self.activeTabKey)
+ }
+ persistTabs()
+ }
+
+ func switchToNextTab() {
+ guard let currentIndex = configuredTabs.firstIndex(of: activeTab), configuredTabs.count > 1 else { return }
+ let nextIndex = configuredTabs.index(after: currentIndex)
+ activeTab = configuredTabs[nextIndex == configuredTabs.endIndex ? configuredTabs.startIndex : nextIndex]
+ userDefaults.set(activeTab.rawValue, forKey: Self.activeTabKey)
+ }
+
+ func declineBlueskyUpgrade() {
+ userDefaults.set(true, forKey: Self.didHandleBlueskyUpgradeKey)
+ isPresentingUpgradePrompt = false
+ }
+
+ func acceptBlueskyUpgrade() {
+ add(.bluesky)
+ userDefaults.set(true, forKey: Self.didHandleBlueskyUpgradeKey)
+ isPresentingUpgradePrompt = false
+ }
+
+ private func normalized(_ tabs: [SocialTab]) -> [SocialTab] {
+ SocialTab.allCases.filter { tabs.contains($0) }
+ }
+
+ private func persistTabs() {
+ userDefaults.set(configuredTabs.map(\.rawValue), forKey: Self.configuredTabsKey)
+ }
+}
@main
-struct solipsistweetsApp: App {
+struct SolipsistweetsApp: App {
@Environment(\.scenePhase) private var scenePhase
- @State private var requestedURL: URL = XSiteProfile().startURL
+ @State private var requestedURL: URL
@StateObject private var screenTimeTracker = OnScreenTimeTracker()
- private let profile: SiteProfile = XSiteProfile()
+ @StateObject private var accountStore: SocialAccountStore
+
+ init() {
+ let accountStore = SocialAccountStore()
+ _accountStore = StateObject(wrappedValue: accountStore)
+ _requestedURL = State(initialValue: accountStore.activeProfile.startURL)
+ }
var body: some Scene {
WindowGroup {
- ContentView(requestedURL: $requestedURL, profile: profile)
+ SocialWebContainer(requestedURL: $requestedURL)
.environmentObject(screenTimeTracker)
+ .environmentObject(accountStore)
.onOpenURL { url in
guard url.scheme?.lowercased() == "echodotapp" else { return }
- if let mapped = profile.mapEchoDotAppToHTTPS(url) {
+ if let mapped = XSiteProfile().mapEchoDotAppToHTTPS(url) {
+ accountStore.add(.x)
+ accountStore.activeTab = .x
requestedURL = mapped
}
}
@@ -39,3 +166,133 @@ struct solipsistweetsApp: App {
}
}
}
+
+private struct SocialWebContainer: View {
+ @Binding var requestedURL: URL
+ @EnvironmentObject private var accountStore: SocialAccountStore
+
+ var body: some View {
+ Group {
+ if accountStore.isPresentingInitialChoice {
+ AccountChoiceView(title: "Which accounts would you like to use?", subtitle: "Choose X, Bluesky, or both to get started.") { tabs in
+ accountStore.completeInitialChoice(tabs)
+ requestedURL = accountStore.activeProfile.startURL
+ }
+ } else if !accountStore.activeProfile.canonicalHosts.contains(requestedURL.host?.lowercased() ?? "") {
+ Color.clear
+ .onAppear {
+ requestedURL = accountStore.activeProfile.startURL
+ }
+ } else {
+ ContentView(
+ requestedURL: $requestedURL,
+ profile: accountStore.activeProfile,
+ switcherIcon: accountStore.nextTab?.emoji,
+ removableTabs: accountStore.configuredTabs,
+ setupTabs: accountStore.missingTabs,
+ onSwitchTab: {
+ accountStore.switchToNextTab()
+ requestedURL = accountStore.activeProfile.startURL
+ },
+ onRemoveTab: { tab in
+ let removedActiveTab = tab == accountStore.activeTab
+ accountStore.remove(tab)
+ if removedActiveTab {
+ requestedURL = accountStore.activeProfile.startURL
+ }
+ },
+ onSetupTab: { tab in
+ accountStore.add(tab)
+ requestedURL = accountStore.activeProfile.startURL
+ }
+ )
+ .id(accountStore.activeTab)
+ .alert("Bluesky Support Is Here", isPresented: $accountStore.isPresentingUpgradePrompt) {
+ Button("Set Up Bluesky") {
+ accountStore.acceptBlueskyUpgrade()
+ requestedURL = accountStore.activeProfile.startURL
+ }
+ Button("Keep Using Twitter / X", role: .cancel) {
+ accountStore.declineBlueskyUpgrade()
+ }
+ } message: {
+ Text("Echo can now open Bluesky alongside Twitter / X. Add Bluesky now, or keep your current setup and add it later by shaking your device.")
+ }
+ }
+ }
+ .onChange(of: accountStore.activeTab) { _, _ in
+ requestedURL = accountStore.activeProfile.startURL
+ }
+ }
+}
+
+private struct AccountChoiceView: View {
+ let title: String
+ let subtitle: String
+ let onComplete: ([SocialTab]) -> Void
+ @State private var selectedTabs: Set = [.x]
+
+ var body: some View {
+ VStack(spacing: 24) {
+ Spacer()
+
+ VStack(spacing: 10) {
+ Text(title)
+ .font(.title2.weight(.bold))
+ .multilineTextAlignment(.center)
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+
+ VStack(spacing: 12) {
+ ForEach(SocialTab.allCases) { tab in
+ Button {
+ toggle(tab)
+ } label: {
+ HStack(spacing: 12) {
+ Text(tab.emoji)
+ .font(.title3)
+ Text(tab.displayName)
+ .font(.headline)
+ Spacer()
+ Image(systemName: selectedTabs.contains(tab) ? "checkmark.circle.fill" : "circle")
+ .font(.title3)
+ .foregroundStyle(selectedTabs.contains(tab) ? Color.accentColor : Color.secondary)
+ }
+ .padding()
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ Button {
+ onComplete(SocialTab.allCases.filter { selectedTabs.contains($0) })
+ } label: {
+ Text("Continue")
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .foregroundStyle(.white)
+ }
+ .disabled(selectedTabs.isEmpty)
+ .opacity(selectedTabs.isEmpty ? 0.5 : 1)
+
+ Spacer()
+ }
+ .padding(24)
+ .background(Color(.systemGroupedBackground))
+ }
+
+ private func toggle(_ tab: SocialTab) {
+ if selectedTabs.contains(tab) {
+ guard selectedTabs.count > 1 else { return }
+ selectedTabs.remove(tab)
+ } else {
+ selectedTabs.insert(tab)
+ }
+ }
+}