Initial social.io Swift app
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Xcode
|
||||||
|
DerivedData/
|
||||||
|
build/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcscmblueprint
|
||||||
|
*.xccheckout
|
||||||
|
xcuserdata/
|
||||||
|
*.moved-aside
|
||||||
|
|
||||||
|
# SwiftPM
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
Package.resolved
|
||||||
|
|
||||||
|
# App artifacts
|
||||||
|
compose-screen.png
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname "$0")" && pwd)"
|
||||||
|
device="${1:-booted}"
|
||||||
|
output_dir="${2:-/tmp/socialio-controlled-shots}"
|
||||||
|
control_file="${SOCIALIO_CONTROL_FILE:-/tmp/socialio-control.txt}"
|
||||||
|
routes_file="${SOCIALIO_SCREENSHOT_ROUTES:-$script_dir/ui-screenshot-routes.txt}"
|
||||||
|
bundle_id="io.social.app"
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
|
||||||
|
printf '%s\n' 'socialio://mailbox/inbox' > "$control_file"
|
||||||
|
|
||||||
|
SIMCTL_CHILD_SOCIALIO_CONTROL_FILE="$control_file" \
|
||||||
|
SIMCTL_CHILD_SOCIALIO_CONTROL_POLL_MS="250" \
|
||||||
|
xcrun simctl launch --terminate-running-process "$device" "$bundle_id" >/dev/null
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
while IFS='|' read -r name route; do
|
||||||
|
if [[ -z "${name}" || "${name}" == \#* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$route" > "$control_file"
|
||||||
|
sleep 1.2
|
||||||
|
xcrun simctl io "$device" screenshot "$output_dir/$name.png" >/dev/null
|
||||||
|
done < "$routes_file"
|
||||||
|
|
||||||
|
echo "Saved screenshots to $output_dir"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# name|route
|
||||||
|
inbox|socialio://mailbox/inbox
|
||||||
|
starred|socialio://mailbox/starred?unreadOnly=true
|
||||||
|
launch-copy|socialio://open?thread=launch-copy&message=launch-copy-2
|
||||||
|
investor-update|socialio://open?thread=investor-update&message=investor-update-1
|
||||||
|
compose-grandma|socialio://compose?to=grandma@example.com&subject=Family%20Photos&body=Hi%20Grandma%2C%0A%0AI%20pulled%20up%20the%20photos%20thread%20for%20you.
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname "$0")" && pwd)"
|
||||||
|
device="${1:-8EBCDD58-34AB-457A-A878-8004A6108CA9}"
|
||||||
|
output_dir="${2:-/tmp/socialio-ui-review}"
|
||||||
|
derived_data="${3:-/tmp/socialio-ui-review-derived}"
|
||||||
|
project="/Users/philkunz/gitea/social.io-swiftapp/SocialIO/SocialIO.xcodeproj"
|
||||||
|
app_path="$derived_data/Build/Products/Debug-iphonesimulator/SocialIO.app"
|
||||||
|
|
||||||
|
xcodebuild \
|
||||||
|
-project "$project" \
|
||||||
|
-scheme SocialIO \
|
||||||
|
-configuration Debug \
|
||||||
|
-destination "id=$device" \
|
||||||
|
-derivedDataPath "$derived_data" \
|
||||||
|
build
|
||||||
|
|
||||||
|
xcrun simctl install "$device" "$app_path"
|
||||||
|
"$script_dir/capture-controlled-screenshots.sh" "$device" "$output_dir"
|
||||||
|
|
||||||
|
echo "Built app at $app_path"
|
||||||
|
echo "Saved screenshots to $output_dir"
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# SocialIO Swift App
|
||||||
|
|
||||||
|
Multiplatform SwiftUI mail client scaffold for macOS, iPadOS, and iOS.
|
||||||
|
|
||||||
|
## What is included
|
||||||
|
|
||||||
|
- One shared SwiftUI app target
|
||||||
|
- Mocked mail backend with seeded conversations
|
||||||
|
- Three-column mail UI that adapts across Apple platforms
|
||||||
|
- Compose flow, search, unread filters, favorites, and message detail
|
||||||
|
- Backend-driven navigation hooks for mailboxes, threads, and compose flows
|
||||||
|
- Screenshot/test automation route IDs and accessibility identifiers
|
||||||
|
|
||||||
|
## Open the project
|
||||||
|
|
||||||
|
1. Install/select full Xcode on this Mac.
|
||||||
|
2. Open `SocialIO/SocialIO.xcodeproj`.
|
||||||
|
3. Build the `SocialIO` scheme for:
|
||||||
|
- `My Mac`
|
||||||
|
- an iPad simulator
|
||||||
|
- an iPhone simulator
|
||||||
|
|
||||||
|
## App control contract
|
||||||
|
|
||||||
|
The app can be driven in three ways:
|
||||||
|
|
||||||
|
- Launch with `SOCIALIO_ROUTE`
|
||||||
|
- Launch with `SOCIALIO_COMMAND_JSON`
|
||||||
|
- Keep a running app subscribed to a mocked backend control file with `SOCIALIO_CONTROL_FILE`
|
||||||
|
|
||||||
|
### Deep link examples
|
||||||
|
|
||||||
|
```text
|
||||||
|
socialio://mailbox/inbox
|
||||||
|
socialio://mailbox/starred?unreadOnly=true
|
||||||
|
socialio://thread/launch-copy
|
||||||
|
socialio://open?thread=launch-copy&message=launch-copy-2
|
||||||
|
socialio://compose?to=grandma@example.com&subject=Family%20Photos&body=Hi%20Grandma
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON command examples
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"kind":"mailbox","mailbox":"archive","search":"roadmap"}
|
||||||
|
{"kind":"thread","threadID":"launch-copy","messageID":"launch-copy-2"}
|
||||||
|
{"kind":"compose","to":"grandma@example.com","subject":"Family Photos","body":"Hi Grandma"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stable mock route IDs
|
||||||
|
|
||||||
|
- Threads: `launch-copy`, `daily-sync-status`, `investor-update`, `search-ranking-polish`, `welcome-to-socialio`, `roadmap-notes`
|
||||||
|
- Messages: `launch-copy-1`, `launch-copy-2`, `investor-update-1`, `roadmap-notes-1`, and similar seeded IDs
|
||||||
|
|
||||||
|
## Mock backend control
|
||||||
|
|
||||||
|
When `SOCIALIO_CONTROL_FILE` points at a text file, the running app polls it and applies the latest command whenever the file contents change.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'socialio://open?thread=launch-copy&message=launch-copy-2' > /tmp/socialio-control.txt
|
||||||
|
echo '{"kind":"compose","to":"grandma@example.com","subject":"Photos","body":"Hi Grandma"}' > /tmp/socialio-control.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
That gives us a mocked backend transport now, and we can swap the same command model behind a real API later.
|
||||||
|
|
||||||
|
## Screenshot automation
|
||||||
|
|
||||||
|
After the app is built and installed in Simulator, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Users/philkunz/gitea/social.io-swiftapp/Scripts/capture-controlled-screenshots.sh booted /tmp/socialio-shots
|
||||||
|
```
|
||||||
|
|
||||||
|
The script launches the app with `SOCIALIO_CONTROL_FILE`, rewrites that file with a series of routes, and saves screenshots for each destination.
|
||||||
|
|
||||||
|
## Standard UI review loop
|
||||||
|
|
||||||
|
For UI-affecting changes, use the one-shot verification script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Users/philkunz/gitea/social.io-swiftapp/Scripts/verify-ios-ui.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That standard flow:
|
||||||
|
|
||||||
|
- builds the iPhone simulator app
|
||||||
|
- installs it into Simulator
|
||||||
|
- runs the backend-control screenshot pass
|
||||||
|
- saves the review set to `/tmp/socialio-ui-review`
|
||||||
|
|
||||||
|
The screenshot list lives in `/Users/philkunz/gitea/social.io-swiftapp/Scripts/ui-screenshot-routes.txt`, so we can keep expanding the review set as the app grows.
|
||||||
|
|
||||||
|
## UI test hooks
|
||||||
|
|
||||||
|
Key controls now expose stable accessibility identifiers such as:
|
||||||
|
|
||||||
|
- `mailbox.inbox`
|
||||||
|
- `filter.unread`
|
||||||
|
- `thread.launch-copy`
|
||||||
|
- `message.launch-copy-2`
|
||||||
|
- `compose.view`
|
||||||
|
- `compose.to`
|
||||||
|
- `compose.subject`
|
||||||
|
- `compose.body`
|
||||||
|
- `compose.send`
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
A10000000000000000000001 /* SocialIOApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* SocialIOApp.swift */; };
|
||||||
|
A10000000000000000000002 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AppViewModel.swift */; };
|
||||||
|
A10000000000000000000003 /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MailModels.swift */; };
|
||||||
|
A10000000000000000000004 /* MockMailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MockMailService.swift */; };
|
||||||
|
A10000000000000000000005 /* MailRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MailRootView.swift */; };
|
||||||
|
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* AppNavigationCommand.swift */; };
|
||||||
|
A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
A20000000000000000000001 /* SocialIOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOApp.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000003 /* MailModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailModels.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000004 /* MockMailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMailService.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000005 /* MailRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailRootView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000006 /* SocialIO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SocialIO.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A20000000000000000000007 /* AppNavigationCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommand.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000008 /* AppControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppControlService.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
A30000000000000000000001 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
A40000000000000000000001 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A40000000000000000000002 /* SocialIO */,
|
||||||
|
A40000000000000000000009 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000002 /* SocialIO */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A40000000000000000000003 /* Sources */,
|
||||||
|
);
|
||||||
|
name = SocialIO;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000003 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A40000000000000000000004 /* App */,
|
||||||
|
A40000000000000000000005 /* Core */,
|
||||||
|
A40000000000000000000008 /* Features */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000004 /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000001 /* SocialIOApp.swift */,
|
||||||
|
A20000000000000000000002 /* AppViewModel.swift */,
|
||||||
|
A20000000000000000000007 /* AppNavigationCommand.swift */,
|
||||||
|
A20000000000000000000008 /* AppControlService.swift */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000005 /* Core */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A40000000000000000000006 /* Models */,
|
||||||
|
A40000000000000000000007 /* Services */,
|
||||||
|
);
|
||||||
|
path = Core;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000006 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000003 /* MailModels.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000007 /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000004 /* MockMailService.swift */,
|
||||||
|
);
|
||||||
|
path = Services;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000008 /* Features */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A4000000000000000000000A /* Mail */,
|
||||||
|
);
|
||||||
|
path = Features;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A40000000000000000000009 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000006 /* SocialIO.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A4000000000000000000000A /* Mail */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000005 /* MailRootView.swift */,
|
||||||
|
);
|
||||||
|
path = Mail;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
A50000000000000000000001 /* SocialIO */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = A70000000000000000000002 /* Build configuration list for PBXNativeTarget "SocialIO" */;
|
||||||
|
buildPhases = (
|
||||||
|
A30000000000000000000002 /* Sources */,
|
||||||
|
A30000000000000000000001 /* Frameworks */,
|
||||||
|
A30000000000000000000003 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = SocialIO;
|
||||||
|
productName = SocialIO;
|
||||||
|
productReference = A20000000000000000000006 /* SocialIO.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
A60000000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2600;
|
||||||
|
LastUpgradeCheck = 2600;
|
||||||
|
TargetAttributes = {
|
||||||
|
A50000000000000000000001 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = A40000000000000000000001;
|
||||||
|
productRefGroup = A40000000000000000000009 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
A50000000000000000000001 /* SocialIO */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
A30000000000000000000003 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
A30000000000000000000002 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A10000000000000000000002 /* AppViewModel.swift in Sources */,
|
||||||
|
A10000000000000000000005 /* MailRootView.swift in Sources */,
|
||||||
|
A10000000000000000000003 /* MailModels.swift in Sources */,
|
||||||
|
A10000000000000000000004 /* MockMailService.swift in Sources */,
|
||||||
|
A10000000000000000000001 /* SocialIOApp.swift in Sources */,
|
||||||
|
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */,
|
||||||
|
A10000000000000000000007 /* AppControlService.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
A80000000000000000000001 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A80000000000000000000002 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
SDKROOT = auto;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
A80000000000000000000003 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = SocialIO;
|
||||||
|
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||||
|
{
|
||||||
|
CFBundleTypeRole = Editor;
|
||||||
|
CFBundleURLName = io.social.app;
|
||||||
|
CFBundleURLSchemes = (
|
||||||
|
socialio,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.social.app;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBSERVATION_ENABLED = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A80000000000000000000004 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = SocialIO;
|
||||||
|
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||||
|
{
|
||||||
|
CFBundleTypeRole = Editor;
|
||||||
|
CFBundleURLName = io.social.app;
|
||||||
|
CFBundleURLSchemes = (
|
||||||
|
socialio,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.social.app;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBSERVATION_ENABLED = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A80000000000000000000001 /* Debug */,
|
||||||
|
A80000000000000000000002 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
A70000000000000000000002 /* Build configuration list for PBXNativeTarget "SocialIO" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A80000000000000000000003 /* Debug */,
|
||||||
|
A80000000000000000000004 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = A60000000000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol AppControlServicing {
|
||||||
|
func commands() -> AsyncStream<AppNavigationCommand>
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockBackendControlService: AppControlServicing {
|
||||||
|
static let controlFileEnvironmentKey = "SOCIALIO_CONTROL_FILE"
|
||||||
|
static let pollingIntervalEnvironmentKey = "SOCIALIO_CONTROL_POLL_MS"
|
||||||
|
|
||||||
|
private let environment: [String: String]
|
||||||
|
|
||||||
|
init(environment: [String: String] = ProcessInfo.processInfo.environment) {
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
func commands() -> AsyncStream<AppNavigationCommand> {
|
||||||
|
guard let controlFilePath = environment[Self.controlFileEnvironmentKey]?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!controlFilePath.isEmpty else {
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let controlFileURL = URL(fileURLWithPath: controlFilePath)
|
||||||
|
let pollingInterval = pollingIntervalDuration
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task.detached(priority: .background) {
|
||||||
|
var lastAppliedPayload: String?
|
||||||
|
|
||||||
|
while !Task.isCancelled {
|
||||||
|
if let payload = try? String(contentsOf: controlFileURL, encoding: .utf8) {
|
||||||
|
let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
if !trimmedPayload.isEmpty,
|
||||||
|
trimmedPayload != lastAppliedPayload,
|
||||||
|
let command = AppNavigationCommand.parse(trimmedPayload) {
|
||||||
|
lastAppliedPayload = trimmedPayload
|
||||||
|
continuation.yield(command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try? await Task.sleep(for: pollingInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { _ in
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pollingIntervalDuration: Duration {
|
||||||
|
guard let rawValue = environment[Self.pollingIntervalEnvironmentKey],
|
||||||
|
let milliseconds = Int(rawValue),
|
||||||
|
milliseconds > 0 else {
|
||||||
|
return .milliseconds(600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .milliseconds(milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppNavigationCommand: Equatable {
|
||||||
|
case mailbox(mailbox: Mailbox, search: String?, unreadOnly: Bool?)
|
||||||
|
case thread(
|
||||||
|
threadRouteID: String,
|
||||||
|
mailbox: Mailbox?,
|
||||||
|
messageRouteID: String?,
|
||||||
|
search: String?,
|
||||||
|
unreadOnly: Bool?
|
||||||
|
)
|
||||||
|
case compose(draft: ComposeDraft)
|
||||||
|
|
||||||
|
static let routeEnvironmentKey = "SOCIALIO_ROUTE"
|
||||||
|
static let jsonEnvironmentKey = "SOCIALIO_COMMAND_JSON"
|
||||||
|
|
||||||
|
static func from(environment: [String: String]) -> AppNavigationCommand? {
|
||||||
|
if let json = environment[jsonEnvironmentKey] {
|
||||||
|
return from(json: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let route = environment[routeEnvironmentKey] {
|
||||||
|
return parse(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ rawValue: String) -> AppNavigationCommand? {
|
||||||
|
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("{") {
|
||||||
|
return from(json: trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = URL(string: trimmed) else { return nil }
|
||||||
|
return from(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(url: URL) -> AppNavigationCommand? {
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = (components.host ?? "").lowercased()
|
||||||
|
let pathComponents = url.path
|
||||||
|
.split(separator: "/")
|
||||||
|
.map(String.init)
|
||||||
|
|
||||||
|
let queryItems = components.queryItems ?? []
|
||||||
|
let mailbox = queryItems.value(named: "mailbox").flatMap(Mailbox.init(rawValue:))
|
||||||
|
let threadRouteID = queryItems.value(named: "thread")
|
||||||
|
let messageRouteID = queryItems.value(named: "message")
|
||||||
|
let search = queryItems.value(named: "search")
|
||||||
|
let unreadOnly = queryItems.value(named: "unreadOnly").flatMap(Bool.init)
|
||||||
|
|
||||||
|
switch host {
|
||||||
|
case "mailbox":
|
||||||
|
guard let mailboxID = pathComponents.first, let mailbox = Mailbox(rawValue: mailboxID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||||
|
|
||||||
|
case "thread":
|
||||||
|
guard let routeID = pathComponents.first else { return nil }
|
||||||
|
return .thread(
|
||||||
|
threadRouteID: routeID,
|
||||||
|
mailbox: mailbox,
|
||||||
|
messageRouteID: messageRouteID,
|
||||||
|
search: search,
|
||||||
|
unreadOnly: unreadOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
case "compose":
|
||||||
|
return .compose(
|
||||||
|
draft: ComposeDraft(
|
||||||
|
to: queryItems.value(named: "to") ?? "",
|
||||||
|
subject: queryItems.value(named: "subject") ?? "",
|
||||||
|
body: queryItems.value(named: "body") ?? ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case "open", "":
|
||||||
|
if let threadRouteID {
|
||||||
|
return .thread(
|
||||||
|
threadRouteID: threadRouteID,
|
||||||
|
mailbox: mailbox,
|
||||||
|
messageRouteID: messageRouteID,
|
||||||
|
search: search,
|
||||||
|
unreadOnly: unreadOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mailbox {
|
||||||
|
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
if queryItems.value(named: "to") != nil ||
|
||||||
|
queryItems.value(named: "subject") != nil ||
|
||||||
|
queryItems.value(named: "body") != nil {
|
||||||
|
return .compose(
|
||||||
|
draft: ComposeDraft(
|
||||||
|
to: queryItems.value(named: "to") ?? "",
|
||||||
|
subject: queryItems.value(named: "subject") ?? "",
|
||||||
|
body: queryItems.value(named: "body") ?? ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(json: String) -> AppNavigationCommand? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let payload = try decoder.decode(AppNavigationPayload.self, from: data)
|
||||||
|
return payload.command
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AppNavigationPayload: Decodable {
|
||||||
|
enum Kind: String, Decodable {
|
||||||
|
case mailbox
|
||||||
|
case thread
|
||||||
|
case compose
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: Kind?
|
||||||
|
let mailbox: Mailbox?
|
||||||
|
let threadID: String?
|
||||||
|
let messageID: String?
|
||||||
|
let search: String?
|
||||||
|
let unreadOnly: Bool?
|
||||||
|
let to: String?
|
||||||
|
let subject: String?
|
||||||
|
let body: String?
|
||||||
|
|
||||||
|
var command: AppNavigationCommand? {
|
||||||
|
switch kind {
|
||||||
|
case .mailbox:
|
||||||
|
guard let mailbox else { return nil }
|
||||||
|
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||||
|
|
||||||
|
case .thread:
|
||||||
|
guard let threadID else { return nil }
|
||||||
|
return .thread(
|
||||||
|
threadRouteID: threadID,
|
||||||
|
mailbox: mailbox,
|
||||||
|
messageRouteID: messageID,
|
||||||
|
search: search,
|
||||||
|
unreadOnly: unreadOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
case .compose:
|
||||||
|
return .compose(
|
||||||
|
draft: ComposeDraft(
|
||||||
|
to: to ?? "",
|
||||||
|
subject: subject ?? "",
|
||||||
|
body: body ?? ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case nil:
|
||||||
|
if let threadID {
|
||||||
|
return .thread(
|
||||||
|
threadRouteID: threadID,
|
||||||
|
mailbox: mailbox,
|
||||||
|
messageRouteID: messageID,
|
||||||
|
search: search,
|
||||||
|
unreadOnly: unreadOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mailbox {
|
||||||
|
return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
if to != nil || subject != nil || body != nil {
|
||||||
|
return .compose(
|
||||||
|
draft: ComposeDraft(
|
||||||
|
to: to ?? "",
|
||||||
|
subject: subject ?? "",
|
||||||
|
body: body ?? ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension [URLQueryItem] {
|
||||||
|
func value(named name: String) -> String? {
|
||||||
|
first(where: { $0.name == name })?.value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class AppViewModel {
|
||||||
|
var selectedMailbox: Mailbox = .inbox
|
||||||
|
var selectedThreadID: MailThread.ID?
|
||||||
|
var focusedMessageRouteID: String?
|
||||||
|
var searchText = ""
|
||||||
|
var showUnreadOnly = false
|
||||||
|
var isComposing = false
|
||||||
|
var composeDraft = ComposeDraft()
|
||||||
|
var threads: [MailThread] = []
|
||||||
|
var isLoading = false
|
||||||
|
var errorMessage: String?
|
||||||
|
var mailboxNavigationToken = UUID()
|
||||||
|
var threadNavigationToken = UUID()
|
||||||
|
|
||||||
|
private let service: MailServicing
|
||||||
|
private let controlService: AppControlServicing
|
||||||
|
private var pendingNavigationCommand: AppNavigationCommand?
|
||||||
|
private var isListeningForBackendCommands = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
service: MailServicing = MockMailService(),
|
||||||
|
controlService: AppControlServicing = MockBackendControlService()
|
||||||
|
) {
|
||||||
|
self.service = service
|
||||||
|
self.controlService = controlService
|
||||||
|
if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) {
|
||||||
|
apply(command: command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedThread: MailThread? {
|
||||||
|
get { threads.first(where: { $0.id == selectedThreadID }) }
|
||||||
|
set { selectedThreadID = newValue?.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredThreads: [MailThread] {
|
||||||
|
threads
|
||||||
|
.filter { thread in
|
||||||
|
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
|
||||||
|
}
|
||||||
|
.filter { thread in
|
||||||
|
!showUnreadOnly || thread.isUnread
|
||||||
|
}
|
||||||
|
.filter(matchesSearch)
|
||||||
|
.sorted { $0.lastUpdated > $1.lastUpdated }
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalUnreadCount: Int {
|
||||||
|
threads.filter(\.isUnread).count
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadCount(in mailbox: Mailbox) -> Int {
|
||||||
|
threads.filter { thread in
|
||||||
|
mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
|
||||||
|
}
|
||||||
|
.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func unreadCount(in mailbox: Mailbox) -> Int {
|
||||||
|
threads.filter { thread in
|
||||||
|
let matchesMailbox = mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
|
||||||
|
return matchesMailbox && thread.isUnread
|
||||||
|
}
|
||||||
|
.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
guard threads.isEmpty else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
threads = try await service.loadThreads()
|
||||||
|
|
||||||
|
if let command = pendingNavigationCommand {
|
||||||
|
pendingNavigationCommand = nil
|
||||||
|
apply(command: command)
|
||||||
|
} else {
|
||||||
|
reconcileSelectionForCurrentFilters()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to load mail."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleStar(for thread: MailThread) {
|
||||||
|
toggleStar(forThreadID: thread.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleStar(forThreadID threadID: MailThread.ID) {
|
||||||
|
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
||||||
|
var updatedThread = threads[index]
|
||||||
|
updatedThread.isStarred.toggle()
|
||||||
|
threads[index] = updatedThread
|
||||||
|
reconcileSelectionForCurrentFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleRead(for thread: MailThread) {
|
||||||
|
toggleRead(forThreadID: thread.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleRead(forThreadID threadID: MailThread.ID) {
|
||||||
|
guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
|
||||||
|
var updatedThread = threads[index]
|
||||||
|
updatedThread.isUnread.toggle()
|
||||||
|
threads[index] = updatedThread
|
||||||
|
reconcileSelectionForCurrentFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectMailbox(_ mailbox: Mailbox) {
|
||||||
|
selectedMailbox = mailbox
|
||||||
|
clearThreadSelection()
|
||||||
|
mailboxNavigationToken = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUnreadOnly(_ unreadOnly: Bool) {
|
||||||
|
showUnreadOnly = unreadOnly
|
||||||
|
clearThreadSelection()
|
||||||
|
mailboxNavigationToken = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSearchText(_ text: String) {
|
||||||
|
searchText = text
|
||||||
|
reconcileSelectionForCurrentFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startCompose() {
|
||||||
|
composeDraft = ComposeDraft()
|
||||||
|
focusedMessageRouteID = nil
|
||||||
|
isComposing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) {
|
||||||
|
guard let thread = thread(withID: threadID) else { return }
|
||||||
|
|
||||||
|
selectedThreadID = threadID
|
||||||
|
|
||||||
|
if let focusedMessageRouteID,
|
||||||
|
thread.messages.contains(where: { $0.routeID == focusedMessageRouteID }) {
|
||||||
|
self.focusedMessageRouteID = focusedMessageRouteID
|
||||||
|
} else {
|
||||||
|
self.focusedMessageRouteID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
threadNavigationToken = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissThreadSelection() {
|
||||||
|
clearThreadSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginBackendControl() async {
|
||||||
|
guard !isListeningForBackendCommands else { return }
|
||||||
|
isListeningForBackendCommands = true
|
||||||
|
|
||||||
|
for await command in controlService.commands() {
|
||||||
|
apply(command: command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(url: URL) {
|
||||||
|
guard let command = AppNavigationCommand.from(url: url) else {
|
||||||
|
errorMessage = "Unable to open requested destination."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(command: command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(command: AppNavigationCommand) {
|
||||||
|
switch command {
|
||||||
|
case let .mailbox(mailbox, search, unreadOnly):
|
||||||
|
isComposing = false
|
||||||
|
searchText = search ?? ""
|
||||||
|
showUnreadOnly = unreadOnly ?? false
|
||||||
|
selectMailbox(mailbox)
|
||||||
|
|
||||||
|
case let .thread(threadRouteID, mailbox, messageRouteID, search, unreadOnly):
|
||||||
|
guard !threads.isEmpty else {
|
||||||
|
pendingNavigationCommand = command
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchText = search ?? ""
|
||||||
|
showUnreadOnly = unreadOnly ?? false
|
||||||
|
isComposing = false
|
||||||
|
|
||||||
|
guard let thread = threads.first(where: { $0.routeID == threadRouteID }) else {
|
||||||
|
errorMessage = "Unable to open requested conversation."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedMailbox = mailbox ?? thread.mailbox
|
||||||
|
openThread(withID: thread.id, focusedMessageRouteID: messageRouteID)
|
||||||
|
|
||||||
|
case let .compose(draft):
|
||||||
|
focusedMessageRouteID = nil
|
||||||
|
composeDraft = draft
|
||||||
|
isComposing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCurrentDraft() async {
|
||||||
|
let draft = composeDraft
|
||||||
|
isComposing = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
let sentThread = try await service.send(draft: draft)
|
||||||
|
threads.insert(sentThread, at: 0)
|
||||||
|
selectedMailbox = .sent
|
||||||
|
openThread(withID: sentThread.id)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Unable to send message."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchesSearch(thread: MailThread) -> Bool {
|
||||||
|
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !query.isEmpty else { return true }
|
||||||
|
|
||||||
|
let haystack = [
|
||||||
|
thread.subject,
|
||||||
|
thread.previewText,
|
||||||
|
thread.participants.map(\.name).joined(separator: " "),
|
||||||
|
thread.tags.joined(separator: " ")
|
||||||
|
]
|
||||||
|
.joined(separator: " ")
|
||||||
|
.localizedLowercase
|
||||||
|
|
||||||
|
return haystack.contains(query.localizedLowercase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thread(withID threadID: MailThread.ID) -> MailThread? {
|
||||||
|
threads.first(where: { $0.id == threadID })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearThreadSelection() {
|
||||||
|
selectedThreadID = nil
|
||||||
|
focusedMessageRouteID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconcileSelectionForCurrentFilters() {
|
||||||
|
if let selectedThreadID,
|
||||||
|
filteredThreads.contains(where: { $0.id == selectedThreadID }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearThreadSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SocialIOApp: App {
|
||||||
|
@State private var model = AppViewModel()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
MailRootView(model: model)
|
||||||
|
.tint(MailTheme.accent)
|
||||||
|
.onOpenURL { url in
|
||||||
|
model.apply(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.defaultSize(width: 1440, height: 900)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Mailbox: String, CaseIterable, Identifiable, Codable {
|
||||||
|
case inbox
|
||||||
|
case starred
|
||||||
|
case sent
|
||||||
|
case drafts
|
||||||
|
case archive
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .inbox: "Inbox"
|
||||||
|
case .starred: "Starred"
|
||||||
|
case .sent: "Sent"
|
||||||
|
case .drafts: "Drafts"
|
||||||
|
case .archive: "Archive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .inbox: "tray.full"
|
||||||
|
case .starred: "star"
|
||||||
|
case .sent: "paperplane"
|
||||||
|
case .drafts: "doc.text"
|
||||||
|
case .archive: "archivebox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MailPerson: Identifiable, Hashable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let name: String
|
||||||
|
let email: String
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), name: String, email: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.email = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MailMessage: Identifiable, Hashable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let routeID: String
|
||||||
|
let sender: MailPerson
|
||||||
|
let recipients: [MailPerson]
|
||||||
|
let sentAt: Date
|
||||||
|
let body: String
|
||||||
|
let isDraft: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
routeID: String = UUID().uuidString.lowercased(),
|
||||||
|
sender: MailPerson,
|
||||||
|
recipients: [MailPerson],
|
||||||
|
sentAt: Date,
|
||||||
|
body: String,
|
||||||
|
isDraft: Bool = false
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.routeID = routeID
|
||||||
|
self.sender = sender
|
||||||
|
self.recipients = recipients
|
||||||
|
self.sentAt = sentAt
|
||||||
|
self.body = body
|
||||||
|
self.isDraft = isDraft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MailThread: Identifiable, Hashable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let routeID: String
|
||||||
|
var mailbox: Mailbox
|
||||||
|
var subject: String
|
||||||
|
var participants: [MailPerson]
|
||||||
|
var messages: [MailMessage]
|
||||||
|
var isUnread: Bool
|
||||||
|
var isStarred: Bool
|
||||||
|
var tags: [String]
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
routeID: String = UUID().uuidString.lowercased(),
|
||||||
|
mailbox: Mailbox,
|
||||||
|
subject: String,
|
||||||
|
participants: [MailPerson],
|
||||||
|
messages: [MailMessage],
|
||||||
|
isUnread: Bool,
|
||||||
|
isStarred: Bool,
|
||||||
|
tags: [String] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.routeID = routeID
|
||||||
|
self.mailbox = mailbox
|
||||||
|
self.subject = subject
|
||||||
|
self.participants = participants
|
||||||
|
self.messages = messages.sorted { $0.sentAt < $1.sentAt }
|
||||||
|
self.isUnread = isUnread
|
||||||
|
self.isStarred = isStarred
|
||||||
|
self.tags = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestMessage: MailMessage? {
|
||||||
|
messages.max(by: { $0.sentAt < $1.sentAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewText: String {
|
||||||
|
latestMessage?.body.replacingOccurrences(of: "\n", with: " ") ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastUpdated: Date {
|
||||||
|
latestMessage?.sentAt ?? .distantPast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComposeDraft: Equatable {
|
||||||
|
var to = ""
|
||||||
|
var subject = ""
|
||||||
|
var body = ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol MailServicing {
|
||||||
|
func loadThreads() async throws -> [MailThread]
|
||||||
|
func send(draft: ComposeDraft) async throws -> MailThread
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockMailService: MailServicing {
|
||||||
|
private let me = MailPerson(name: "Phil Kunz", email: "phil@social.io")
|
||||||
|
|
||||||
|
func loadThreads() async throws -> [MailThread] {
|
||||||
|
try await Task.sleep(for: .milliseconds(150))
|
||||||
|
return seededThreads.sorted { $0.lastUpdated > $1.lastUpdated }
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(draft: ComposeDraft) async throws -> MailThread {
|
||||||
|
try await Task.sleep(for: .milliseconds(120))
|
||||||
|
|
||||||
|
let threadRouteID = "sent-\(UUID().uuidString.lowercased())"
|
||||||
|
let messageRouteID = "\(threadRouteID)-message"
|
||||||
|
|
||||||
|
let recipientNames = draft.to
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
let recipients = recipientNames.map { raw in
|
||||||
|
MailPerson(
|
||||||
|
name: raw.components(separatedBy: "@").first?.capitalized ?? raw,
|
||||||
|
email: raw
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = MailMessage(
|
||||||
|
routeID: messageRouteID,
|
||||||
|
sender: me,
|
||||||
|
recipients: recipients,
|
||||||
|
sentAt: .now,
|
||||||
|
body: draft.body
|
||||||
|
)
|
||||||
|
|
||||||
|
return MailThread(
|
||||||
|
routeID: threadRouteID,
|
||||||
|
mailbox: .sent,
|
||||||
|
subject: draft.subject.isEmpty ? "(No Subject)" : draft.subject,
|
||||||
|
participants: recipients + [me],
|
||||||
|
messages: [message],
|
||||||
|
isUnread: false,
|
||||||
|
isStarred: false,
|
||||||
|
tags: ["Sent"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var seededThreads: [MailThread] {
|
||||||
|
let alex = MailPerson(name: "Alex Rivera", email: "alex@social.io")
|
||||||
|
let nora = MailPerson(name: "Nora Chen", email: "nora@social.io")
|
||||||
|
let tanya = MailPerson(name: "Tanya Hall", email: "tanya@design.social.io")
|
||||||
|
let ops = MailPerson(name: "Ops Bot", email: "ops@social.io")
|
||||||
|
let investor = MailPerson(name: "Mina Park", email: "mina@northshore.vc")
|
||||||
|
|
||||||
|
return [
|
||||||
|
MailThread(
|
||||||
|
routeID: "launch-copy",
|
||||||
|
mailbox: .inbox,
|
||||||
|
subject: "Launch copy for the onboarding flow",
|
||||||
|
participants: [tanya, me],
|
||||||
|
messages: [
|
||||||
|
MailMessage(
|
||||||
|
routeID: "launch-copy-1",
|
||||||
|
sender: tanya,
|
||||||
|
recipients: [me],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600 * 2),
|
||||||
|
body: "I tightened the onboarding copy and added a warmer empty-state tone. If you're good with it, I can hand the strings to the app team today."
|
||||||
|
),
|
||||||
|
MailMessage(
|
||||||
|
routeID: "launch-copy-2",
|
||||||
|
sender: me,
|
||||||
|
recipients: [tanya],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600),
|
||||||
|
body: "Looks strong. Let's keep the playful language on iPhone and simplify the desktop first-run copy a bit."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
isUnread: true,
|
||||||
|
isStarred: true,
|
||||||
|
tags: ["Design", "Launch"]
|
||||||
|
),
|
||||||
|
MailThread(
|
||||||
|
routeID: "daily-sync-status",
|
||||||
|
mailbox: .inbox,
|
||||||
|
subject: "Daily inbox sync status",
|
||||||
|
participants: [ops, me],
|
||||||
|
messages: [
|
||||||
|
MailMessage(
|
||||||
|
routeID: "daily-sync-status-1",
|
||||||
|
sender: ops,
|
||||||
|
recipients: [me],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600 * 4),
|
||||||
|
body: "Mock sync complete. 1,284 messages mirrored from staging. Attachment fetch remains disabled in the sandbox profile."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
isUnread: false,
|
||||||
|
isStarred: false,
|
||||||
|
tags: ["System"]
|
||||||
|
),
|
||||||
|
MailThread(
|
||||||
|
routeID: "investor-update",
|
||||||
|
mailbox: .inbox,
|
||||||
|
subject: "Investor update before next Friday",
|
||||||
|
participants: [investor, me],
|
||||||
|
messages: [
|
||||||
|
MailMessage(
|
||||||
|
routeID: "investor-update-1",
|
||||||
|
sender: investor,
|
||||||
|
recipients: [me],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600 * 26),
|
||||||
|
body: "Could you send a concise product update and a few screenshots of the new mail experience before Friday? Interested in how you are differentiating from commodity inboxes."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
isUnread: true,
|
||||||
|
isStarred: false,
|
||||||
|
tags: ["External"]
|
||||||
|
),
|
||||||
|
MailThread(
|
||||||
|
routeID: "search-ranking-polish",
|
||||||
|
mailbox: .sent,
|
||||||
|
subject: "Re: Search ranking polish",
|
||||||
|
participants: [alex, me],
|
||||||
|
messages: [
|
||||||
|
MailMessage(
|
||||||
|
routeID: "search-ranking-polish-1",
|
||||||
|
sender: alex,
|
||||||
|
recipients: [me],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600 * 30),
|
||||||
|
body: "The current search sort is useful, but I still feel too much recency over intent."
|
||||||
|
),
|
||||||
|
MailMessage(
|
||||||
|
routeID: "search-ranking-polish-2",
|
||||||
|
sender: me,
|
||||||
|
recipients: [alex],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600 * 28),
|
||||||
|
body: "Agreed. I want us to bias toward active collaborators and threads with lightweight action language like review, approve, or ship."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
isUnread: false,
|
||||||
|
isStarred: false,
|
||||||
|
tags: ["Search"]
|
||||||
|
),
|
||||||
|
MailThread(
|
||||||
|
routeID: "welcome-to-socialio",
|
||||||
|
mailbox: .drafts,
|
||||||
|
subject: "Welcome to social.io mail",
|
||||||
|
participants: [me, nora],
|
||||||
|
messages: [
|
||||||
|
MailMessage(
|
||||||
|
routeID: "welcome-to-socialio-1",
|
||||||
|
sender: me,
|
||||||
|
recipients: [nora],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600 * 6),
|
||||||
|
body: "Thanks for joining the early design partner group. Here is a quick overview of our calm, collaborative take on email...",
|
||||||
|
isDraft: true
|
||||||
|
)
|
||||||
|
],
|
||||||
|
isUnread: false,
|
||||||
|
isStarred: false,
|
||||||
|
tags: ["Draft"]
|
||||||
|
),
|
||||||
|
MailThread(
|
||||||
|
routeID: "roadmap-notes",
|
||||||
|
mailbox: .archive,
|
||||||
|
subject: "Roadmap notes from product sync",
|
||||||
|
participants: [nora, alex, me],
|
||||||
|
messages: [
|
||||||
|
MailMessage(
|
||||||
|
routeID: "roadmap-notes-1",
|
||||||
|
sender: nora,
|
||||||
|
recipients: [alex, me],
|
||||||
|
sentAt: .now.addingTimeInterval(-3600 * 72),
|
||||||
|
body: "Captured the big roadmap themes: faster triage, identity-rich threads, and calmer notifications. I archived the raw notes after cleanup."
|
||||||
|
)
|
||||||
|
],
|
||||||
|
isUnread: false,
|
||||||
|
isStarred: true,
|
||||||
|
tags: ["Product"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user