Add MailRootView and related components for mail functionality

- Implement MailRootView with navigation and sidebar for mail management.
- Create MailSidebarView, ThreadListView, and ThreadDetailView for displaying mail content.
- Introduce ComposeView for composing new messages.
- Add MailTheme for consistent styling across mail components.
- Implement adaptive layouts for iOS and macOS.
- Create unit tests for AppNavigationCommand and AppViewModel to ensure correct functionality.
This commit is contained in:
2026-04-19 01:00:32 +02:00
parent 6b665c666f
commit ad059e9b8d
15 changed files with 19 additions and 19 deletions
+46
View File
@@ -0,0 +1,46 @@
{
"@git.zone/tsswift": {
"defaultScheme": "SocialIO",
"defaultConfiguration": "Debug",
"derivedDataPath": ".build/xcode-derived-data",
"parallelBuilds": true,
"parallelTests": false,
"buildPlatforms": ["macos", "ios", "ipad"],
"testPlatforms": ["macos"],
"defaultPlatform": "macos",
"app": {
"bundleId": "io.social.app"
},
"control": {
"filePath": "/tmp/socialio-control.txt",
"fileEnvKey": "SOCIALIO_CONTROL_FILE",
"pollMs": 250,
"pollMsEnvKey": "SOCIALIO_CONTROL_POLL_MS",
"initialCommand": "socialio://mailbox/inbox"
},
"screenshots": {
"scenariosFile": "Automation/ui-screenshot-routes.txt",
"outputDir": "/tmp/socialio-ui-review",
"reviewPlatforms": ["ios", "ipad", "macos"],
"launchDelayMs": 2000,
"stepDelayMs": 1200,
"initialCommand": "socialio://mailbox/inbox",
"macosWindow": {
"width": 1440,
"height": 900,
"x": 80,
"y": 80
}
},
"targets": {
"ios": {
"simulatorName": "iPhone Air",
"runtime": "latest"
},
"ipad": {
"simulatorName": "iPad mini (A17 Pro)",
"runtime": "latest"
}
}
}
}
@@ -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.
+500
View File
@@ -0,0 +1,500 @@
// !$*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 */; };
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.swift */; };
A10000000000000000000009 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppViewModelTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A90000000000000000000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A60000000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = A50000000000000000000001;
remoteInfo = SocialIO;
};
/* End PBXContainerItemProxy 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>"; };
A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = "<group>"; };
A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A30000000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A30000000000000000000004 /* 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 */,
A4000000000000000000000B /* Tests */,
);
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 */,
A2000000000000000000000B /* SocialIOTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
A4000000000000000000000A /* Mail */ = {
isa = PBXGroup;
children = (
A20000000000000000000005 /* MailRootView.swift */,
);
path = Mail;
sourceTree = "<group>";
};
A4000000000000000000000B /* Tests */ = {
isa = PBXGroup;
children = (
A20000000000000000000009 /* AppNavigationCommandTests.swift */,
A2000000000000000000000A /* AppViewModelTests.swift */,
);
path = Tests;
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";
};
A50000000000000000000002 /* SocialIOTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A70000000000000000000003 /* Build configuration list for PBXNativeTarget "SocialIOTests" */;
buildPhases = (
A30000000000000000000006 /* Sources */,
A30000000000000000000004 /* Frameworks */,
A30000000000000000000005 /* Resources */,
);
buildRules = (
);
dependencies = (
A90000000000000000000002 /* PBXTargetDependency */,
);
name = SocialIOTests;
productName = SocialIOTests;
productReference = A2000000000000000000000B /* SocialIOTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A60000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
A50000000000000000000001 = {
CreatedOnToolsVersion = 26.0;
};
A50000000000000000000002 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = A50000000000000000000001;
};
};
};
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 */,
A50000000000000000000002 /* SocialIOTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A30000000000000000000003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A30000000000000000000005 /* 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;
};
A30000000000000000000006 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */,
A10000000000000000000009 /* AppViewModelTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A90000000000000000000002 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A50000000000000000000001 /* SocialIO */;
targetProxy = A90000000000000000000001 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency 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_TESTABILITY = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "social.io";
INFOPLIST_KEY_CFBundleName = "social.io";
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 = "social.io";
INFOPLIST_KEY_CFBundleName = "social.io";
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;
};
A80000000000000000000005 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SocialIO.app/Contents/MacOS/SocialIO";
};
name = Debug;
};
A80000000000000000000006 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = macosx;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SocialIO.app/Contents/MacOS/SocialIO";
};
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;
};
A70000000000000000000003 /* Build configuration list for PBXNativeTarget "SocialIOTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A80000000000000000000005 /* Debug */,
A80000000000000000000006 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A60000000000000000000001 /* Project object */;
}
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000001"
BuildableName = "SocialIO.app"
BlueprintName = "SocialIO"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000002"
BuildableName = "SocialIOTests.xctest"
BlueprintName = "SocialIOTests"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000001"
BuildableName = "SocialIO.app"
BlueprintName = "SocialIO"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000002"
BuildableName = "SocialIOTests.xctest"
BlueprintName = "SocialIOTests"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000001"
BuildableName = "SocialIO.app"
BlueprintName = "SocialIO"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A50000000000000000000001"
BuildableName = "SocialIO.app"
BlueprintName = "SocialIO"
ReferencedContainer = "container:SocialIO.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+66
View File
@@ -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
}
}
+264
View File
@@ -0,0 +1,264 @@
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 isSending = 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
defer { isListeningForBackendCommands = false }
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 -> Bool {
guard !isSending else { return false }
let draft = composeDraft
isSending = true
defer { isSending = false }
do {
let sentThread = try await service.send(draft: draft)
threads.insert(sentThread, at: 0)
selectedMailbox = .sent
openThread(withID: sentThread.id)
isComposing = false
return true
} catch {
errorMessage = "Unable to send message."
return false
}
}
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()
}
}
+19
View File
@@ -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
}
}
+123
View File
@@ -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
@@ -0,0 +1,51 @@
import XCTest
@testable import SocialIO
final class AppNavigationCommandTests: XCTestCase {
func testFromEnvironmentPrefersJSONOverRoute() {
let environment = [
AppNavigationCommand.routeEnvironmentKey: "socialio://mailbox/inbox",
AppNavigationCommand.jsonEnvironmentKey: #"{"kind":"compose","to":"team@social.io","subject":"Hello","body":"Hi"}"#
]
let command = AppNavigationCommand.from(environment: environment)
XCTAssertEqual(
command,
.compose(draft: ComposeDraft(to: "team@social.io", subject: "Hello", body: "Hi"))
)
}
func testParseMailboxURLIncludesSearchAndUnreadOnly() {
let command = AppNavigationCommand.parse("socialio://mailbox/starred?search=roadmap&unreadOnly=true")
XCTAssertEqual(
command,
.mailbox(mailbox: .starred, search: "roadmap", unreadOnly: true)
)
}
func testParseOpenURLMapsThreadAndMessageSelection() {
let command = AppNavigationCommand.parse("socialio://open?thread=launch-copy&message=launch-copy-2&mailbox=sent")
XCTAssertEqual(
command,
.thread(
threadRouteID: "launch-copy",
mailbox: .sent,
messageRouteID: "launch-copy-2",
search: nil,
unreadOnly: nil
)
)
}
func testParseJSONWithoutKindFallsBackToComposePayload() {
let command = AppNavigationCommand.from(json: #"{"to":"grandma@example.com","subject":"Photos","body":"Hi Grandma"}"#)
XCTAssertEqual(
command,
.compose(draft: ComposeDraft(to: "grandma@example.com", subject: "Photos", body: "Hi Grandma"))
)
}
}
+249
View File
@@ -0,0 +1,249 @@
import XCTest
@testable import SocialIO
final class AppViewModelTests: XCTestCase {
@MainActor
func testFilteredThreadsRespectMailboxUnreadAndSearch() async throws {
let inboxUnread = makeThread(
routeID: "inbox-unread",
mailbox: .inbox,
subject: "Roadmap review",
body: "Please review the roadmap before launch.",
isUnread: true,
isStarred: false,
sentAt: .now
)
let inboxRead = makeThread(
routeID: "inbox-read",
mailbox: .inbox,
subject: "Budget sync",
body: "Closing the budget sync loop.",
isUnread: false,
isStarred: false,
sentAt: .now.addingTimeInterval(-60)
)
let archivedStarred = makeThread(
routeID: "archived-starred",
mailbox: .archive,
subject: "Archived roadmap notes",
body: "Keeping the roadmap context around.",
isUnread: true,
isStarred: true,
sentAt: .now.addingTimeInterval(-120)
)
let model = AppViewModel(
service: StubMailService(threadsToLoad: [inboxRead, archivedStarred, inboxUnread]),
controlService: StubControlService()
)
await model.load()
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["inbox-unread", "inbox-read"])
model.setUnreadOnly(true)
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["inbox-unread"])
model.selectMailbox(.starred)
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["archived-starred"])
model.setSearchText("context")
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["archived-starred"])
model.setSearchText("launch")
XCTAssertTrue(model.filteredThreads.isEmpty)
}
@MainActor
func testPendingThreadCommandAppliesAfterLoad() async throws {
let thread = makeThread(
routeID: "launch-copy",
mailbox: .inbox,
subject: "Launch copy",
body: "The second pass is ready.",
isUnread: true,
isStarred: false,
sentAt: .now,
messageRouteID: "launch-copy-2"
)
let model = AppViewModel(
service: StubMailService(threadsToLoad: [thread]),
controlService: StubControlService()
)
model.apply(
command: .thread(
threadRouteID: "launch-copy",
mailbox: .inbox,
messageRouteID: "launch-copy-2",
search: nil,
unreadOnly: nil
)
)
XCTAssertNil(model.selectedThread)
await model.load()
XCTAssertEqual(model.selectedThread?.routeID, "launch-copy")
XCTAssertEqual(model.focusedMessageRouteID, "launch-copy-2")
XCTAssertEqual(model.selectedMailbox, .inbox)
}
@MainActor
func testSendCurrentDraftSuccessClosesComposeAndSelectsSentThread() async throws {
let sentThread = makeThread(
routeID: "sent-1",
mailbox: .sent,
subject: "Status",
body: "Sent body",
isUnread: false,
isStarred: false,
sentAt: .now
)
let service = StubMailService(threadsToLoad: [], sendResult: .success(sentThread))
let model = AppViewModel(service: service, controlService: StubControlService())
let draft = ComposeDraft(to: "team@social.io", subject: "Status", body: "Sent body")
model.composeDraft = draft
model.isComposing = true
let didSend = await model.sendCurrentDraft()
XCTAssertTrue(didSend)
XCTAssertEqual(service.sentDrafts, [draft])
XCTAssertFalse(model.isComposing)
XCTAssertFalse(model.isSending)
XCTAssertEqual(model.selectedMailbox, .sent)
XCTAssertEqual(model.selectedThread?.routeID, "sent-1")
XCTAssertEqual(model.threads.first?.routeID, "sent-1")
}
@MainActor
func testSendCurrentDraftFailureKeepsComposeOpenAndPreservesDraft() async throws {
let service = StubMailService(threadsToLoad: [], sendResult: .failure(TestError.sendFailed))
let model = AppViewModel(service: service, controlService: StubControlService())
let draft = ComposeDraft(to: "team@social.io", subject: "Status", body: "Body")
model.composeDraft = draft
model.isComposing = true
let didSend = await model.sendCurrentDraft()
XCTAssertFalse(didSend)
XCTAssertEqual(service.sentDrafts, [draft])
XCTAssertTrue(model.isComposing)
XCTAssertFalse(model.isSending)
XCTAssertEqual(model.composeDraft, draft)
XCTAssertEqual(model.errorMessage, "Unable to send message.")
XCTAssertTrue(model.threads.isEmpty)
}
@MainActor
func testBeginBackendControlCanRestartAfterPreviousStreamFinishes() async throws {
let model = AppViewModel(
service: StubMailService(threadsToLoad: [
makeThread(
routeID: "launch-copy",
mailbox: .inbox,
subject: "Launch copy",
body: "Mail body",
isUnread: true,
isStarred: false,
sentAt: .now
)
]),
controlService: StubControlService(commandsPerCall: [
[.mailbox(mailbox: .archive, search: nil, unreadOnly: true)],
[.mailbox(mailbox: .starred, search: "roadmap", unreadOnly: false)]
])
)
await model.load()
await model.beginBackendControl()
XCTAssertEqual(model.selectedMailbox, .archive)
XCTAssertTrue(model.showUnreadOnly)
await model.beginBackendControl()
XCTAssertEqual(model.selectedMailbox, .starred)
XCTAssertEqual(model.searchText, "roadmap")
XCTAssertFalse(model.showUnreadOnly)
}
}
private enum TestError: Error {
case sendFailed
}
private final class StubMailService: MailServicing {
private let threadsToLoad: [MailThread]
private let sendResult: Result<MailThread, Error>
private(set) var sentDrafts: [ComposeDraft] = []
init(threadsToLoad: [MailThread], sendResult: Result<MailThread, Error> = .failure(TestError.sendFailed)) {
self.threadsToLoad = threadsToLoad
self.sendResult = sendResult
}
func loadThreads() async throws -> [MailThread] {
threadsToLoad
}
func send(draft: ComposeDraft) async throws -> MailThread {
sentDrafts.append(draft)
return try sendResult.get()
}
}
private final class StubControlService: AppControlServicing {
private let commandsPerCall: [[AppNavigationCommand]]
private var callCount = 0
init(commandsPerCall: [[AppNavigationCommand]] = []) {
self.commandsPerCall = commandsPerCall
}
func commands() -> AsyncStream<AppNavigationCommand> {
let commands = callCount < commandsPerCall.count ? commandsPerCall[callCount] : []
callCount += 1
return AsyncStream { continuation in
for command in commands {
continuation.yield(command)
}
continuation.finish()
}
}
}
private func makeThread(
routeID: String,
mailbox: Mailbox,
subject: String,
body: String,
isUnread: Bool,
isStarred: Bool,
sentAt: Date,
messageRouteID: String? = nil
) -> MailThread {
let sender = MailPerson(name: "Sender", email: "sender@social.io")
let recipient = MailPerson(name: "Recipient", email: "recipient@social.io")
return MailThread(
routeID: routeID,
mailbox: mailbox,
subject: subject,
participants: [sender, recipient],
messages: [
MailMessage(
routeID: messageRouteID ?? "\(routeID)-message",
sender: sender,
recipients: [recipient],
sentAt: sentAt,
body: body
)
],
isUnread: isUnread,
isStarred: isStarred,
tags: []
)
}