diff --git a/.smartconfig.json b/.smartconfig.json
index 5594b9f..f5ea851 100644
--- a/.smartconfig.json
+++ b/.smartconfig.json
@@ -5,9 +5,19 @@
"derivedDataPath": "swift/.build/xcode-derived-data",
"parallelBuilds": true,
"parallelTests": false,
- "buildPlatforms": ["macos", "ios", "ipad"],
- "testPlatforms": ["macos"],
- "watchPlatforms": ["macos", "ios", "ipad"],
+ "buildPlatforms": [
+ "macos",
+ "ios",
+ "ipad"
+ ],
+ "testPlatforms": [
+ "macos"
+ ],
+ "watchPlatforms": [
+ "macos",
+ "ios",
+ "ipad"
+ ],
"watchDebounceMs": 1200,
"defaultPlatform": "macos",
"app": {
@@ -23,7 +33,11 @@
"screenshots": {
"scenariosFile": "swift/Automation/ui-screenshot-routes.txt",
"outputDir": "/tmp/socialio-ui-review",
- "reviewPlatforms": ["ios", "ipad", "macos"],
+ "reviewPlatforms": [
+ "ios",
+ "ipad",
+ "macos"
+ ],
"launchDelayMs": 2000,
"stepDelayMs": 1200,
"initialCommand": "socialio://mailbox/inbox",
@@ -36,12 +50,14 @@
},
"targets": {
"ios": {
- "simulatorName": "iPhone Air",
- "runtime": "latest"
+ "simulatorName": "iPhone 17 Pro",
+ "runtime": "26.4",
+ "simulatorUDID": "8EBCDD58-34AB-457A-A878-8004A6108CA9"
},
"ipad": {
- "simulatorName": "iPad mini (A17 Pro)",
- "runtime": "latest"
+ "simulatorName": "iPad Pro 11-inch (M5)",
+ "runtime": "26.4",
+ "simulatorUDID": "4ED4902E-C8EB-444A-A8FF-183ABC08E8C2"
}
}
}
diff --git a/swift/Assets.xcassets/AppIcon.appiconset/Contents.json b/swift/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..31fbaca
--- /dev/null
+++ b/swift/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,8 @@
+{
+ "images" : [
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/swift/Assets.xcassets/Contents.json b/swift/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/swift/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/swift/Assets.xcassets/LaneFeed.colorset/Contents.json b/swift/Assets.xcassets/LaneFeed.colorset/Contents.json
new file mode 100644
index 0000000..3f1154d
--- /dev/null
+++ b/swift/Assets.xcassets/LaneFeed.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.420",
+ "red" : "0.184"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.510",
+ "red" : "0.312"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/swift/Assets.xcassets/LanePaper.colorset/Contents.json b/swift/Assets.xcassets/LanePaper.colorset/Contents.json
new file mode 100644
index 0000000..0801d6e
--- /dev/null
+++ b/swift/Assets.xcassets/LanePaper.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.039",
+ "green" : "0.624",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.206",
+ "green" : "0.706",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/swift/Assets.xcassets/LanePeople.colorset/Contents.json b/swift/Assets.xcassets/LanePeople.colorset/Contents.json
new file mode 100644
index 0000000..f677647
--- /dev/null
+++ b/swift/Assets.xcassets/LanePeople.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.345",
+ "green" : "0.819",
+ "red" : "0.188"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.470",
+ "green" : "0.900",
+ "red" : "0.320"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/swift/Assets.xcassets/SIOTint.colorset/Contents.json b/swift/Assets.xcassets/SIOTint.colorset/Contents.json
new file mode 100644
index 0000000..3f1154d
--- /dev/null
+++ b/swift/Assets.xcassets/SIOTint.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.420",
+ "red" : "0.184"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "0.510",
+ "red" : "0.312"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/swift/SocialIO.entitlements b/swift/SocialIO.entitlements
new file mode 100644
index 0000000..fd1013d
--- /dev/null
+++ b/swift/SocialIO.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.developer.activitykit
+
+
+
diff --git a/swift/SocialIO.xcodeproj/project.pbxproj b/swift/SocialIO.xcodeproj/project.pbxproj
index 9ca7727..c8a8f6c 100644
--- a/swift/SocialIO.xcodeproj/project.pbxproj
+++ b/swift/SocialIO.xcodeproj/project.pbxproj
@@ -16,6 +16,31 @@
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 */; };
+ A1000000000000000000000A /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000C /* SIOTokens.swift */; };
+ A1000000000000000000000B /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000D /* ButtonStyles.swift */; };
+ A1000000000000000000000C /* GlassChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000E /* GlassChrome.swift */; };
+ A1000000000000000000000D /* LaneChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000F /* LaneChip.swift */; };
+ A1000000000000000000000E /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* AvatarView.swift */; };
+ A1000000000000000000000F /* AISummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* AISummaryCard.swift */; };
+ A10000000000000000000010 /* KeyboardHint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000012 /* KeyboardHint.swift */; };
+ A10000000000000000000011 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000013 /* Haptics.swift */; };
+ A10000000000000000000012 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* Assets.xcassets */; };
+ A10000000000000000000013 /* SocialIOWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000015 /* SocialIOWatchApp.swift */; };
+ A10000000000000000000014 /* WatchInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000016 /* WatchInboxView.swift */; };
+ A10000000000000000000015 /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000017 /* Watch SIOTokens.swift */; };
+ A10000000000000000000016 /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MailModels.swift */; };
+ A10000000000000000000017 /* MockMailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MockMailService.swift */; };
+ A10000000000000000000018 /* SocialIOWatchWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000018 /* SocialIOWatchWidgets.swift */; };
+ A10000000000000000000019 /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000017 /* Watch SIOTokens.swift */; };
+ A1000000000000000000001A /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MailModels.swift */; };
+ A1000000000000000000001B /* MockMailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MockMailService.swift */; };
+ A1000000000000000000001C /* MailNotificationActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001B /* MailNotificationActivity.swift */; };
+ A1000000000000000000001D /* MailNotificationActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001B /* MailNotificationActivity.swift */; };
+ A1000000000000000000001E /* MailNotificationWidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001D /* MailNotificationWidgetExtension.swift */; };
+ A1000000000000000000001F /* SIOTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000C /* SIOTokens.swift */; };
+ A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* Assets.xcassets */; };
+ A10000000000000000000021 /* SocialIOWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001C /* SocialIOWidgets.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ A10000000000000000000022 /* SocialIOWatchWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001A /* SocialIOWatchWidgets.appex */; platformFilter = watchos; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -26,8 +51,47 @@
remoteGlobalIDString = A50000000000000000000001;
remoteInfo = SocialIO;
};
+ A90000000000000000000003 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = A60000000000000000000001 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A50000000000000000000005;
+ remoteInfo = SocialIOWidgets;
+ };
+ A90000000000000000000004 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = A60000000000000000000001 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A50000000000000000000004;
+ remoteInfo = SocialIOWatchWidgets;
+ };
/* End PBXContainerItemProxy section */
+/* Begin PBXCopyFilesBuildPhase section */
+ A30000000000000000000010 /* Embed App Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ A10000000000000000000021 /* SocialIOWidgets.appex in Embed App Extensions */,
+ );
+ name = "Embed App Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A30000000000000000000011 /* Embed App Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ A10000000000000000000022 /* SocialIOWatchWidgets.appex in Embed App Extensions */,
+ );
+ name = "Embed App Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
A20000000000000000000001 /* SocialIOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOApp.swift; sourceTree = ""; };
A20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; };
@@ -40,6 +104,24 @@
A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = ""; };
A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = ""; };
A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ A2000000000000000000000C /* SIOTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SIOTokens.swift; sourceTree = ""; };
+ A2000000000000000000000D /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; };
+ A2000000000000000000000E /* GlassChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassChrome.swift; sourceTree = ""; };
+ A2000000000000000000000F /* LaneChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaneChip.swift; sourceTree = ""; };
+ A20000000000000000000010 /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; };
+ A20000000000000000000011 /* AISummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryCard.swift; sourceTree = ""; };
+ A20000000000000000000012 /* KeyboardHint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHint.swift; sourceTree = ""; };
+ A20000000000000000000013 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; };
+ A20000000000000000000014 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ A20000000000000000000015 /* SocialIOWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOWatchApp.swift; sourceTree = ""; };
+ A20000000000000000000016 /* WatchInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchInboxView.swift; sourceTree = ""; };
+ A20000000000000000000017 /* Watch SIOTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SIOTokens.swift; sourceTree = ""; };
+ A20000000000000000000018 /* SocialIOWatchWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOWatchWidgets.swift; sourceTree = ""; };
+ A20000000000000000000019 /* SocialIOWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SocialIOWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ A2000000000000000000001A /* SocialIOWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SocialIOWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ A2000000000000000000001B /* MailNotificationActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailNotificationActivity.swift; sourceTree = ""; };
+ A2000000000000000000001C /* SocialIOWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SocialIOWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ A2000000000000000000001D /* MailNotificationWidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailNotificationWidgetExtension.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -57,6 +139,27 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A30000000000000000000007 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A30000000000000000000008 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A3000000000000000000000D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -71,6 +174,8 @@
A40000000000000000000002 /* SocialIO */ = {
isa = PBXGroup;
children = (
+ A20000000000000000000014 /* Assets.xcassets */,
+ A4000000000000000000000D /* WatchApp */,
A40000000000000000000003 /* Sources */,
A4000000000000000000000B /* Tests */,
);
@@ -92,6 +197,8 @@
children = (
A20000000000000000000001 /* SocialIOApp.swift */,
A20000000000000000000002 /* AppViewModel.swift */,
+ A2000000000000000000001B /* MailNotificationActivity.swift */,
+ A2000000000000000000001D /* MailNotificationWidgetExtension.swift */,
A20000000000000000000007 /* AppNavigationCommand.swift */,
A20000000000000000000008 /* AppControlService.swift */,
);
@@ -101,6 +208,7 @@
A40000000000000000000005 /* Core */ = {
isa = PBXGroup;
children = (
+ A4000000000000000000000C /* Design */,
A40000000000000000000006 /* Models */,
A40000000000000000000007 /* Services */,
);
@@ -136,6 +244,9 @@
children = (
A20000000000000000000006 /* SocialIO.app */,
A2000000000000000000000B /* SocialIOTests.xctest */,
+ A2000000000000000000001C /* SocialIOWidgets.appex */,
+ A20000000000000000000019 /* SocialIOWatch.app */,
+ A2000000000000000000001A /* SocialIOWatchWidgets.appex */,
);
name = Products;
sourceTree = "";
@@ -157,6 +268,48 @@
path = Tests;
sourceTree = "";
};
+ A4000000000000000000000C /* Design */ = {
+ isa = PBXGroup;
+ children = (
+ A2000000000000000000000C /* SIOTokens.swift */,
+ A2000000000000000000000D /* ButtonStyles.swift */,
+ A2000000000000000000000E /* GlassChrome.swift */,
+ A2000000000000000000000F /* LaneChip.swift */,
+ A20000000000000000000010 /* AvatarView.swift */,
+ A20000000000000000000011 /* AISummaryCard.swift */,
+ A20000000000000000000012 /* KeyboardHint.swift */,
+ A20000000000000000000013 /* Haptics.swift */,
+ );
+ path = Design;
+ sourceTree = "";
+ };
+ A4000000000000000000000D /* WatchApp */ = {
+ isa = PBXGroup;
+ children = (
+ A20000000000000000000015 /* SocialIOWatchApp.swift */,
+ A20000000000000000000016 /* WatchInboxView.swift */,
+ A4000000000000000000000E /* Design */,
+ A4000000000000000000000F /* Widgets */,
+ );
+ path = WatchApp;
+ sourceTree = "";
+ };
+ A4000000000000000000000E /* Design */ = {
+ isa = PBXGroup;
+ children = (
+ A20000000000000000000017 /* Watch SIOTokens.swift */,
+ );
+ path = Design;
+ sourceTree = "";
+ };
+ A4000000000000000000000F /* Widgets */ = {
+ isa = PBXGroup;
+ children = (
+ A20000000000000000000018 /* SocialIOWatchWidgets.swift */,
+ );
+ path = Widgets;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -166,6 +319,7 @@
buildPhases = (
A30000000000000000000002 /* Sources */,
A30000000000000000000001 /* Frameworks */,
+ A30000000000000000000010 /* Embed App Extensions */,
A30000000000000000000003 /* Resources */,
);
buildRules = (
@@ -195,6 +349,59 @@
productReference = A2000000000000000000000B /* SocialIOTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
+ A50000000000000000000003 /* SocialIOWatch */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A70000000000000000000004 /* Build configuration list for PBXNativeTarget "SocialIOWatch" */;
+ buildPhases = (
+ A30000000000000000000009 /* Sources */,
+ A30000000000000000000007 /* Frameworks */,
+ A30000000000000000000011 /* Embed App Extensions */,
+ A3000000000000000000000B /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ A90000000000000000000006 /* PBXTargetDependency */,
+ );
+ name = SocialIOWatch;
+ productName = SocialIOWatch;
+ productReference = A20000000000000000000019 /* SocialIOWatch.app */;
+ productType = "com.apple.product-type.application";
+ };
+ A50000000000000000000004 /* SocialIOWatchWidgets */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A70000000000000000000005 /* Build configuration list for PBXNativeTarget "SocialIOWatchWidgets" */;
+ buildPhases = (
+ A3000000000000000000000A /* Sources */,
+ A30000000000000000000008 /* Frameworks */,
+ A3000000000000000000000C /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = SocialIOWatchWidgets;
+ productName = SocialIOWatchWidgets;
+ productReference = A2000000000000000000001A /* SocialIOWatchWidgets.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ A50000000000000000000005 /* SocialIOWidgets */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A70000000000000000000006 /* Build configuration list for PBXNativeTarget "SocialIOWidgets" */;
+ buildPhases = (
+ A3000000000000000000000F /* Sources */,
+ A3000000000000000000000D /* Frameworks */,
+ A3000000000000000000000E /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = SocialIOWidgets;
+ productName = SocialIOWidgets;
+ productReference = A2000000000000000000001C /* SocialIOWidgets.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -212,6 +419,15 @@
CreatedOnToolsVersion = 26.0;
TestTargetID = A50000000000000000000001;
};
+ A50000000000000000000003 = {
+ CreatedOnToolsVersion = 26.0;
+ };
+ A50000000000000000000004 = {
+ CreatedOnToolsVersion = 26.0;
+ };
+ A50000000000000000000005 = {
+ CreatedOnToolsVersion = 26.0;
+ };
};
};
buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */;
@@ -229,6 +445,9 @@
targets = (
A50000000000000000000001 /* SocialIO */,
A50000000000000000000002 /* SocialIOTests */,
+ A50000000000000000000005 /* SocialIOWidgets */,
+ A50000000000000000000003 /* SocialIOWatch */,
+ A50000000000000000000004 /* SocialIOWatchWidgets */,
);
};
/* End PBXProject section */
@@ -238,6 +457,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ A10000000000000000000012 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -248,6 +468,28 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A3000000000000000000000B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A3000000000000000000000C /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A3000000000000000000000E /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000020 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -255,10 +497,19 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ A1000000000000000000000F /* AISummaryCard.swift in Sources */,
A10000000000000000000002 /* AppViewModel.swift in Sources */,
+ A1000000000000000000001C /* MailNotificationActivity.swift in Sources */,
+ A1000000000000000000000E /* AvatarView.swift in Sources */,
+ A1000000000000000000000B /* ButtonStyles.swift in Sources */,
+ A1000000000000000000000C /* GlassChrome.swift in Sources */,
+ A10000000000000000000011 /* Haptics.swift in Sources */,
+ A10000000000000000000010 /* KeyboardHint.swift in Sources */,
+ A1000000000000000000000D /* LaneChip.swift in Sources */,
A10000000000000000000005 /* MailRootView.swift in Sources */,
A10000000000000000000003 /* MailModels.swift in Sources */,
A10000000000000000000004 /* MockMailService.swift in Sources */,
+ A1000000000000000000000A /* SIOTokens.swift in Sources */,
A10000000000000000000001 /* SocialIOApp.swift in Sources */,
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */,
A10000000000000000000007 /* AppControlService.swift in Sources */,
@@ -274,6 +525,39 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A30000000000000000000009 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000016 /* MailModels.swift in Sources */,
+ A10000000000000000000017 /* MockMailService.swift in Sources */,
+ A10000000000000000000015 /* SIOTokens.swift in Sources */,
+ A10000000000000000000013 /* SocialIOWatchApp.swift in Sources */,
+ A10000000000000000000014 /* WatchInboxView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A3000000000000000000000A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A1000000000000000000001A /* MailModels.swift in Sources */,
+ A1000000000000000000001B /* MockMailService.swift in Sources */,
+ A10000000000000000000019 /* SIOTokens.swift in Sources */,
+ A10000000000000000000018 /* SocialIOWatchWidgets.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A3000000000000000000000F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A1000000000000000000001D /* MailNotificationActivity.swift in Sources */,
+ A1000000000000000000001E /* MailNotificationWidgetExtension.swift in Sources */,
+ A1000000000000000000001F /* SIOTokens.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -282,6 +566,16 @@
target = A50000000000000000000001 /* SocialIO */;
targetProxy = A90000000000000000000001 /* PBXContainerItemProxy */;
};
+ A90000000000000000000005 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A50000000000000000000005 /* SocialIOWidgets */;
+ targetProxy = A90000000000000000000003 /* PBXContainerItemProxy */;
+ };
+ A90000000000000000000006 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A50000000000000000000004 /* SocialIOWatchWidgets */;
+ targetProxy = A90000000000000000000004 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -304,8 +598,8 @@
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;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
SDKROOT = auto;
SWIFT_VERSION = 5.0;
};
@@ -331,8 +625,8 @@
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;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
SDKROOT = auto;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
@@ -344,7 +638,9 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
+ "CODE_SIGN_ENTITLEMENTS[sdk=iphone*]" = SocialIO.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_TESTABILITY = YES;
@@ -369,6 +665,8 @@
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -384,7 +682,9 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
+ "CODE_SIGN_ENTITLEMENTS[sdk=iphone*]" = SocialIO.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
@@ -408,6 +708,8 @@
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.1.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -432,7 +734,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -454,7 +756,7 @@
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -464,6 +766,154 @@
};
name = Release;
};
+ A80000000000000000000007 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "social.io";
+ INFOPLIST_KEY_CFBundleName = "social.io";
+ INFOPLIST_KEY_WKApplication = YES;
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 11.0;
+ };
+ name = Debug;
+ };
+ A80000000000000000000008 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_CFBundleDisplayName = "social.io";
+ INFOPLIST_KEY_CFBundleName = "social.io";
+ INFOPLIST_KEY_WKApplication = YES;
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 11.0;
+ };
+ name = Release;
+ };
+ A80000000000000000000009 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = WatchApp/Widgets/SocialIOWatchWidgets-Info.plist;
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch.widgets;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 11.0;
+ };
+ name = Debug;
+ };
+ A8000000000000000000000A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = WatchApp/Widgets/SocialIOWatchWidgets-Info.plist;
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.social.app.watch.widgets;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 11.0;
+ };
+ name = Release;
+ };
+ A8000000000000000000000B /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Sources/App/SocialIOWidgets-Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.social.app.widgets;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBSERVATION_ENABLED = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A8000000000000000000000C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGNING_REQUIRED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Sources/App/SocialIOWidgets-Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.social.app.widgets;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBSERVATION_ENABLED = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) WIDGET_EXTENSION";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -494,6 +944,33 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ A70000000000000000000004 /* Build configuration list for PBXNativeTarget "SocialIOWatch" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A80000000000000000000007 /* Debug */,
+ A80000000000000000000008 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A70000000000000000000005 /* Build configuration list for PBXNativeTarget "SocialIOWatchWidgets" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A80000000000000000000009 /* Debug */,
+ A8000000000000000000000A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A70000000000000000000006 /* Build configuration list for PBXNativeTarget "SocialIOWidgets" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A8000000000000000000000B /* Debug */,
+ A8000000000000000000000C /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = A60000000000000000000001 /* Project object */;
diff --git a/swift/SocialIO.xcodeproj/xcshareddata/xcschemes/SocialIO.xcscheme b/swift/SocialIO.xcodeproj/xcshareddata/xcschemes/SocialIO.xcscheme
index 9599022..32cd0af 100644
--- a/swift/SocialIO.xcodeproj/xcshareddata/xcschemes/SocialIO.xcscheme
+++ b/swift/SocialIO.xcodeproj/xcshareddata/xcschemes/SocialIO.xcscheme
@@ -20,6 +20,48 @@
ReferencedContainer = "container:SocialIO.xcodeproj">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/swift/Sources/App/AppViewModel.swift b/swift/Sources/App/AppViewModel.swift
index 0fb8e3c..3fd70e9 100644
--- a/swift/Sources/App/AppViewModel.swift
+++ b/swift/Sources/App/AppViewModel.swift
@@ -9,19 +9,24 @@ final class AppViewModel {
var focusedMessageRouteID: String?
var searchText = ""
var showUnreadOnly = false
+ var laneFilter: Lane?
var isComposing = false
- var composeDraft = ComposeDraft()
+ var isCommandPalettePresented = false
+ var composeDraft = ComposeDraft(from: "phil@social.io")
var threads: [MailThread] = []
var isLoading = false
var isSending = false
var errorMessage: String?
var mailboxNavigationToken = UUID()
var threadNavigationToken = UUID()
+ let currentUser = MailPerson(name: "Phil Kunz", email: "phil@social.io")
private let service: MailServicing
private let controlService: AppControlServicing
private var pendingNavigationCommand: AppNavigationCommand?
private var isListeningForBackendCommands = false
+ @ObservationIgnored private var composeAutosaveTask: Task?
+ @ObservationIgnored private let autosaveKey = "sio.compose.autosave"
init(
service: MailServicing = MockMailService(),
@@ -29,6 +34,7 @@ final class AppViewModel {
) {
self.service = service
self.controlService = controlService
+ restoreAutosavedDraft()
if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) {
apply(command: command)
}
@@ -40,9 +46,9 @@ final class AppViewModel {
}
var filteredThreads: [MailThread] {
- threads
+ baseThreads(for: selectedMailbox)
.filter { thread in
- selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
+ laneFilter == nil || thread.lane == laneFilter
}
.filter { thread in
!showUnreadOnly || thread.isUnread
@@ -56,18 +62,92 @@ final class AppViewModel {
}
func threadCount(in mailbox: Mailbox) -> Int {
- threads.filter { thread in
- mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox
- }
- .count
+ baseThreads(for: 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
+ baseThreads(for: mailbox)
+ .filter { $0.isUnread }
+ .count
+ }
+
+ func laneCount(_ lane: Lane?, in mailbox: Mailbox? = nil) -> Int {
+ baseThreads(for: mailbox ?? selectedMailbox)
+ .filter { lane == nil || $0.lane == lane }
+ .count
+ }
+
+ func unreadCount(for lane: Lane?) -> Int {
+ baseThreads(for: selectedMailbox)
+ .filter { thread in
+ thread.isUnread && (lane == nil || thread.lane == lane)
+ }
+ .count
+ }
+
+ var screenerThreads: [MailThread] {
+ baseThreads(for: .screener)
+ .sorted { $0.lastUpdated > $1.lastUpdated }
+ }
+
+ var searchResults: [MailThread] {
+ threads
+ .filter(matchesSearch)
+ .sorted { lhs, rhs in
+ let lhsScore = score(for: lhs)
+ let rhsScore = score(for: rhs)
+ if lhsScore == rhsScore {
+ return lhs.lastUpdated > rhs.lastUpdated
+ }
+
+ return lhsScore > rhsScore
+ }
+ }
+
+ var folderNames: [String] {
+ Array(Set(threads.flatMap(\.tags)))
+ .filter { !$0.isEmpty && !["Draft", "Launch", "Search", "System", "External", "Sent"].contains($0) }
+ .sorted()
+ }
+
+ var topSearchResult: MailThread? {
+ searchResults.first
+ }
+
+ var remainingSearchResults: [MailThread] {
+ Array(searchResults.dropFirst())
+ }
+
+ var isSearchActive: Bool {
+ !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ func setLaneFilter(_ lane: Lane?) {
+ laneFilter = lane
+ reconcileSelectionForCurrentFilters()
+ }
+
+ func cycleToNextLane() {
+ let allFilters: [Lane?] = [nil] + Lane.allCases
+ guard let currentIndex = allFilters.firstIndex(where: { $0 == laneFilter }) else {
+ laneFilter = nil
+ return
+ }
+
+ laneFilter = allFilters[(currentIndex + 1) % allFilters.count]
+ reconcileSelectionForCurrentFilters()
+ }
+
+ func queueDraftAutosave() {
+ let snapshot = composeDraft
+ composeAutosaveTask?.cancel()
+ composeAutosaveTask = Task { [autosaveKey] in
+ try? await Task.sleep(for: .milliseconds(800))
+ guard !Task.isCancelled else { return }
+ if let data = try? JSONEncoder().encode(snapshot) {
+ UserDefaults.standard.set(data, forKey: autosaveKey)
+ }
}
- .count
}
func load() async {
@@ -85,6 +165,8 @@ final class AppViewModel {
} else {
reconcileSelectionForCurrentFilters()
}
+
+ await refreshLiveActivity()
} catch {
errorMessage = "Unable to load mail."
}
@@ -132,11 +214,120 @@ final class AppViewModel {
}
func startCompose() {
- composeDraft = ComposeDraft()
+ if composeDraft.isEmpty {
+ restoreAutosavedDraft()
+ }
+
+ if composeDraft.isEmpty {
+ composeDraft = ComposeDraft(from: currentUser.email)
+ } else if composeDraft.from.isEmpty {
+ composeDraft.from = currentUser.email
+ }
+
focusedMessageRouteID = nil
isComposing = true
}
+ func dismissCompose() {
+ isComposing = false
+ }
+
+ func discardCompose() {
+ composeAutosaveTask?.cancel()
+ UserDefaults.standard.removeObject(forKey: autosaveKey)
+ composeDraft = ComposeDraft(from: currentUser.email)
+ isComposing = false
+ }
+
+ func startReply(to threadID: MailThread.ID, replyAll: Bool = false, forward: Bool = false) {
+ guard let thread = thread(withID: threadID) else { return }
+
+ let latestMessage = thread.latestMessage
+ let recipients: [MailPerson]
+ if forward {
+ recipients = []
+ } else if replyAll {
+ recipients = thread.participants.filter { $0.email != currentUser.email }
+ } else if let latestMessage {
+ recipients = [latestMessage.sender]
+ } else {
+ recipients = thread.participants.filter { $0.email != currentUser.email }
+ }
+
+ let replyPrefix = forward ? "Fwd:" : "Re:"
+ let quotedBody = latestMessage?.body
+ .split(separator: "\n", omittingEmptySubsequences: false)
+ .prefix(4)
+ .map { "> \($0)" }
+ .joined(separator: "\n") ?? ""
+
+ composeDraft = ComposeDraft(
+ to: recipients.map(\.email).joined(separator: ", "),
+ cc: "",
+ from: currentUser.email,
+ subject: thread.subject.hasPrefix(replyPrefix) ? thread.subject : "\(replyPrefix) \(thread.subject)",
+ body: forward || quotedBody.isEmpty ? "" : "\n\n\(quotedBody)"
+ )
+ focusedMessageRouteID = nil
+ isComposing = true
+ }
+
+ func moveThread(withID threadID: MailThread.ID, to mailbox: Mailbox) {
+ guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
+ threads[index].mailbox = mailbox
+ if mailbox == .trash {
+ threads[index].isUnread = false
+ }
+ reconcileSelectionForCurrentFilters()
+ }
+
+ func sendInlineReply(_ body: String, in threadID: MailThread.ID) {
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedBody.isEmpty,
+ let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
+
+ let recipients = threads[index].participants.filter { $0.email != currentUser.email }
+ threads[index].messages.append(
+ MailMessage(
+ routeID: "\(threads[index].routeID)-reply-\(threads[index].messages.count + 1)",
+ sender: currentUser,
+ recipients: recipients,
+ sentAt: .now,
+ body: trimmedBody
+ )
+ )
+ threads[index].isUnread = false
+ openThread(withID: threadID)
+ }
+
+ func snoozeThread(withID threadID: MailThread.ID) {
+ moveThread(withID: threadID, to: .snoozed)
+ }
+
+ func applyScreenerDecision(_ decision: ScreenDecision, to threadID: MailThread.ID) {
+ guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return }
+
+ switch decision {
+ case .approve:
+ threads[index].mailbox = .inbox
+ threads[index].isScreeningCandidate = false
+ case .block:
+ threads[index].mailbox = .trash
+ threads[index].isUnread = false
+ threads[index].isScreeningCandidate = false
+ case .sendToPaper:
+ threads[index].mailbox = .inbox
+ threads[index].lane = .paper
+ threads[index].isScreeningCandidate = false
+ }
+
+ reconcileSelectionForCurrentFilters()
+
+ Task {
+ try? await service.screen(threadID: threadID, as: decision)
+ }
+ }
+
func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) {
guard let thread = thread(withID: threadID) else { return }
@@ -203,7 +394,7 @@ final class AppViewModel {
case let .compose(draft):
focusedMessageRouteID = nil
- composeDraft = draft
+ composeDraft = normalizedDraft(draft)
isComposing = true
}
}
@@ -221,6 +412,8 @@ final class AppViewModel {
selectedMailbox = .sent
openThread(withID: sentThread.id)
isComposing = false
+ UserDefaults.standard.removeObject(forKey: autosaveKey)
+ composeDraft = ComposeDraft(from: currentUser.email)
return true
} catch {
errorMessage = "Unable to send message."
@@ -236,7 +429,11 @@ final class AppViewModel {
thread.subject,
thread.previewText,
thread.participants.map(\.name).joined(separator: " "),
- thread.tags.joined(separator: " ")
+ thread.tags.joined(separator: " "),
+ thread.lane.label,
+ thread.summary?.joined(separator: " ") ?? "",
+ thread.messages.map(\.body).joined(separator: " "),
+ thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ")
]
.joined(separator: " ")
.localizedLowercase
@@ -253,6 +450,57 @@ final class AppViewModel {
focusedMessageRouteID = nil
}
+ private func baseThreads(for mailbox: Mailbox) -> [MailThread] {
+ switch mailbox {
+ case .starred:
+ return threads.filter(\.isStarred)
+ case .screener:
+ return threads.filter(\.isScreeningCandidate)
+ default:
+ return threads.filter { $0.mailbox == mailbox }
+ }
+ }
+
+ private func score(for thread: MailThread) -> Int {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).localizedLowercase
+ guard !query.isEmpty else { return 0 }
+
+ var score = 0
+ if thread.subject.localizedLowercase.contains(query) { score += 4 }
+ if thread.participants.map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 3 }
+ if thread.previewText.localizedLowercase.contains(query) { score += 2 }
+ if thread.messages.flatMap(\.attachments).map(\.name).joined(separator: " ").localizedLowercase.contains(query) { score += 1 }
+ return score
+ }
+
+ private func normalizedDraft(_ draft: ComposeDraft) -> ComposeDraft {
+ var normalized = draft
+ if normalized.from.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ normalized.from = currentUser.email
+ }
+ return normalized
+ }
+
+ private func restoreAutosavedDraft() {
+ guard let data = UserDefaults.standard.data(forKey: autosaveKey),
+ let draft = try? JSONDecoder().decode(ComposeDraft.self, from: data) else {
+ if composeDraft.from.isEmpty {
+ composeDraft = ComposeDraft(from: currentUser.email)
+ }
+ return
+ }
+
+ composeDraft = normalizedDraft(draft)
+ }
+
+ private func refreshLiveActivity() async {
+ #if os(iOS)
+ if let thread = threads.first(where: { $0.mailbox == .inbox && $0.isUnread }) {
+ await MailNotificationActivityController.startIfNeeded(with: thread)
+ }
+ #endif
+ }
+
private func reconcileSelectionForCurrentFilters() {
if let selectedThreadID,
filteredThreads.contains(where: { $0.id == selectedThreadID }) {
diff --git a/swift/Sources/App/MailNotificationActivity.swift b/swift/Sources/App/MailNotificationActivity.swift
new file mode 100644
index 0000000..9a6f386
--- /dev/null
+++ b/swift/Sources/App/MailNotificationActivity.swift
@@ -0,0 +1,168 @@
+#if os(iOS) && canImport(ActivityKit) && canImport(AppIntents) && canImport(WidgetKit)
+import ActivityKit
+import AppIntents
+import SwiftUI
+import WidgetKit
+
+struct MailNotificationActivityAttributes: ActivityAttributes {
+ struct ContentState: Codable, Hashable {
+ var sender: String
+ var initials: String
+ var subject: String
+ var preview: String
+ var route: String
+ }
+
+ var threadRouteID: String
+}
+
+struct OpenMailLiveActivityIntent: LiveActivityIntent {
+ static var title: LocalizedStringResource = "Open"
+ static var openAppWhenRun = true
+
+ @Parameter(title: "Route")
+ var route: String
+
+ init() {
+ route = "socialio://mailbox/inbox"
+ }
+
+ init(route: String) {
+ self.route = route
+ }
+
+ func perform() async throws -> some IntentResult {
+ .result()
+ }
+}
+
+struct SnoozeMailLiveActivityIntent: LiveActivityIntent {
+ static var title: LocalizedStringResource = "Snooze"
+
+ func perform() async throws -> some IntentResult {
+ .result()
+ }
+}
+
+struct MailNotificationLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: MailNotificationActivityAttributes.self) { context in
+ VStack(alignment: .leading, spacing: 10) {
+ header(context: context)
+
+ Text(context.state.preview)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 10) {
+ Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
+ Text("Open")
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button(intent: SnoozeMailLiveActivityIntent()) {
+ Text("Snooze")
+ }
+ .buttonStyle(.bordered)
+ }
+ }
+ .padding(.vertical, 6)
+ } dynamicIsland: { context in
+ DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Image(systemName: "envelope.fill")
+ .foregroundStyle(SIO.tint)
+ }
+
+ DynamicIslandExpandedRegion(.trailing) {
+ Text(context.state.initials)
+ .font(.headline.weight(.semibold))
+ }
+
+ DynamicIslandExpandedRegion(.center) {
+ VStack(alignment: .leading, spacing: 4) {
+ header(context: context)
+ Text(context.state.preview)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+
+ DynamicIslandExpandedRegion(.bottom) {
+ HStack(spacing: 10) {
+ Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
+ Text("Open")
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button(intent: SnoozeMailLiveActivityIntent()) {
+ Text("Snooze")
+ }
+ .buttonStyle(.bordered)
+ }
+ }
+ } compactLeading: {
+ Image(systemName: "envelope.fill")
+ } compactTrailing: {
+ Text(context.state.initials)
+ .font(.caption2.weight(.bold))
+ } minimal: {
+ Image(systemName: "envelope.fill")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func header(context: ActivityViewContext) -> some View {
+ HStack(spacing: 10) {
+ Text(context.state.initials)
+ .font(.caption.weight(.bold))
+ .foregroundStyle(SIO.tint)
+ .frame(width: 28, height: 28)
+ .background(SIO.tint.opacity(0.12), in: Circle())
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(context.state.sender)
+ .font(.subheadline.weight(.semibold))
+ Text(context.state.subject)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+}
+
+#if !WIDGET_EXTENSION
+enum MailNotificationActivityController {
+ static func startIfNeeded(with thread: MailThread) async {
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
+
+ let attributes = MailNotificationActivityAttributes(threadRouteID: thread.routeID)
+ let state = MailNotificationActivityAttributes.ContentState(
+ sender: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "social.io",
+ initials: initials(from: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "SI"),
+ subject: thread.subject,
+ preview: thread.previewText,
+ route: "socialio://open?thread=\(thread.routeID)"
+ )
+
+ if let existing = Activity.activities.first {
+ await existing.update(ActivityContent(state: state, staleDate: nil))
+ return
+ }
+
+ _ = try? Activity.request(
+ attributes: attributes,
+ content: ActivityContent(state: state, staleDate: nil)
+ )
+ }
+
+ private static func initials(from name: String) -> String {
+ String(name.split(separator: " ").prefix(2).compactMap { $0.first }).uppercased()
+ }
+}
+#endif
+#endif
diff --git a/swift/Sources/App/MailNotificationWidgetExtension.swift b/swift/Sources/App/MailNotificationWidgetExtension.swift
new file mode 100644
index 0000000..a53db58
--- /dev/null
+++ b/swift/Sources/App/MailNotificationWidgetExtension.swift
@@ -0,0 +1,13 @@
+#if os(iOS) && canImport(WidgetKit) && canImport(ActivityKit) && canImport(AppIntents)
+import ActivityKit
+import AppIntents
+import SwiftUI
+import WidgetKit
+
+@main
+struct SocialIOWidgetsExtension: WidgetBundle {
+ var body: some Widget {
+ MailNotificationLiveActivity()
+ }
+}
+#endif
diff --git a/swift/Sources/App/SocialIOApp.swift b/swift/Sources/App/SocialIOApp.swift
index eac9789..0f7a664 100644
--- a/swift/Sources/App/SocialIOApp.swift
+++ b/swift/Sources/App/SocialIOApp.swift
@@ -3,17 +3,36 @@ import SwiftUI
@main
struct SocialIOApp: App {
@State private var model = AppViewModel()
+ @AppStorage("sio.theme") private var themeRawValue = ThemePreference.system.rawValue
var body: some Scene {
- WindowGroup {
- MailRootView(model: model)
- .tint(MailTheme.accent)
- .onOpenURL { url in
- model.apply(url: url)
- }
- }
#if os(macOS)
+ WindowGroup {
+ rootView
+ }
.defaultSize(width: 1440, height: 900)
+
+ Settings {
+ AppearanceSettingsView()
+ .frame(width: 420, height: 300)
+ }
+ #else
+ WindowGroup {
+ rootView
+ }
#endif
}
+
+ private var themePreference: ThemePreference {
+ ThemePreference(rawValue: themeRawValue) ?? .system
+ }
+
+ private var rootView: some View {
+ MailRootView(model: model)
+ .tint(SIO.tint)
+ .preferredColorScheme(themePreference.colorScheme)
+ .onOpenURL { url in
+ model.apply(url: url)
+ }
+ }
}
diff --git a/swift/Sources/App/SocialIOWidgets-Info.plist b/swift/Sources/App/SocialIOWidgets-Info.plist
new file mode 100644
index 0000000..eeee481
--- /dev/null
+++ b/swift/Sources/App/SocialIOWidgets-Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ social.io Widgets
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ MinimumOSVersion
+ $(IPHONEOS_DEPLOYMENT_TARGET)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/swift/Sources/Core/Design/AISummaryCard.swift b/swift/Sources/Core/Design/AISummaryCard.swift
new file mode 100644
index 0000000..c778c80
--- /dev/null
+++ b/swift/Sources/Core/Design/AISummaryCard.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+struct AISummaryCard: View {
+ let count: Int
+ let bullets: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 7) {
+ Image(systemName: "sparkles")
+ Text("SUMMARY")
+ Text(".")
+ Text("\(count) \(messageLabel.uppercased())")
+ }
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(SIO.tint)
+
+ ForEach(Array(bullets.enumerated()), id: \.offset) { _, bullet in
+ HStack(alignment: .top, spacing: 8) {
+ Circle()
+ .fill(SIO.tint)
+ .frame(width: 6, height: 6)
+ .padding(.top, 6)
+ Text(bullet)
+ .font(.subheadline)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ .padding(16)
+ .sioCardBackground(tint: SIO.tint)
+ }
+
+ private var messageLabel: String {
+ count == 1 ? "message" : "messages"
+ }
+}
diff --git a/swift/Sources/Core/Design/AvatarView.swift b/swift/Sources/Core/Design/AvatarView.swift
new file mode 100644
index 0000000..1366186
--- /dev/null
+++ b/swift/Sources/Core/Design/AvatarView.swift
@@ -0,0 +1,21 @@
+import SwiftUI
+
+struct AvatarView: View {
+ let name: String
+ var color: Color = SIO.tint
+ var size: CGFloat = 30
+
+ var body: some View {
+ Text(initials)
+ .font(.system(size: size * 0.42, weight: .semibold, design: .rounded))
+ .foregroundStyle(.white)
+ .frame(width: size, height: size)
+ .background(color, in: Circle())
+ .overlay(Circle().strokeBorder(.white.opacity(0.16), lineWidth: 1))
+ }
+
+ private var initials: String {
+ let parts = name.split(separator: " ")
+ return String(parts.prefix(2).compactMap { $0.first }).uppercased()
+ }
+}
diff --git a/swift/Sources/Core/Design/ButtonStyles.swift b/swift/Sources/Core/Design/ButtonStyles.swift
new file mode 100644
index 0000000..e420085
--- /dev/null
+++ b/swift/Sources/Core/Design/ButtonStyles.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+
+public struct PrimaryActionStyle: ButtonStyle {
+ public init() {}
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 11)
+ .frame(minHeight: 44)
+ .background(
+ RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous)
+ .fill(SIO.tint.opacity(configuration.isPressed ? 0.82 : 1))
+ )
+ .opacity(configuration.isPressed ? 0.94 : 1)
+ }
+}
+
+public struct SecondaryActionStyle: ButtonStyle {
+ public init() {}
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.primary)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 11)
+ .frame(minHeight: 44)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ )
+ .opacity(configuration.isPressed ? 0.9 : 1)
+ }
+}
+
+public struct DestructiveStyle: ButtonStyle {
+ public init() {}
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.red)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 11)
+ .frame(minHeight: 44)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous)
+ .strokeBorder(Color.red.opacity(configuration.isPressed ? 0.35 : 0.18), lineWidth: 1)
+ )
+ .opacity(configuration.isPressed ? 0.9 : 1)
+ }
+}
diff --git a/swift/Sources/Core/Design/GlassChrome.swift b/swift/Sources/Core/Design/GlassChrome.swift
new file mode 100644
index 0000000..746eddf
--- /dev/null
+++ b/swift/Sources/Core/Design/GlassChrome.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+public extension View {
+ @ViewBuilder
+ func sioGlassChrome() -> some View {
+ if #available(iOS 26.0, macOS 26.0, *) {
+ self.glassEffect(.regular)
+ } else {
+ self.background(.regularMaterial)
+ }
+ }
+
+ @ViewBuilder
+ func sioGlassSurface(in shape: S, tint: Color? = nil) -> some View {
+ if #available(iOS 26.0, macOS 26.0, *) {
+ self.glassEffect(Glass.regular.tint(tint), in: shape)
+ } else {
+ self.background(.regularMaterial, in: shape)
+ .overlay(shape.stroke(Color.primary.opacity(0.08), lineWidth: 1))
+ .overlay(shape.fill((tint ?? .clear).opacity(0.08)))
+ }
+ }
+}
diff --git a/swift/Sources/Core/Design/Haptics.swift b/swift/Sources/Core/Design/Haptics.swift
new file mode 100644
index 0000000..d1ccec0
--- /dev/null
+++ b/swift/Sources/Core/Design/Haptics.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+#if os(iOS)
+import UIKit
+#endif
+
+enum Haptics {
+ static func selection() {
+ #if os(iOS)
+ UISelectionFeedbackGenerator().selectionChanged()
+ #endif
+ }
+
+ static func success() {
+ #if os(iOS)
+ UINotificationFeedbackGenerator().notificationOccurred(.success)
+ #endif
+ }
+
+ static func warning() {
+ #if os(iOS)
+ UINotificationFeedbackGenerator().notificationOccurred(.warning)
+ #endif
+ }
+}
diff --git a/swift/Sources/Core/Design/KeyboardHint.swift b/swift/Sources/Core/Design/KeyboardHint.swift
new file mode 100644
index 0000000..3568a03
--- /dev/null
+++ b/swift/Sources/Core/Design/KeyboardHint.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+struct KeyboardHint: View {
+ let title: String
+
+ var body: some View {
+ Text(title)
+ .font(.caption2.weight(.semibold))
+ .monospaced()
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 4)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ )
+ }
+}
diff --git a/swift/Sources/Core/Design/LaneChip.swift b/swift/Sources/Core/Design/LaneChip.swift
new file mode 100644
index 0000000..f2548f9
--- /dev/null
+++ b/swift/Sources/Core/Design/LaneChip.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+struct LaneChip: View {
+ let lane: Lane
+ var count: Int?
+
+ var body: some View {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(lane.color)
+ .frame(width: 7, height: 7)
+ Text(lane.label.uppercased())
+ if let count {
+ Text(count, format: .number)
+ .foregroundStyle(lane.color.opacity(0.8))
+ }
+ }
+ .font(.caption2.weight(.semibold))
+ .padding(.horizontal, 9)
+ .padding(.vertical, 5)
+ .background(lane.color.opacity(0.14), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
+ .foregroundStyle(lane.color)
+ }
+}
diff --git a/swift/Sources/Core/Design/SIOTokens.swift b/swift/Sources/Core/Design/SIOTokens.swift
new file mode 100644
index 0000000..06b7573
--- /dev/null
+++ b/swift/Sources/Core/Design/SIOTokens.swift
@@ -0,0 +1,136 @@
+import SwiftUI
+
+public enum SIO {
+ public static let tint = Color("SIOTint")
+ public static let laneFeed = Color("LaneFeed")
+ public static let lanePaper = Color("LanePaper")
+ public static let lanePeople = Color("LanePeople")
+ public static let cardRadius: CGFloat = 14
+ public static let controlRadius: CGFloat = 10
+ public static let chipRadius: CGFloat = 6
+}
+
+public enum Lane: String, CaseIterable, Codable, Identifiable {
+ case feed
+ case paper
+ case people
+
+ public var id: String { rawValue }
+
+ public var label: String {
+ switch self {
+ case .feed: "Feed"
+ case .paper: "Paper"
+ case .people: "People"
+ }
+ }
+
+ public var color: Color {
+ switch self {
+ case .feed: SIO.laneFeed
+ case .paper: SIO.lanePaper
+ case .people: SIO.lanePeople
+ }
+ }
+}
+
+public enum ThreadRowDensity: String, CaseIterable, Identifiable {
+ case compact
+ case cozy
+ case comfortable
+
+ public var id: String { rawValue }
+
+ public var avatarSize: CGFloat {
+ switch self {
+ case .compact: 24
+ case .cozy: 30
+ case .comfortable: 30
+ }
+ }
+
+ public var previewLineLimit: Int {
+ switch self {
+ case .compact, .cozy: 1
+ case .comfortable: 2
+ }
+ }
+
+ public var rowPadding: CGFloat {
+ switch self {
+ case .compact: 10
+ case .cozy: 12
+ case .comfortable: 14
+ }
+ }
+
+ public var showsMetaChips: Bool {
+ self == .comfortable
+ }
+}
+
+public enum ThemePreference: String, CaseIterable, Identifiable {
+ case system
+ case light
+ case dark
+
+ public var id: String { rawValue }
+
+ public var label: String {
+ switch self {
+ case .system: "System"
+ case .light: "Light"
+ case .dark: "Dark"
+ }
+ }
+
+ public var colorScheme: ColorScheme? {
+ switch self {
+ case .system: nil
+ case .light: .light
+ case .dark: .dark
+ }
+ }
+}
+
+public enum ReadingPanePreference: String, CaseIterable, Identifiable {
+ case right
+ case bottom
+ case off
+
+ public var id: String { rawValue }
+
+ public var label: String {
+ switch self {
+ case .right: "Right"
+ case .bottom: "Bottom"
+ case .off: "Off"
+ }
+ }
+}
+
+public extension View {
+ func sioCardBackground(tint: Color? = nil, cornerRadius: CGFloat = SIO.cardRadius) -> some View {
+ background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .fill((tint ?? Color.clear).opacity(0.08))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+ )
+ }
+
+ func sioSoftSelection(_ isSelected: Bool) -> some View {
+ background(
+ RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
+ .fill(isSelected ? SIO.tint.opacity(0.12) : Color.clear)
+ )
+ }
+
+ func sioProse() -> some View {
+ font(.system(size: 15.5))
+ .lineSpacing(6)
+ }
+}
diff --git a/swift/Sources/Core/Models/MailModels.swift b/swift/Sources/Core/Models/MailModels.swift
index 8d6f821..9b49842 100644
--- a/swift/Sources/Core/Models/MailModels.swift
+++ b/swift/Sources/Core/Models/MailModels.swift
@@ -3,9 +3,12 @@ import Foundation
enum Mailbox: String, CaseIterable, Identifiable, Codable {
case inbox
case starred
+ case snoozed
+ case screener
case sent
case drafts
case archive
+ case trash
var id: String { rawValue }
@@ -13,9 +16,12 @@ enum Mailbox: String, CaseIterable, Identifiable, Codable {
switch self {
case .inbox: "Inbox"
case .starred: "Starred"
+ case .snoozed: "Snoozed"
+ case .screener: "The Screener"
case .sent: "Sent"
case .drafts: "Drafts"
case .archive: "Archive"
+ case .trash: "Trash"
}
}
@@ -23,9 +29,12 @@ enum Mailbox: String, CaseIterable, Identifiable, Codable {
switch self {
case .inbox: "tray.full"
case .starred: "star"
+ case .snoozed: "clock.badge"
+ case .screener: "line.3.horizontal.decrease.circle"
case .sent: "paperplane"
case .drafts: "doc.text"
case .archive: "archivebox"
+ case .trash: "trash"
}
}
}
@@ -50,6 +59,7 @@ struct MailMessage: Identifiable, Hashable, Codable {
let sentAt: Date
let body: String
let isDraft: Bool
+ let attachments: [MailAttachment]
init(
id: UUID = UUID(),
@@ -58,7 +68,8 @@ struct MailMessage: Identifiable, Hashable, Codable {
recipients: [MailPerson],
sentAt: Date,
body: String,
- isDraft: Bool = false
+ isDraft: Bool = false,
+ attachments: [MailAttachment] = []
) {
self.id = id
self.routeID = routeID
@@ -67,6 +78,19 @@ struct MailMessage: Identifiable, Hashable, Codable {
self.sentAt = sentAt
self.body = body
self.isDraft = isDraft
+ self.attachments = attachments
+ }
+}
+
+struct MailAttachment: Identifiable, Hashable, Codable {
+ let id: UUID
+ let name: String
+ let size: String
+
+ init(id: UUID = UUID(), name: String, size: String) {
+ self.id = id
+ self.name = name
+ self.size = size
}
}
@@ -80,6 +104,9 @@ struct MailThread: Identifiable, Hashable, Codable {
var isUnread: Bool
var isStarred: Bool
var tags: [String]
+ var lane: Lane
+ var summary: [String]?
+ var isScreeningCandidate: Bool
init(
id: UUID = UUID(),
@@ -90,7 +117,10 @@ struct MailThread: Identifiable, Hashable, Codable {
messages: [MailMessage],
isUnread: Bool,
isStarred: Bool,
- tags: [String] = []
+ tags: [String] = [],
+ lane: Lane = .people,
+ summary: [String]? = nil,
+ isScreeningCandidate: Bool = false
) {
self.id = id
self.routeID = routeID
@@ -101,6 +131,9 @@ struct MailThread: Identifiable, Hashable, Codable {
self.isUnread = isUnread
self.isStarred = isStarred
self.tags = tags
+ self.lane = lane
+ self.summary = summary
+ self.isScreeningCandidate = isScreeningCandidate
}
var latestMessage: MailMessage? {
@@ -114,10 +147,27 @@ struct MailThread: Identifiable, Hashable, Codable {
var lastUpdated: Date {
latestMessage?.sentAt ?? .distantPast
}
+
+ var messageCount: Int {
+ messages.count
+ }
+
+ var hasAttachments: Bool {
+ messages.contains { !$0.attachments.isEmpty }
+ }
}
-struct ComposeDraft: Equatable {
+struct ComposeDraft: Equatable, Codable {
var to = ""
+ var cc = ""
+ var from = ""
var subject = ""
var body = ""
+
+ var isEmpty: Bool {
+ to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
}
diff --git a/swift/Sources/Core/Services/MockMailService.swift b/swift/Sources/Core/Services/MockMailService.swift
index 8219786..6204b13 100644
--- a/swift/Sources/Core/Services/MockMailService.swift
+++ b/swift/Sources/Core/Services/MockMailService.swift
@@ -3,6 +3,19 @@ import Foundation
protocol MailServicing {
func loadThreads() async throws -> [MailThread]
func send(draft: ComposeDraft) async throws -> MailThread
+ func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws
+}
+
+extension MailServicing {
+ func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws {}
+}
+
+enum ScreenDecision: String, CaseIterable, Identifiable {
+ case approve
+ case block
+ case sendToPaper
+
+ var id: String { rawValue }
}
struct MockMailService: MailServicing {
@@ -47,140 +60,311 @@ struct MockMailService: MailServicing {
messages: [message],
isUnread: false,
isStarred: false,
- tags: ["Sent"]
+ tags: ["Sent"],
+ lane: .people
)
}
+ func screen(threadID: MailThread.ID, as decision: ScreenDecision) async throws {
+ try await Task.sleep(for: .milliseconds(40))
+ }
+
+ func previewThreads() -> [MailThread] {
+ seededThreads.sorted { $0.lastUpdated > $1.lastUpdated }
+ }
+
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")
+ let aiko = MailPerson(name: "Aiko Tanaka", email: "aiko@social.io")
+ let appleDeveloper = MailPerson(name: "Apple Developer", email: "developer@apple.com")
+ let figma = MailPerson(name: "Figma", email: "no-reply@figma.com")
+ let github = MailPerson(name: "GitHub", email: "noreply@github.com")
+ let lena = MailPerson(name: "Lena Park", email: "lena@orbitlabs.io")
+ let linear = MailPerson(name: "Linear", email: "updates@linear.app")
+ let marcos = MailPerson(name: "Marcos Vidal", email: "marcos@northbeam.vc")
+ let mom = MailPerson(name: "Mom", email: "mom@example.com")
+ let priya = MailPerson(name: "Priya Shah", email: "priya@social.io")
+ let socialTeam = MailPerson(name: "social.io team", email: "team@social.io")
+ let stripe = MailPerson(name: "Stripe", email: "receipts@stripe.com")
+
+ func hoursAgo(_ hours: Double) -> Date {
+ .now.addingTimeInterval(-(hours * 60 * 60))
+ }
return [
MailThread(
routeID: "launch-copy",
mailbox: .inbox,
- subject: "Launch copy for the onboarding flow",
- participants: [tanya, me],
+ subject: "Launch copy review - second pass",
+ participants: [lena, aiko, 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."
+ sender: lena,
+ recipients: [me, aiko],
+ sentAt: hoursAgo(25),
+ body: "Hi Phil - pulled together the changes from yesterday. Hero is the big one; the rest are mostly trims.\n\nThe three lanes framing landed well in the user tests, so I leaned into it harder.\n\nTwo open questions:\n1. Should we keep the screener mention up top, or move it down so we lead with split inbox?\n2. The closing CTA still feels long. I attached two trims.\n\nWould love a 15-minute sync before Tuesday's ship.",
+ attachments: [
+ MailAttachment(name: "cta-trims.pdf", size: "184 KB")
+ ]
),
MailMessage(
routeID: "launch-copy-2",
+ sender: aiko,
+ recipients: [lena, me],
+ sentAt: hoursAgo(17),
+ body: "Hero is great. Lead with split inbox first and keep the screener second. The shorter CTA also feels better."
+ ),
+ MailMessage(
+ routeID: "launch-copy-3",
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."
+ recipients: [lena, aiko],
+ sentAt: hoursAgo(12),
+ body: "Agree on both points. Let's keep the copy calmer and make split inbox the first thing people understand."
+ ),
+ MailMessage(
+ routeID: "launch-copy-4",
+ sender: lena,
+ recipients: [me, aiko],
+ sentAt: hoursAgo(5),
+ body: "Perfect. I made both edits and dropped in the latest screenshots as well. Monday 2pm works for me if that still does for you."
)
],
isUnread: true,
isStarred: true,
- tags: ["Design", "Launch"]
+ tags: ["Orbit Labs"],
+ lane: .people,
+ summary: [
+ "Lena rewrote the hero and the three lanes section after Tuesday's feedback.",
+ "Two open questions remain: where to introduce the screener and which shorter CTA trim to ship.",
+ "A short sync before Tuesday is the last blocker."
+ ]
),
MailThread(
routeID: "daily-sync-status",
mailbox: .inbox,
- subject: "Daily inbox sync status",
- participants: [ops, me],
+ subject: "Daily sync - 14 issues moved to Done",
+ participants: [linear, me],
messages: [
MailMessage(
routeID: "daily-sync-status-1",
- sender: ops,
+ sender: linear,
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."
+ sentAt: hoursAgo(8),
+ body: "Mobile: iOS keyboard regression fixed. Inbox: reconnect backoff landed. Compose autosave race condition is queued for review."
)
],
isUnread: true,
isStarred: false,
- tags: ["External"]
+ tags: ["System"],
+ lane: .feed
+ ),
+ MailThread(
+ routeID: "investor-update",
+ mailbox: .inbox,
+ subject: "Re: Q1 investor update - looks great, two small notes",
+ participants: [marcos, me],
+ messages: [
+ MailMessage(
+ routeID: "investor-update-1",
+ sender: marcos,
+ recipients: [me],
+ sentAt: hoursAgo(34),
+ body: "Strong narrative overall. Two things: the ARR chart on slide 8 needs a zero baseline, and the differentiation story should stay concrete. The product story is strongest when you anchor it in the split inbox and command-first workflows.",
+ attachments: [
+ MailAttachment(name: "Q1-investor-update.pdf", size: "1.8 MB")
+ ]
+ ),
+ MailMessage(
+ routeID: "investor-update-2",
+ sender: me,
+ recipients: [marcos],
+ sentAt: hoursAgo(31),
+ body: "Helpful. I will tighten the chart and rework the story around calmer triage instead of generic AI claims."
+ )
+ ],
+ isUnread: true,
+ isStarred: true,
+ tags: ["Board & investors"],
+ lane: .people,
+ summary: [
+ "Marcos likes the overall update but wants a tighter chart and cleaner positioning.",
+ "He thinks the strongest differentiation story is split inbox plus keyboard-first workflow.",
+ "A revised deck with updated screenshots is the next step."
+ ]
),
MailThread(
routeID: "search-ranking-polish",
mailbox: .sent,
- subject: "Re: Search ranking polish",
- participants: [alex, me],
+ subject: "Search ranking polish - final round before ship",
+ participants: [priya, me],
messages: [
MailMessage(
routeID: "search-ranking-polish-1",
- sender: alex,
+ sender: priya,
recipients: [me],
- sentAt: .now.addingTimeInterval(-3600 * 30),
- body: "The current search sort is useful, but I still feel too much recency over intent."
+ sentAt: hoursAgo(40),
+ body: "I tagged the 12 queries that still feel off. Most of them are personal-name ambiguity and a few intent misses around review threads."
),
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."
+ recipients: [priya],
+ sentAt: hoursAgo(28),
+ body: "Agree. We should bias harder toward active collaborators and action language like review, approve, and ship instead of pure recency."
)
],
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"]
+ tags: ["Search"],
+ lane: .people
),
MailThread(
routeID: "roadmap-notes",
mailbox: .archive,
- subject: "Roadmap notes from product sync",
- participants: [nora, alex, me],
+ subject: "Roadmap notes from this morning",
+ participants: [aiko, 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."
+ sender: aiko,
+ recipients: [me],
+ sentAt: hoursAgo(46),
+ body: "Captured everything from the whiteboard and split it into doing, next, and later. The biggest open question is when we bring the screener into the main story instead of leaving it as a power-user feature.",
+ attachments: [
+ MailAttachment(name: "roadmap-notes.pdf", size: "412 KB")
+ ]
+ )
+ ],
+ isUnread: false,
+ isStarred: false,
+ tags: [],
+ lane: .people,
+ summary: [
+ "The roadmap is grouped into doing, next, and later for the next planning pass.",
+ "The biggest open product question is when the screener should become a core story instead of a hidden power-user tool.",
+ "This archived thread is the clean summary version of the whiteboard session."
+ ]
+ ),
+ MailThread(
+ routeID: "stripe-receipt",
+ mailbox: .inbox,
+ subject: "Receipt for EUR 240.00 - social-io-prod",
+ participants: [stripe, me],
+ messages: [
+ MailMessage(
+ routeID: "stripe-receipt-1",
+ sender: stripe,
+ recipients: [me],
+ sentAt: hoursAgo(72),
+ body: "Your payment of EUR 240.00 to AWS Europe SARL was successful. View invoice INV-04219 in your dashboard.",
+ attachments: [
+ MailAttachment(name: "INV-04219.pdf", size: "96 KB")
+ ]
+ )
+ ],
+ isUnread: false,
+ isStarred: false,
+ tags: ["Receipts 2026"],
+ lane: .paper
+ ),
+ MailThread(
+ routeID: "figma-comment",
+ mailbox: .inbox,
+ subject: "Lena commented on Inbox - iPad split",
+ participants: [figma, me],
+ messages: [
+ MailMessage(
+ routeID: "figma-comment-1",
+ sender: figma,
+ recipients: [me],
+ sentAt: hoursAgo(76),
+ body: "Love the lane chips. Can we try them at 10pt instead of 11pt? They fight the row title a little at this size."
+ )
+ ],
+ isUnread: false,
+ isStarred: false,
+ tags: ["Orbit Labs"],
+ lane: .feed
+ ),
+ MailThread(
+ routeID: "github-pr",
+ mailbox: .inbox,
+ subject: "[social-io/web] PR #842 ready for review - Compose autosave",
+ participants: [github, me],
+ messages: [
+ MailMessage(
+ routeID: "github-pr-1",
+ sender: github,
+ recipients: [me],
+ sentAt: hoursAgo(82),
+ body: "priya-shah opened a pull request: Persist compose draft to IndexedDB every 800ms and restore on cold open. 14 files changed, plus 382 and minus 97."
+ )
+ ],
+ isUnread: false,
+ isStarred: false,
+ tags: ["Search"],
+ lane: .feed
+ ),
+ MailThread(
+ routeID: "mom-photos",
+ mailbox: .inbox,
+ subject: "Photos from the weekend",
+ participants: [mom, me],
+ messages: [
+ MailMessage(
+ routeID: "mom-photos-1",
+ sender: mom,
+ recipients: [me],
+ sentAt: hoursAgo(90),
+ body: "Hi liebling - I tried the share thing you set up. Did the photos arrive? There are 23 of them, mostly the dog with the new yellow flowers.",
+ attachments: [
+ MailAttachment(name: "weekend-photos.zip", size: "24 MB")
+ ]
)
],
isUnread: false,
isStarred: true,
- tags: ["Product"]
+ tags: [],
+ lane: .people
+ ),
+ MailThread(
+ routeID: "apple-developer",
+ mailbox: .inbox,
+ subject: "Your annual membership renews on April 30",
+ participants: [appleDeveloper, me],
+ messages: [
+ MailMessage(
+ routeID: "apple-developer-1",
+ sender: appleDeveloper,
+ recipients: [me],
+ sentAt: hoursAgo(132),
+ body: "Your Apple Developer Program membership will automatically renew on April 30. Manage your membership in App Store Connect."
+ )
+ ],
+ isUnread: false,
+ isStarred: false,
+ tags: ["Receipts 2026"],
+ lane: .paper
+ ),
+ MailThread(
+ routeID: "welcome-to-socialio",
+ mailbox: .screener,
+ subject: "Welcome to social.io - start here",
+ participants: [socialTeam, me],
+ messages: [
+ MailMessage(
+ routeID: "welcome-to-socialio-1",
+ sender: socialTeam,
+ recipients: [me],
+ sentAt: hoursAgo(18),
+ body: "Three things to know: split inbox is automatic, command-K does almost everything, and snoozed mail comes back when you are actually free."
+ )
+ ],
+ isUnread: true,
+ isStarred: true,
+ tags: [],
+ lane: .people,
+ isScreeningCandidate: true
)
]
}
diff --git a/swift/Sources/Features/Mail/MailRootView.swift b/swift/Sources/Features/Mail/MailRootView.swift
index 4e1c934..c26fdb7 100644
--- a/swift/Sources/Features/Mail/MailRootView.swift
+++ b/swift/Sources/Features/Mail/MailRootView.swift
@@ -1,45 +1,49 @@
import SwiftUI
+
#if os(macOS)
import AppKit
#else
import UIKit
#endif
-enum MailTheme {
- static let accent = Color(red: 0.20, green: 0.47, blue: 0.94)
- static let ocean = Color(red: 0.18, green: 0.53, blue: 0.97)
- static let mint = Color(red: 0.26, green: 0.74, blue: 0.68)
- static let sunrise = Color(red: 1.00, green: 0.67, blue: 0.38)
- static let ink = Color(red: 0.10, green: 0.17, blue: 0.27)
+private enum CompactMailTab: Hashable {
+ case inbox
+ case search
+ case compose
+ case activity
+}
+
+enum MailLayoutMode {
+ case compact
+ case regular
}
struct MailRootView: View {
@Bindable var model: AppViewModel
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var preferredCompactColumn: NavigationSplitViewColumn = .content
+ @State private var compactTab: CompactMailTab = .inbox
+ @State private var lastCompactTab: CompactMailTab = .inbox
+ @State private var regularColumnVisibility: NavigationSplitViewVisibility = .all
+ @AppStorage("sio.readingPane") private var readingPaneRawValue = ReadingPanePreference.right.rawValue
var body: some View {
- NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) {
- MailSidebarView(model: model)
- } content: {
- ThreadListView(model: model)
- } detail: {
- ThreadDetailView(model: model)
- }
- .navigationSplitViewStyle(.balanced)
- .searchable(text: searchTextBinding, prompt: "Search mail")
- .toolbar {
- if showsToolbarCompose {
- ToolbarItem(placement: .primaryAction) {
- Button {
- model.startCompose()
- } label: {
- Label("Compose", systemImage: "square.and.pencil")
- }
- }
+ Group {
+ if usesCompactLayout {
+ compactScene
+ } else {
+ regularScene
}
}
- .sheet(isPresented: $model.isComposing) {
- ComposeView(model: model)
+ .overlay {
+ if model.isCommandPalettePresented {
+ CommandPaletteView(model: model)
+ }
+ }
+ .overlay(alignment: .topLeading) {
+ if !usesCompactLayout {
+ CommandPaletteShortcut(model: model)
+ }
}
.task {
await model.load()
@@ -47,25 +51,24 @@ struct MailRootView: View {
.task {
await model.beginBackendControl()
}
+ .onAppear {
+ syncRegularColumns()
+ }
+ .onChange(of: readingPaneRawValue) {
+ syncRegularColumns()
+ }
.onChange(of: model.mailboxNavigationToken) {
- showCompactColumn(.content)
+ if usesCompactLayout {
+ compactTab = .inbox
+ }
}
.onChange(of: model.threadNavigationToken) {
- showCompactColumn(.detail)
- }
- .onChange(of: model.selectedThreadID) {
- if model.selectedThreadID == nil {
- showCompactColumn(.content)
+ if usesCompactLayout {
+ compactTab = .inbox
}
}
.onChange(of: model.isComposing) {
- guard model.isComposing, usesCompactSplitNavigation else { return }
- model.dismissThreadSelection()
- showCompactColumn(.content)
- }
- .onChange(of: preferredCompactColumn) {
- guard usesCompactSplitNavigation, preferredCompactColumn != .detail else { return }
- model.dismissThreadSelection()
+ syncRegularColumns()
}
.alert("Something went wrong", isPresented: errorPresented) {
Button("OK") {
@@ -76,6 +79,146 @@ struct MailRootView: View {
}
}
+ private var compactScene: some View {
+ TabView(selection: $compactTab) {
+ NavigationStack {
+ ThreadListView(model: model, layoutMode: .compact)
+ .navigationTitle(model.selectedMailbox.title)
+ .compactInboxNavigation(searchText: searchTextBinding)
+ .navigationDestination(isPresented: compactThreadPresented) {
+ ThreadReadingView(model: model)
+ }
+ }
+ .tabItem {
+ Label("Inbox", systemImage: "tray.full")
+ }
+ .tag(CompactMailTab.inbox)
+
+ NavigationStack {
+ SearchView(model: model)
+ }
+ .tabItem {
+ Label("Search", systemImage: "magnifyingglass")
+ }
+ .tag(CompactMailTab.search)
+
+ Color.clear
+ .tabItem {
+ Label("Compose", systemImage: "square.and.pencil")
+ }
+ .tag(CompactMailTab.compose)
+
+ NavigationStack {
+ ActivityView(model: model)
+ }
+ .tabItem {
+ Label("Activity", systemImage: "bolt.horizontal")
+ }
+ .tag(CompactMailTab.activity)
+ }
+ .sheet(isPresented: compactComposePresented) {
+ ComposeView(model: model)
+ .presentationDetents([.large])
+ }
+ .onChange(of: compactTab) {
+ guard compactTab == .compose else {
+ lastCompactTab = compactTab
+ return
+ }
+
+ model.startCompose()
+ compactTab = lastCompactTab
+ }
+ }
+
+ private var regularScene: some View {
+ NavigationSplitView(
+ columnVisibility: $regularColumnVisibility,
+ preferredCompactColumn: $preferredCompactColumn
+ ) {
+ SidebarView(model: model)
+ } content: {
+ ThreadListView(model: model, layoutMode: .regular)
+ .navigationTitle(model.selectedMailbox.title)
+ } detail: {
+ if model.isComposing {
+ ComposeView(model: model)
+ } else {
+ ThreadReadingView(model: model)
+ }
+ }
+ .readingPaneNavigationStyle(readingPanePreference)
+ .sheet(isPresented: regularThreadSheetPresented) {
+ NavigationStack {
+ ThreadReadingView(model: model)
+ }
+ .frame(minWidth: 760, minHeight: 620)
+ }
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ model.startCompose()
+ } label: {
+ Label("Compose", systemImage: "square.and.pencil")
+ }
+ }
+
+ ToolbarItem(placement: .automatic) {
+ Button {
+ model.isCommandPalettePresented = true
+ } label: {
+ Label("Command Palette", systemImage: "command")
+ }
+ .keyboardShortcut("k", modifiers: .command)
+ }
+ }
+ }
+
+ private var searchTextBinding: Binding {
+ Binding(
+ get: { model.searchText },
+ set: { model.setSearchText($0) }
+ )
+ }
+
+ private var compactThreadPresented: Binding {
+ Binding(
+ get: { model.selectedThreadID != nil && !model.isComposing },
+ set: { isPresented in
+ if !isPresented {
+ model.dismissThreadSelection()
+ }
+ }
+ )
+ }
+
+ private var compactComposePresented: Binding {
+ Binding(
+ get: { model.isComposing },
+ set: { isPresented in
+ if !isPresented {
+ model.dismissCompose()
+ }
+ }
+ )
+ }
+
+ private var regularThreadSheetPresented: Binding {
+ Binding(
+ get: {
+ !usesCompactLayout &&
+ readingPanePreference == .off &&
+ model.selectedThreadID != nil &&
+ !model.isComposing
+ },
+ set: { isPresented in
+ if !isPresented {
+ model.dismissThreadSelection()
+ }
+ }
+ )
+ }
+
private var errorPresented: Binding {
Binding(
get: { model.errorMessage != nil },
@@ -87,312 +230,415 @@ struct MailRootView: View {
)
}
- private var searchTextBinding: Binding {
- Binding(
- get: { model.searchText },
- set: { model.setSearchText($0) }
- )
- }
-
- private var showsToolbarCompose: Bool {
+ private var usesCompactLayout: Bool {
#if os(iOS)
- UIDevice.current.userInterfaceIdiom != .phone
- #else
- true
- #endif
- }
-
- private var usesCompactSplitNavigation: Bool {
- #if os(iOS)
- UIDevice.current.userInterfaceIdiom == .phone
+ UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact
#else
false
#endif
}
- private func showCompactColumn(_ column: NavigationSplitViewColumn) {
- guard usesCompactSplitNavigation else { return }
- preferredCompactColumn = column
+ private var readingPanePreference: ReadingPanePreference {
+ ReadingPanePreference(rawValue: readingPaneRawValue) ?? .right
+ }
+
+ private func syncRegularColumns() {
+ guard !usesCompactLayout else { return }
+ regularColumnVisibility = readingPanePreference == .off && !model.isComposing ? .doubleColumn : .all
}
}
-private struct MailSidebarView: View {
+struct SidebarView: View {
@Bindable var model: AppViewModel
var body: some View {
List {
Section {
- SidebarHeader(model: model)
- .listRowInsets(EdgeInsets(top: 12, leading: 14, bottom: 16, trailing: 14))
+ SidebarAccountHeader(model: model)
+ .listRowInsets(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10))
.listRowBackground(Color.clear)
}
- Section("Mailboxes") {
- ForEach(Mailbox.allCases) { mailbox in
- Button {
- model.selectMailbox(mailbox)
- } label: {
- mailboxRow(for: mailbox)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .listRowBackground(
- mailbox == model.selectedMailbox
- ? MailTheme.accent.opacity(0.10)
- : Color.clear
- )
- .accessibilityIdentifier("sidebar.mailbox.\(mailbox.id)")
- }
+ Section("Inbox") {
+ inboxAllRow
+ laneRow(.feed)
+ laneRow(.paper)
+ laneRow(.people)
}
- Section("Filters") {
- Toggle(isOn: unreadOnlyBinding) {
- HStack {
- Label("Unread Only", systemImage: "circle.badge")
- Spacer()
- Text(model.totalUnreadCount, format: .number)
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
+ Section("Smart") {
+ mailboxRow(.starred)
+ mailboxRow(.snoozed)
+ mailboxRow(.screener, accessibilityID: "mailbox.screener")
+ }
+
+ Section("Mailboxes") {
+ mailboxRow(.sent)
+ mailboxRow(.drafts)
+ mailboxRow(.archive)
+ mailboxRow(.trash)
+ }
+
+ if !model.folderNames.isEmpty {
+ Section("Folders") {
+ ForEach(model.folderNames, id: \.self) { folder in
+ HStack(spacing: 12) {
+ Image(systemName: "folder")
+ .foregroundStyle(.secondary)
+ Text(folder)
+ }
+ .font(.subheadline)
}
}
}
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
- .background(MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.mint))
- .navigationTitle("social.io")
+ .background(platformBackground.ignoresSafeArea())
}
- private var unreadOnlyBinding: Binding {
- Binding(
- get: { model.showUnreadOnly },
- set: { model.setUnreadOnly($0) }
- )
- }
-
- private func mailboxRow(for mailbox: Mailbox) -> some View {
- HStack(spacing: 12) {
- Label(mailbox.title, systemImage: mailbox.systemImage)
- .foregroundStyle(mailbox == model.selectedMailbox ? .primary : .secondary)
-
- Spacer()
-
- Text(model.threadCount(in: mailbox), format: .number)
- .font(.caption.weight(.semibold))
- .foregroundStyle(mailbox == model.selectedMailbox ? .primary : .secondary)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(
- mailbox == model.selectedMailbox
- ? MailTheme.accent.opacity(0.14)
- : Color.secondary.opacity(0.10),
- in: Capsule()
- )
+ private var inboxAllRow: some View {
+ Button {
+ model.selectMailbox(.inbox)
+ model.setLaneFilter(nil)
+ } label: {
+ SidebarMailboxLabel(
+ title: "All",
+ systemImage: Mailbox.inbox.systemImage,
+ isSelected: model.selectedMailbox == .inbox && model.laneFilter == nil
+ )
}
- .frame(maxWidth: .infinity, alignment: .leading)
+ .buttonStyle(.plain)
+ .badge(model.threadCount(in: .inbox))
+ .accessibilityIdentifier("mailbox.inbox")
+ }
+
+ private func mailboxRow(_ mailbox: Mailbox, accessibilityID: String? = nil) -> some View {
+ Button {
+ model.selectMailbox(mailbox)
+ if mailbox != .inbox {
+ model.setLaneFilter(nil)
+ }
+ } label: {
+ SidebarMailboxLabel(
+ title: mailbox.title,
+ systemImage: mailbox.systemImage,
+ isSelected: model.selectedMailbox == mailbox
+ )
+ }
+ .buttonStyle(.plain)
+ .badge(model.threadCount(in: mailbox))
+ .accessibilityIdentifier(accessibilityID ?? "mailbox.\(mailbox.id)")
+ }
+
+ private func laneRow(_ lane: Lane) -> some View {
+ Button {
+ model.selectMailbox(.inbox)
+ model.setLaneFilter(lane)
+ } label: {
+ HStack(spacing: 12) {
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .fill(lane.color)
+ .frame(width: 14, height: 14)
+ Text(lane.label)
+ .font(.subheadline.weight(model.selectedMailbox == .inbox && model.laneFilter == lane ? .semibold : .regular))
+ .foregroundStyle(model.selectedMailbox == .inbox && model.laneFilter == lane ? lane.color : Color.primary)
+ Spacer(minLength: 0)
+ }
+ .padding(.vertical, 4)
+ }
+ .buttonStyle(.plain)
+ .badge(model.laneCount(lane, in: .inbox))
+ .accessibilityIdentifier("mailbox.lane.\(lane.rawValue)")
}
}
-private struct SidebarHeader: View {
+private struct SidebarAccountHeader: View {
@Bindable var model: AppViewModel
var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .center, spacing: 14) {
- Image(systemName: "at.circle.fill")
- .font(.system(size: 30, weight: .semibold))
- .foregroundStyle(MailTheme.accent, MailTheme.mint)
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 12) {
+ AvatarView(name: model.currentUser.name, color: SIO.tint, size: 42)
- VStack(alignment: .leading, spacing: 4) {
- Text("social.io mail")
- .font(.title3.weight(.bold))
- Text("Calm inboxes for real conversations.")
+ VStack(alignment: .leading, spacing: 2) {
+ Text(model.currentUser.name)
+ .font(.headline)
+ Text(model.currentUser.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
- AdaptiveGlassGroup(spacing: 16) {
- HStack(spacing: 12) {
- SummaryChip(
- title: "Unread",
- value: model.totalUnreadCount,
- tint: MailTheme.accent.opacity(0.18)
- )
-
- SummaryChip(
- title: "Starred",
- value: model.threadCount(in: .starred),
- tint: MailTheme.sunrise.opacity(0.18)
- )
- }
+ HStack(spacing: 12) {
+ compactSummary(title: "Unread", value: model.totalUnreadCount, tint: SIO.tint)
+ compactSummary(title: "Starred", value: model.threadCount(in: .starred), tint: .yellow)
}
}
+ .padding(16)
+ .sioCardBackground(tint: SIO.tint)
}
-}
-private struct SummaryChip: View {
- let title: String
- let value: Int
- let tint: Color?
-
- var body: some View {
+ private func compactSummary(title: String, value: Int, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value, format: .number)
.font(.headline.weight(.semibold))
+ .foregroundStyle(tint)
}
.frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 14)
- .padding(.vertical, 12)
- .socialGlass(in: RoundedRectangle(cornerRadius: 18, style: .continuous), tint: tint)
}
}
-private struct ThreadListView: View {
- @Bindable var model: AppViewModel
+private struct SidebarMailboxLabel: View {
+ let title: String
+ let systemImage: String
+ let isSelected: Bool
var body: some View {
- ZStack {
- MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.sunrise)
- .ignoresSafeArea()
+ HStack(spacing: 12) {
+ Image(systemName: systemImage)
+ .foregroundStyle(isSelected ? SIO.tint : Color.secondary)
+ Text(title)
+ .font(.subheadline.weight(isSelected ? .semibold : .regular))
+ .foregroundStyle(isSelected ? SIO.tint : Color.primary)
+ Spacer(minLength: 0)
+ }
+ .padding(.vertical, 4)
+ }
+}
- VStack(spacing: 0) {
- MailboxFilterBar(model: model)
- MailboxHeroCard(model: model)
+struct ThreadListView: View {
+ @Bindable var model: AppViewModel
+ let layoutMode: MailLayoutMode
+ @AppStorage("sio.density") private var densityRawValue = ""
- Group {
- if model.isLoading {
- ProgressView("Loading mail…")
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else if model.filteredThreads.isEmpty {
- ContentUnavailableView(
- "No Messages",
- systemImage: "tray",
- description: Text("Try another mailbox or relax the filters.")
- )
+ var body: some View {
+ VStack(spacing: 0) {
+ if layoutMode == .regular {
+ ThreadListSearchHeader(model: model)
+ }
+
+ if layoutMode == .compact && model.selectedMailbox == .inbox {
+ CompactLaneOverview(model: model)
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ }
+
+ LaneFilterStrip(model: model)
+ .padding(.vertical, 10)
+
+ Divider()
+
+ Group {
+ if model.isLoading {
+ ProgressView("Loading mail...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- List(model.filteredThreads) { thread in
- Button {
- model.openThread(withID: thread.id)
- } label: {
- ThreadRow(thread: thread, isSelected: thread.id == model.selectedThreadID)
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
+ } else if model.selectedMailbox == .screener {
+ ScreenerListView(model: model)
+ } else if model.filteredThreads.isEmpty {
+ ContentUnavailableView(
+ "No Messages",
+ systemImage: "tray",
+ description: Text("Try another mailbox or relax the filters.")
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ List(model.filteredThreads) { thread in
+ Button {
+ Haptics.selection()
+ model.openThread(withID: thread.id)
+ } label: {
+ ThreadRow(
+ thread: thread,
+ density: density,
+ isSelected: thread.id == model.selectedThreadID
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .listRowInsets(EdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14))
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ .contextMenu {
+ Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
+ model.toggleRead(for: thread)
}
- .buttonStyle(.plain)
- .listRowSeparator(.hidden)
- .listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 8, trailing: 18))
- .listRowBackground(Color.clear)
- .contextMenu {
- Button(thread.isUnread ? "Mark Read" : "Mark Unread") {
- model.toggleRead(for: thread)
- }
- Button(thread.isStarred ? "Remove Star" : "Star Thread") {
- model.toggleStar(for: thread)
- }
+ Button(thread.isStarred ? "Remove Star" : "Star") {
+ model.toggleStar(for: thread)
+ }
+
+ Button("Archive") {
+ model.moveThread(withID: thread.id, to: .archive)
}
}
- .listStyle(.plain)
- .scrollContentBackground(.hidden)
}
+ .listStyle(.plain)
+ .scrollContentBackground(.hidden)
}
}
}
+ .background(platformBackground.ignoresSafeArea())
.safeAreaInset(edge: .bottom) {
- FloatingComposeButton(model: model)
+ if layoutMode == .compact {
+ FloatingComposeButton(model: model)
+ }
}
- .navigationTitle(model.selectedMailbox.title)
- .mailInlineNavigationTitle()
- .mailNavigationChrome()
+ }
+
+ private var density: ThreadRowDensity {
+ if let stored = ThreadRowDensity(rawValue: densityRawValue) {
+ return stored
+ }
+
+ #if os(macOS)
+ return .cozy
+ #else
+ return .comfortable
+ #endif
}
}
-private struct MailboxHeroCard: View {
+private struct ThreadListSearchHeader: View {
@Bindable var model: AppViewModel
var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- Text(model.selectedMailbox.title)
- .font(.system(.largeTitle, design: .rounded, weight: .bold))
+ VStack(alignment: .leading, spacing: 12) {
+ MailSearchField(
+ text: Binding(
+ get: { model.searchText },
+ set: { model.setSearchText($0) }
+ ),
+ placeholder: "Search mail"
+ )
- Text(mailboxDescription)
- .font(.subheadline)
- .foregroundStyle(.secondary)
-
- AdaptiveGlassGroup(spacing: 12) {
- HStack(spacing: 12) {
- SummaryChip(
- title: "Visible",
- value: model.filteredThreads.count,
- tint: MailTheme.accent.opacity(0.20)
- )
-
- SummaryChip(
- title: "Unread",
- value: model.filteredThreads.filter(\.isUnread).count,
- tint: MailTheme.mint.opacity(0.18)
- )
-
- SummaryChip(
- title: "Starred",
- value: model.filteredThreads.filter(\.isStarred).count,
- tint: MailTheme.sunrise.opacity(0.18)
- )
- }
- }
-
- if let latestThread = model.filteredThreads.first {
+ ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
- Image(systemName: "clock")
- Text("Latest activity \(latestThread.lastUpdated.formatted(date: .abbreviated, time: .shortened))")
+ FilterChip(title: "From:", subtitle: model.selectedThread?.participants.first?.name ?? "Anyone")
+ FilterChip(title: "Has attachment", isSelected: model.filteredThreads.contains(where: \.hasAttachments))
+ FilterChip(title: "Last 30 days", isSelected: true)
+ FilterChip(title: "Lane:", subtitle: model.laneFilter?.label ?? "All")
}
- .font(.footnote)
- .foregroundStyle(.secondary)
}
}
- .padding(.horizontal, 20)
- .padding(.vertical, 22)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(heroBackground, in: RoundedRectangle(cornerRadius: 30, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 30, style: .continuous)
- .stroke(Color.white.opacity(0.20), lineWidth: 1)
- )
- .padding(.horizontal, 20)
- .padding(.bottom, 12)
+ .padding(.horizontal, 16)
+ .padding(.top, 14)
+ .padding(.bottom, 10)
}
+}
- private var mailboxDescription: String {
- switch model.selectedMailbox {
- case .inbox:
- "Fresh conversations, live signals, and mail worth deciding on now."
- case .starred:
- "Pinned threads that still deserve attention, not just memory."
- case .sent:
- "Everything you shipped recently, ready for quick follow-up."
- case .drafts:
- "Half-finished notes and messages waiting for a final pass."
- case .archive:
- "Quieted threads with context still close at hand."
+private struct LaneFilterStrip: View {
+ @Bindable var model: AppViewModel
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ filterButton(title: "All", lane: nil)
+ ForEach(Lane.allCases) { lane in
+ filterButton(title: lane.label, lane: lane)
+ }
+ unreadButton
+ }
+ .padding(.horizontal, 16)
}
}
- private var heroBackground: some ShapeStyle {
- LinearGradient(
- colors: [
- MailTheme.accent.opacity(0.28),
- MailTheme.ocean.opacity(0.16),
- Color.white.opacity(0.08)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
+ private func filterButton(title: String, lane: Lane?) -> some View {
+ Button {
+ model.setLaneFilter(lane)
+ } label: {
+ HStack(spacing: 8) {
+ if let lane {
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .fill(lane.color)
+ .frame(width: 10, height: 10)
+ }
+ Text(title)
+ if model.unreadCount(for: lane) > 0 {
+ Text(model.unreadCount(for: lane), format: .number)
+ .font(.caption2.weight(.bold))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background((lane?.color ?? SIO.tint).opacity(0.14), in: Capsule())
+ }
+ }
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(model.laneFilter == lane ? (lane?.color ?? SIO.tint) : Color.primary)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(
+ Capsule(style: .continuous)
+ .fill(model.laneFilter == lane ? (lane?.color ?? SIO.tint).opacity(0.12) : Color.secondary.opacity(0.08))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var unreadButton: some View {
+ Button {
+ model.setUnreadOnly(!model.showUnreadOnly)
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: model.showUnreadOnly ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
+ Text("Unread")
+ }
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(model.showUnreadOnly ? SIO.tint : Color.primary)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(
+ Capsule(style: .continuous)
+ .fill(model.showUnreadOnly ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.08))
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("filter.unread")
+ }
+}
+
+private struct ScreenerListView: View {
+ @Bindable var model: AppViewModel
+
+ var body: some View {
+ if model.screenerThreads.isEmpty {
+ ContentUnavailableView(
+ "The Screener is Empty",
+ systemImage: "person.crop.circle.badge.checkmark",
+ description: Text("New first-contact mail will land here before it reaches the inbox.")
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ List(model.screenerThreads) { thread in
+ VStack(alignment: .leading, spacing: 14) {
+ ThreadRow(thread: thread, density: .comfortable, isSelected: false)
+
+ HStack(spacing: 10) {
+ Button("Approve") {
+ model.applyScreenerDecision(.approve, to: thread.id)
+ }
+ .buttonStyle(PrimaryActionStyle())
+
+ Button("Send to Paper") {
+ model.applyScreenerDecision(.sendToPaper, to: thread.id)
+ }
+ .buttonStyle(SecondaryActionStyle())
+
+ Button("Block") {
+ model.applyScreenerDecision(.block, to: thread.id)
+ }
+ .buttonStyle(DestructiveStyle())
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14))
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+ .listStyle(.plain)
+ .scrollContentBackground(.hidden)
+ }
}
}
@@ -400,213 +646,247 @@ private struct FloatingComposeButton: View {
@Bindable var model: AppViewModel
var body: some View {
- Group {
- if shouldShow {
- HStack {
- Spacer()
-
- Button {
- model.startCompose()
- } label: {
- HStack(spacing: 10) {
- Image(systemName: "square.and.pencil")
- Text("Compose")
- }
- .font(.headline.weight(.semibold))
- .padding(.horizontal, 18)
- .padding(.vertical, 14)
- .socialGlass(
- in: Capsule(),
- tint: MailTheme.accent.opacity(0.22),
- interactive: true
- )
- .contentShape(Capsule())
- }
- .buttonStyle(.plain)
- .accessibilityIdentifier("compose.floating")
+ HStack {
+ Spacer()
+ Button {
+ model.startCompose()
+ } label: {
+ HStack(spacing: 10) {
+ Image(systemName: "square.and.pencil")
+ Text("Compose")
}
- .padding(.horizontal, 20)
- .padding(.top, 8)
- .padding(.bottom, 12)
- .background(Color.clear)
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 14)
+ .sioGlassSurface(in: Capsule(), tint: SIO.tint)
}
+ .buttonStyle(.plain)
}
- }
-
- private var shouldShow: Bool {
- #if os(iOS)
- UIDevice.current.userInterfaceIdiom == .phone
- #else
- false
- #endif
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, 12)
}
}
-private struct MailboxFilterBar: View {
+private struct CompactLaneOverview: View {
@Bindable var model: AppViewModel
var body: some View {
- ScrollView(.horizontal, showsIndicators: false) {
- AdaptiveGlassGroup(spacing: 16) {
- HStack(spacing: 12) {
- ForEach(Mailbox.allCases) { mailbox in
- Button {
- model.selectMailbox(mailbox)
- } label: {
- HStack(spacing: 8) {
- Image(systemName: mailbox.systemImage)
- Text(mailbox.title)
- Text(model.threadCount(in: mailbox), format: .number)
- .font(.caption2.weight(.bold))
- .foregroundStyle(.secondary)
- }
- .font(.subheadline.weight(.semibold))
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
- .socialGlass(
- in: Capsule(),
- tint: mailbox == model.selectedMailbox ? MailTheme.accent.opacity(0.18) : nil,
- interactive: true
- )
+ HStack(spacing: 10) {
+ ForEach(Lane.allCases) { lane in
+ Button {
+ model.selectMailbox(.inbox)
+ model.setLaneFilter(lane)
+ } label: {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(lane.color)
+ .frame(width: 7, height: 7)
+ Text(lane.label.uppercased())
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.secondary)
}
- .buttonStyle(.plain)
- .accessibilityIdentifier("mailbox.\(mailbox.id)")
- }
- Button {
- model.setUnreadOnly(!model.showUnreadOnly)
- } label: {
- HStack(spacing: 8) {
- Image(systemName: model.showUnreadOnly ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
- Text("Unread")
- }
- .font(.subheadline.weight(.semibold))
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
- .socialGlass(
- in: Capsule(),
- tint: model.showUnreadOnly ? MailTheme.mint.opacity(0.18) : nil,
- interactive: true
- )
+ Text(model.unreadCount(for: lane), format: .number)
+ .font(.title2.weight(.bold))
+ .foregroundStyle(model.laneFilter == lane ? lane.color : Color.primary)
}
- .buttonStyle(.plain)
- .accessibilityIdentifier("filter.unread")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(model.laneFilter == lane ? lane.color.opacity(0.12) : Color.secondary.opacity(0.08))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(model.laneFilter == lane ? lane.color.opacity(0.24) : Color.primary.opacity(0.06), lineWidth: 1)
+ )
}
+ .buttonStyle(.plain)
}
- .padding(.horizontal, 20)
- .padding(.vertical, 14)
}
}
}
private struct ThreadRow: View {
let thread: MailThread
+ let density: ThreadRowDensity
let isSelected: Bool
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack(alignment: .top, spacing: 12) {
- VStack(alignment: .leading, spacing: 4) {
- Text(thread.participants.map(\.name).joined(separator: ", "))
- .font(.subheadline.weight(thread.isUnread ? .semibold : .regular))
- .lineLimit(1)
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 10) {
+ unreadDot
+
+ AvatarView(name: senderName, color: thread.lane.color, size: density.avatarSize)
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(senderName)
+ .font(.subheadline.weight(thread.isUnread ? .bold : .semibold))
+ .foregroundStyle(isSelected ? SIO.tint : Color.primary)
+ .lineLimit(1)
+
+ if thread.messageCount > 1 {
+ Text(thread.messageCount, format: .number)
+ .font(.caption2.weight(.bold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.secondary.opacity(0.12), in: Capsule())
+ }
+
+ Spacer(minLength: 0)
+
+ if thread.isStarred {
+ Image(systemName: "star.fill")
+ .foregroundStyle(.yellow)
+ }
+
+ if thread.hasAttachments {
+ Image(systemName: "paperclip")
+ .foregroundStyle(.secondary)
+ }
+
+ Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
Text(thread.subject)
.font(.headline)
.lineLimit(1)
- }
- Spacer(minLength: 0)
-
- VStack(alignment: .trailing, spacing: 6) {
- if thread.isStarred {
- Image(systemName: "star.fill")
- .foregroundStyle(.yellow)
- }
-
- Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
- .font(.caption)
+ Text(thread.previewText)
+ .font(.subheadline)
.foregroundStyle(.secondary)
- }
- }
+ .lineLimit(density.previewLineLimit)
- Text(thread.previewText)
- .font(.footnote)
- .foregroundStyle(.secondary)
- .lineLimit(2)
+ if density.showsMetaChips {
+ HStack(spacing: 8) {
+ LaneChip(lane: thread.lane)
- if !thread.tags.isEmpty {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- ForEach(thread.tags, id: \.self) { tag in
- Text(tag)
- .font(.caption.weight(.medium))
- .padding(.horizontal, 10)
- .padding(.vertical, 6)
- .background(Color.secondary.opacity(0.10), in: Capsule())
+ if thread.summary != nil {
+ HStack(spacing: 6) {
+ Image(systemName: "sparkles")
+ Text("AI Summary")
+ }
+ .font(.caption.weight(.semibold))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .background(SIO.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous))
+ .foregroundStyle(SIO.tint)
+ }
}
}
}
}
}
- .padding(16)
- .mailPanelBackground(
- in: RoundedRectangle(cornerRadius: 24, style: .continuous),
- highlight: isSelected ? MailTheme.accent.opacity(0.28) : Color.white.opacity(0.10)
+ .padding(density.rowPadding)
+ .background(
+ RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
+ .fill(isSelected ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.06))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
+ .strokeBorder(isSelected ? SIO.tint.opacity(0.18) : Color.primary.opacity(0.06), lineWidth: 1)
)
.accessibilityIdentifier("thread.\(thread.routeID)")
}
+
+ private var senderName: String {
+ thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown Sender"
+ }
+
+ @ViewBuilder
+ private var unreadDot: some View {
+ Circle()
+ .fill(thread.isUnread ? SIO.tint : Color.clear)
+ .frame(width: 8, height: 8)
+ .padding(.top, density.avatarSize > 24 ? 10 : 8)
+ }
}
-private struct ThreadDetailView: View {
+struct ThreadReadingView: View {
@Bindable var model: AppViewModel
+ @State private var replyText = ""
var body: some View {
- ZStack {
- MailCanvasBackground(primary: MailTheme.mint, secondary: MailTheme.sunrise)
- .ignoresSafeArea()
+ Group {
+ if let thread = model.selectedThread {
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ ReadingHeader(thread: thread)
- Group {
- if let thread = model.selectedThread {
- ScrollViewReader { proxy in
- ScrollView {
- VStack(alignment: .leading, spacing: 24) {
- ThreadHero(threadID: thread.id, model: model)
-
- ForEach(thread.messages) { message in
- MessageCard(
- message: message,
- isLatest: message.id == thread.latestMessage?.id,
- isFocused: message.routeID == model.focusedMessageRouteID
- )
- .id(message.routeID)
- }
+ if let summary = thread.summary {
+ AISummaryCard(count: thread.messageCount, bullets: summary)
+ }
+
+ ForEach(thread.messages) { message in
+ MessageCard(
+ message: message,
+ isFocused: message.routeID == model.focusedMessageRouteID,
+ isLatest: message.id == thread.latestMessage?.id
+ )
+ .id(message.routeID)
}
- .padding(24)
- .frame(maxWidth: 920, alignment: .leading)
}
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .onAppear {
- scrollToFocusedMessage(using: proxy, animated: false)
- }
- .onChange(of: model.focusedMessageRouteID) {
- scrollToFocusedMessage(using: proxy)
- }
- .onChange(of: thread.routeID) {
- scrollToFocusedMessage(using: proxy, animated: false)
+ .padding(20)
+ .frame(maxWidth: 920, alignment: .leading)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ .background(platformBackground.ignoresSafeArea())
+ .safeAreaInset(edge: .top) {
+ if !isPhone {
+ ReadingToolbar(model: model, thread: thread)
}
}
- } else {
- ContentUnavailableView(
- "Select a Thread",
- systemImage: "envelope.open",
- description: Text("Choose a conversation to read or compose a new message.")
- )
+ .safeAreaInset(edge: .bottom) {
+ InlineReplyComposer(
+ text: $replyText,
+ placeholder: "Reply to \(replyTargetName(for: thread))..."
+ ) {
+ model.sendInlineReply(replyText, in: thread.id)
+ replyText = ""
+ Haptics.success()
+ }
+ }
+ .onAppear {
+ scrollToFocusedMessage(using: proxy, animated: false)
+ }
+ .onChange(of: model.focusedMessageRouteID) {
+ scrollToFocusedMessage(using: proxy)
+ }
+ .onChange(of: thread.routeID) {
+ scrollToFocusedMessage(using: proxy, animated: false)
+ }
}
+ } else {
+ ContentUnavailableView(
+ "Select a Thread",
+ systemImage: "envelope.open",
+ description: Text("Choose a conversation to read or compose a new message.")
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(platformBackground.ignoresSafeArea())
}
}
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .navigationTitle("Conversation")
+ }
+
+ private var isPhone: Bool {
+ #if os(iOS)
+ UIDevice.current.userInterfaceIdiom == .phone
+ #else
+ false
+ #endif
+ }
+
+ private func replyTargetName(for thread: MailThread) -> String {
+ thread.latestMessage?.sender.name ?? thread.participants.first(where: { $0.email != model.currentUser.email })?.name ?? "sender"
}
private func scrollToFocusedMessage(using proxy: ScrollViewProxy, animated: Bool = true) {
@@ -622,218 +902,123 @@ private struct ThreadDetailView: View {
}
}
-private struct ThreadHero: View {
- let threadID: MailThread.ID
- @Bindable var model: AppViewModel
+private struct ReadingHeader: View {
+ let thread: MailThread
var body: some View {
- Group {
- if let thread = model.thread(withID: threadID) {
- VStack(alignment: .leading, spacing: 18) {
- if usesCompactHeroLayout {
- VStack(alignment: .leading, spacing: 16) {
- heroHeaderContent(for: thread)
- ThreadActionBar(threadID: thread.id, model: model, compact: true)
- }
- } else {
- HStack(alignment: .top, spacing: 16) {
- heroHeaderContent(for: thread)
-
- Spacer(minLength: 0)
-
- ThreadActionBar(threadID: thread.id, model: model)
- }
- }
-
- if !thread.tags.isEmpty {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- ForEach(thread.tags, id: \.self) { tag in
- Text(tag)
- .font(.caption.weight(.medium))
- .padding(.horizontal, 10)
- .padding(.vertical, 6)
- .background(Color.secondary.opacity(0.10), in: Capsule())
- }
- }
- }
- }
-
- Text("Latest update \(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))")
- .font(.footnote)
- .foregroundStyle(.secondary)
- }
- .padding(24)
- .background(heroBackground, in: RoundedRectangle(cornerRadius: 32, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 32, style: .continuous)
- .stroke(Color.white.opacity(0.18), lineWidth: 1)
- )
- }
- }
- }
-
- private func heroHeaderContent(for thread: MailThread) -> some View {
- VStack(alignment: .leading, spacing: 10) {
- AdaptiveGlassGroup(spacing: 14) {
- if usesCompactHeroLayout {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 10) {
- heroStatusChips(for: thread)
- }
- }
- } else {
- HStack(spacing: 10) {
- heroStatusChips(for: thread)
- }
- }
- }
-
+ VStack(alignment: .leading, spacing: 14) {
Text(thread.subject)
- .font(.system(.largeTitle, design: .rounded, weight: .bold))
+ .font(.title.weight(.bold))
- Text(thread.participants.map(\.email).joined(separator: ", "))
- .font(.subheadline)
- .foregroundStyle(.secondary)
+ HStack(alignment: .center, spacing: 12) {
+ participantStack
+
+ LaneChip(lane: thread.lane)
+
+ Text("\(thread.messageCount) \(thread.messageCount == 1 ? "message" : "messages")")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ Spacer(minLength: 0)
+
+ Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
}
+ .padding(18)
+ .sioCardBackground(tint: thread.lane.color)
}
- @ViewBuilder
- private func heroStatusChips(for thread: MailThread) -> some View {
- StatusChip(
- title: thread.mailbox.title,
- systemImage: thread.mailbox.systemImage,
- tint: MailTheme.accent.opacity(0.18)
- )
-
- StatusChip(
- title: "Unread",
- systemImage: "circle.badge.fill",
- tint: MailTheme.sunrise.opacity(0.18)
- )
- .opacity(thread.isUnread ? 1 : 0)
- .accessibilityHidden(!thread.isUnread)
- }
-
- private var heroBackground: some ShapeStyle {
- LinearGradient(
- colors: [
- MailTheme.accent.opacity(0.22),
- MailTheme.mint.opacity(0.12),
- Color.white.opacity(0.06)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- }
-
- private var usesCompactHeroLayout: Bool {
- #if os(iOS)
- UIDevice.current.userInterfaceIdiom == .phone
- #else
- false
- #endif
+ private var participantStack: some View {
+ HStack(spacing: -10) {
+ ForEach(Array(thread.participants.prefix(3))) { participant in
+ AvatarView(name: participant.name, color: thread.lane.color, size: 28)
+ }
+ }
}
}
-private struct StatusChip: View {
- let title: String
- let systemImage: String
- let tint: Color?
-
- var body: some View {
- HStack(spacing: 8) {
- Image(systemName: systemImage)
- Text(title)
- }
- .font(.caption.weight(.semibold))
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .socialGlass(in: Capsule(), tint: tint)
- }
-}
-
-private struct ThreadActionBar: View {
- let threadID: MailThread.ID
+private struct ReadingToolbar: View {
@Bindable var model: AppViewModel
- var compact = false
- private let controlAnimation = Animation.snappy(duration: 0.24, extraBounce: 0.03)
+ let thread: MailThread
var body: some View {
- Group {
- if let thread = model.thread(withID: threadID) {
- HStack(spacing: compact ? 10 : 12) {
- actionButtons(for: thread)
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 10) {
+ toolbarButton("Archive", systemImage: "archivebox", key: "E") {
+ model.moveThread(withID: thread.id, to: .archive)
}
- .animation(controlAnimation, value: thread.isStarred)
- .animation(controlAnimation, value: thread.isUnread)
+ .keyboardShortcut("e", modifiers: [])
+
+ toolbarButton("Move", systemImage: "tray.and.arrow.down", key: "I") {
+ model.moveThread(withID: thread.id, to: .inbox)
+ }
+ .keyboardShortcut("i", modifiers: [])
+
+ toolbarButton("Delete", systemImage: "trash", key: "⌫", destructive: true) {
+ model.moveThread(withID: thread.id, to: .trash)
+ }
+
+ toolbarButton("Reply", systemImage: "arrowshape.turn.up.left", key: "R") {
+ model.startReply(to: thread.id)
+ }
+ .keyboardShortcut("r", modifiers: [])
+
+ toolbarButton("Reply all", systemImage: "arrowshape.turn.up.left.2", key: "⇧R") {
+ model.startReply(to: thread.id, replyAll: true)
+ }
+ .keyboardShortcut("r", modifiers: [.shift])
+
+ toolbarButton("Forward", systemImage: "arrowshape.turn.up.right", key: "F") {
+ model.startReply(to: thread.id, forward: true)
+ }
+ .keyboardShortcut("f", modifiers: [])
}
+ .padding(.horizontal, 20)
+ .padding(.top, 12)
+ .padding(.bottom, 6)
}
+ .background(platformBackground.opacity(0.92))
}
- private func actionButtons(for thread: MailThread) -> some View {
- Group {
- actionButton(
- title: thread.isStarred ? "Starred" : "Star",
- systemImage: thread.isStarred ? "star.fill" : "star",
- tint: thread.isStarred ? MailTheme.sunrise.opacity(0.22) : nil
- ) {
- withAnimation(controlAnimation) {
- model.toggleStar(forThreadID: thread.id)
- }
- }
-
- actionButton(
- title: thread.isUnread ? "Mark Read" : "Mark Unread",
- systemImage: thread.isUnread ? "envelope.open.fill" : "envelope.badge",
- tint: thread.isUnread ? MailTheme.mint.opacity(0.20) : nil
- ) {
- withAnimation(controlAnimation) {
- model.toggleRead(forThreadID: thread.id)
- }
- }
- }
- }
-
- private func actionButton(
- title: String,
+ private func toolbarButton(
+ _ title: String,
systemImage: String,
- tint: Color?,
+ key: String,
+ destructive: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
- HStack(spacing: 8) {
+ HStack(spacing: 10) {
Image(systemName: systemImage)
- .contentTransition(.symbolEffect(.replace))
Text(title)
- .lineLimit(1)
- .contentTransition(.opacity)
+ KeyboardHint(title: key)
}
.font(.subheadline.weight(.semibold))
- .fixedSize(horizontal: true, vertical: false)
- .padding(.horizontal, 14)
+ .padding(.horizontal, 12)
.padding(.vertical, 10)
- .stableControlPill(tint: tint)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
+ .foregroundStyle(destructive ? Color.red : Color.primary)
}
.buttonStyle(.plain)
- .animation(controlAnimation, value: title)
- .animation(controlAnimation, value: systemImage)
- .animation(controlAnimation, value: tint != nil)
}
}
private struct MessageCard: View {
let message: MailMessage
- let isLatest: Bool
let isFocused: Bool
+ let isLatest: Bool
var body: some View {
VStack(alignment: .leading, spacing: 14) {
- HStack(alignment: .top) {
- VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .top, spacing: 12) {
+ AvatarView(name: message.sender.name, color: SIO.tint, size: 32)
+
+ VStack(alignment: .leading, spacing: 3) {
Text(message.sender.name)
.font(.headline)
- Text(message.sender.email)
+ Text("to \(message.recipients.map(\.email).joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -846,162 +1031,368 @@ private struct MessageCard: View {
}
Text(message.body)
- .font(.body)
+ .sioProse()
.textSelection(.enabled)
- }
- .padding(20)
- .mailPanelBackground(
- in: RoundedRectangle(cornerRadius: 28, style: .continuous),
- highlight: messageHighlight
- )
- .overlay(alignment: .topTrailing) {
- if isFocused {
- Text("Focused")
- .font(.caption2.weight(.bold))
- .padding(.horizontal, 10)
- .padding(.vertical, 6)
- .socialGlass(in: Capsule(), tint: MailTheme.accent.opacity(0.18))
- .padding(14)
+
+ if !message.attachments.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(message.attachments) { attachment in
+ HStack(spacing: 6) {
+ Image(systemName: "doc")
+ Text(attachment.name)
+ Text(attachment.size)
+ .foregroundStyle(.secondary)
+ Image(systemName: "arrow.down.circle")
+ }
+ .font(.caption2.weight(.semibold))
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
+ }
+ }
}
}
+ .padding(18)
+ .background(
+ RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
+ .fill(isFocused ? SIO.tint.opacity(0.12) : (isLatest ? SIO.tint.opacity(0.08) : Color.secondary.opacity(0.05)))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
+ .strokeBorder(isFocused ? SIO.tint.opacity(0.22) : Color.primary.opacity(0.06), lineWidth: 1)
+ )
.accessibilityIdentifier("message.\(message.routeID)")
}
-
- private var messageHighlight: Color {
- if isFocused {
- return MailTheme.accent.opacity(0.38)
- }
-
- if isLatest {
- return MailTheme.accent.opacity(0.22)
- }
-
- return Color.white.opacity(0.10)
- }
}
-private struct ComposeView: View {
+private struct InlineReplyComposer: View {
+ @Binding var text: String
+ let placeholder: String
+ let onSend: () -> Void
+
+ var body: some View {
+ HStack(alignment: .bottom, spacing: 12) {
+ ZStack(alignment: .leading) {
+ if text.isEmpty {
+ Text(placeholder)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ }
+
+ TextEditor(text: $text)
+ .font(.system(size: 15.5))
+ .scrollContentBackground(.hidden)
+ .frame(minHeight: 44, maxHeight: min(140, 44 + CGFloat(max(0, text.components(separatedBy: "\n").count - 1)) * 22))
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(Color.clear)
+ }
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
+
+ Button(action: onSend) {
+ Image(systemName: "arrow.up")
+ .font(.headline.weight(.bold))
+ .foregroundStyle(.white)
+ .frame(width: 42, height: 42)
+ .background(SIO.tint, in: Circle())
+ }
+ .buttonStyle(.plain)
+ .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, 12)
+ .background(platformBackground.opacity(0.94))
+ }
+}
+
+struct ComposeView: View {
@Environment(\.dismiss) private var dismiss
@Bindable var model: AppViewModel
var body: some View {
- Group {
- if usesCompactComposeLayout {
- composeScene
- } else {
- composeScene
- .frame(minWidth: 560, minHeight: 520)
+ VStack(spacing: 0) {
+ HStack {
+ Button("Cancel") {
+ model.dismissCompose()
+ dismiss()
+ }
+ .buttonStyle(SecondaryActionStyle())
+
+ Spacer(minLength: 0)
+
+ Text("New Message")
+ .font(.headline.weight(.semibold))
+
+ Spacer(minLength: 0)
+
+ Button(model.isSending ? "Sending..." : "Send") {
+ Task {
+ let didSend = await model.sendCurrentDraft()
+ if didSend {
+ Haptics.success()
+ dismiss()
+ } else {
+ Haptics.warning()
+ }
+ }
+ }
+ .buttonStyle(PrimaryActionStyle())
+ .disabled(model.isSending || model.composeDraft.to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.composeDraft.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ .accessibilityIdentifier("compose.send")
+ }
+ .padding(16)
+
+ Divider()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 14) {
+ ComposeHeaderRow(title: "To") {
+ RecipientTokenInput(text: $model.composeDraft.to, placeholder: "name@example.com", accessibilityID: "compose.to")
+ }
+
+ ComposeHeaderRow(title: "Cc") {
+ RecipientTokenInput(text: $model.composeDraft.cc, placeholder: "Optional")
+ }
+
+ ComposeHeaderRow(title: "From") {
+ Menu {
+ Button(model.currentUser.email) {
+ model.composeDraft.from = model.currentUser.email
+ }
+ } label: {
+ HStack {
+ Text(model.composeDraft.from.isEmpty ? model.currentUser.email : model.composeDraft.from)
+ Spacer(minLength: 0)
+ Image(systemName: "chevron.up.chevron.down")
+ .foregroundStyle(.secondary)
+ }
+ .font(.body)
+ .padding(.vertical, 4)
+ }
+ .buttonStyle(.plain)
+ }
+
+ ComposeHeaderRow(title: "Subject") {
+ TextField("What's this about?", text: $model.composeDraft.subject)
+ .textFieldStyle(.plain)
+ .accessibilityIdentifier("compose.subject")
+ }
+
+ VStack(alignment: .leading, spacing: 10) {
+ TextEditor(text: $model.composeDraft.body)
+ .font(.system(size: 15.5))
+ .scrollContentBackground(.hidden)
+ .frame(minHeight: 320)
+ .accessibilityIdentifier("compose.body")
+
+ ComposeFormatToolbar()
+ }
+ .padding(14)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous))
+ }
+ .padding(16)
}
}
+ .background(platformBackground.ignoresSafeArea())
.accessibilityIdentifier("compose.view")
+ .onChange(of: model.composeDraft) {
+ model.queueDraftAutosave()
+ }
+ }
+}
+
+private struct ComposeHeaderRow: View {
+ let title: String
+ let content: Content
+
+ init(title: String, @ViewBuilder content: () -> Content) {
+ self.title = title
+ self.content = content()
}
- private var composeScene: some View {
- NavigationStack {
- ZStack {
- MailCanvasBackground(primary: MailTheme.accent, secondary: MailTheme.sunrise)
- .ignoresSafeArea()
+ var body: some View {
+ HStack(alignment: .top, spacing: 14) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 56, alignment: .leading)
- ScrollView {
- VStack(alignment: .leading, spacing: 18) {
- VStack(alignment: .leading, spacing: 8) {
- Text("New Message")
- .font(.system(.largeTitle, design: .rounded, weight: .bold))
- Text("Keep the controls light and let the conversation do the work.")
- .font(.subheadline)
- .foregroundStyle(.secondary)
+ content
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(14)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous))
+ }
+}
+
+private struct RecipientTokenInput: View {
+ @Binding var text: String
+ let placeholder: String
+ var accessibilityID: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if !tokens.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(tokens, id: \.self) { token in
+ Text(token)
+ .font(.caption.weight(.semibold))
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(SIO.tint.opacity(0.12), in: Capsule())
+ .foregroundStyle(SIO.tint)
}
-
- ComposeFieldCard(title: "To") {
- toField
- }
-
- ComposeFieldCard(title: "Subject") {
- TextField("What's this about?", text: $model.composeDraft.subject)
- .textFieldStyle(.plain)
- .disabled(model.isSending)
- .accessibilityIdentifier("compose.subject")
- }
-
- ComposeFieldCard(title: "Message") {
- TextEditor(text: $model.composeDraft.body)
- .scrollContentBackground(.hidden)
- .frame(minHeight: 240)
- .disabled(model.isSending)
- .accessibilityIdentifier("compose.body")
- }
-
- Spacer(minLength: 0)
}
- .padding(usesCompactComposeLayout ? 20 : 24)
- .frame(maxWidth: 720, alignment: .topLeading)
- .frame(maxWidth: .infinity, alignment: .top)
}
}
- .navigationTitle("Compose")
- .composeNavigationTitleDisplayMode(isCompact: usesCompactComposeLayout)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- dismiss()
- }
- .disabled(model.isSending)
- .accessibilityIdentifier("compose.cancel")
- }
- ToolbarItem(placement: .confirmationAction) {
- Button(model.isSending ? "Sending…" : "Send") {
- Task {
- _ = await model.sendCurrentDraft()
- }
- }
- .disabled(model.isSending || model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty)
- .accessibilityIdentifier("compose.send")
- }
- }
+ TextField(placeholder, text: $text)
+ .textFieldStyle(.plain)
+ .recipientAutocapitalization()
+ .accessibilityIdentifier(accessibilityID ?? "")
}
}
- @ViewBuilder
- private var toField: some View {
- #if os(iOS)
- TextField("name@example.com", text: $model.composeDraft.to)
- .textFieldStyle(.plain)
- .textContentType(.emailAddress)
- .keyboardType(.emailAddress)
- .textInputAutocapitalization(.never)
- .disabled(model.isSending)
- .accessibilityIdentifier("compose.to")
- #else
- TextField("name@example.com", text: $model.composeDraft.to)
- .textFieldStyle(.plain)
- .textContentType(.emailAddress)
- .disabled(model.isSending)
- .accessibilityIdentifier("compose.to")
- #endif
- }
-
- private var usesCompactComposeLayout: Bool {
- #if os(iOS)
- UIDevice.current.userInterfaceIdiom == .phone
- #else
- false
- #endif
+ private var tokens: [String] {
+ text.split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
}
}
-private extension View {
- @ViewBuilder
- func composeNavigationTitleDisplayMode(isCompact: Bool) -> some View {
- #if os(iOS)
- navigationBarTitleDisplayMode(isCompact ? .inline : .automatic)
- #else
- self
- #endif
+private struct ComposeFormatToolbar: View {
+ var body: some View {
+ HStack(spacing: 10) {
+ formatButton("Aa")
+ formatButton("B")
+ formatButton("I")
+ formatButton("U")
+ iconButton("paperclip")
+ iconButton("camera")
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func formatButton(_ title: String) -> some View {
+ Button(title) {}
+ .buttonStyle(SecondaryActionStyle())
+ }
+
+ private func iconButton(_ systemImage: String) -> some View {
+ Button {
+ } label: {
+ Image(systemName: systemImage)
+ }
+ .buttonStyle(SecondaryActionStyle())
}
}
-private struct ComposeFieldCard: View {
+struct SearchView: View {
+ @Bindable var model: AppViewModel
+ @State private var attachmentsOnly = false
+ @State private var last30DaysOnly = false
+ @State private var selectedLane: Lane?
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ MailSearchField(
+ text: Binding(
+ get: { model.searchText },
+ set: { model.setSearchText($0) }
+ ),
+ placeholder: "Search messages",
+ showsCancel: true,
+ onLongPress: {
+ model.isCommandPalettePresented = true
+ }
+ )
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ FilterChip(title: "From:", subtitle: model.topSearchResult?.participants.first?.name ?? "Anyone")
+ Button {
+ attachmentsOnly.toggle()
+ } label: {
+ FilterChip(title: "Has attachment", isSelected: attachmentsOnly)
+ }
+ .buttonStyle(.plain)
+
+ Button {
+ last30DaysOnly.toggle()
+ } label: {
+ FilterChip(title: "Last 30 days", isSelected: last30DaysOnly)
+ }
+ .buttonStyle(.plain)
+
+ Menu {
+ Button("All") { selectedLane = nil }
+ ForEach(Lane.allCases) { lane in
+ Button(lane.label) { selectedLane = lane }
+ }
+ } label: {
+ FilterChip(title: "Lane:", subtitle: selectedLane?.label ?? "All")
+ }
+ }
+ }
+
+ if filteredResults.isEmpty {
+ ContentUnavailableView(
+ "Search mail",
+ systemImage: "magnifyingglass",
+ description: Text("Use search to find senders, subjects, summaries, and attachments.")
+ )
+ .frame(maxWidth: .infinity)
+ .padding(.top, 40)
+ } else {
+ if let topHit = filteredResults.first {
+ SearchResultSection(title: "Top hit") {
+ resultButton(for: topHit)
+ }
+ }
+
+ let remaining = Array(filteredResults.dropFirst())
+ if !remaining.isEmpty {
+ SearchResultSection(title: "Messages (\(remaining.count))") {
+ ForEach(remaining) { thread in
+ resultButton(for: thread)
+ }
+ }
+ }
+ }
+ }
+ .padding(16)
+ }
+ .background(platformBackground.ignoresSafeArea())
+ .navigationTitle("Search")
+ }
+
+ private var filteredResults: [MailThread] {
+ model.searchResults.filter { thread in
+ let matchesAttachments = !attachmentsOnly || thread.hasAttachments
+ let matchesDate = !last30DaysOnly || thread.lastUpdated > .now.addingTimeInterval(-30 * 24 * 60 * 60)
+ let matchesLane = selectedLane == nil || thread.lane == selectedLane
+ return matchesAttachments && matchesDate && matchesLane
+ }
+ }
+
+ private func resultButton(for thread: MailThread) -> some View {
+ Button {
+ model.selectMailbox(thread.mailbox)
+ model.openThread(withID: thread.id)
+ } label: {
+ ThreadRow(thread: thread, density: .comfortable, isSelected: false)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+private struct SearchResultSection: View {
let title: String
let content: Content
@@ -1015,138 +1406,476 @@ private struct ComposeFieldCard: View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
- content
+
+ VStack(spacing: 10) {
+ content
+ }
}
- .padding(18)
- .mailPanelBackground(in: RoundedRectangle(cornerRadius: 24, style: .continuous))
}
}
-private struct MailCanvasBackground: View {
- let primary: Color
- let secondary: Color
+private struct MailSearchField: View {
+ @Binding var text: String
+ let placeholder: String
+ var showsCancel = false
+ var onLongPress: (() -> Void)?
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+
+ TextField(placeholder, text: $text)
+ .textFieldStyle(.plain)
+
+ if !text.isEmpty {
+ Button {
+ text = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+
+ if showsCancel && !text.isEmpty {
+ Button("Cancel") {
+ text = ""
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .onLongPressGesture {
+ onLongPress?()
+ }
+ }
+}
+
+private struct FilterChip: View {
+ let title: String
+ var subtitle: String? = nil
+ var isSelected = false
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Text(title)
+ if let subtitle {
+ Text(subtitle)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .font(.caption.weight(.semibold))
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(isSelected ? SIO.tint.opacity(0.12) : Color.secondary.opacity(0.08), in: Capsule())
+ .foregroundStyle(isSelected ? SIO.tint : Color.primary)
+ }
+}
+
+private struct ActivityView: View {
+ @Bindable var model: AppViewModel
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Activity")
+ .font(.largeTitle.weight(.bold))
+
+ HStack(spacing: 12) {
+ activityCard(title: "Unread", value: model.totalUnreadCount, tint: SIO.tint)
+ activityCard(title: "Snoozed", value: model.threadCount(in: .snoozed), tint: .orange)
+ }
+
+ if let summaryThread = model.threads.first(where: { $0.summary != nil }) {
+ AISummaryCard(count: summaryThread.messageCount, bullets: summaryThread.summary ?? [])
+ }
+
+ AppearanceSettingsCard()
+ }
+ .padding(16)
+ }
+ .background(platformBackground.ignoresSafeArea())
+ .navigationTitle("Activity")
+ }
+
+ private func activityCard(title: String, value: Int, tint: Color) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(value, format: .number)
+ .font(.title2.weight(.bold))
+ .foregroundStyle(tint)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(16)
+ .sioCardBackground(tint: tint)
+ }
+}
+
+struct CommandPaletteView: View {
+ @Bindable var model: AppViewModel
+ @State private var selectedIndex = 0
var body: some View {
ZStack {
- LinearGradient(
- colors: [
- platformBackgroundColor,
- primary.opacity(0.10),
- secondary.opacity(0.12)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
+ Rectangle()
+ .fill(Color.black.opacity(0.32))
+ .ignoresSafeArea()
+ .onTapGesture {
+ model.isCommandPalettePresented = false
+ }
+
+ VStack(alignment: .leading, spacing: 18) {
+ ForEach(Array(sectionedItems.enumerated()), id: \.offset) { _, section in
+ VStack(alignment: .leading, spacing: 8) {
+ Text(section.title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ VStack(spacing: 6) {
+ ForEach(Array(section.items.enumerated()), id: \.element.id) { _, item in
+ paletteRow(for: item)
+ }
+ }
+ }
+ }
+ }
+ .padding(20)
+ .frame(maxWidth: 640)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: SIO.cardRadius, style: .continuous)
+ .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
+ .sioGlassChrome()
+ .focusable()
+ .onAppear {
+ selectedIndex = 0
+ }
+ .onKeyPress(.upArrow) {
+ selectedIndex = max(0, selectedIndex - 1)
+ return .handled
+ }
+ .onKeyPress(.downArrow) {
+ selectedIndex = min(items.count - 1, selectedIndex + 1)
+ return .handled
+ }
+ .onKeyPress(.escape) {
+ model.isCommandPalettePresented = false
+ return .handled
+ }
+ .onKeyPress(.return) {
+ guard items.indices.contains(selectedIndex) else { return .ignored }
+ items[selectedIndex].action()
+ model.isCommandPalettePresented = false
+ return .handled
+ }
+ }
+ .accessibilityIdentifier("commandPalette")
+ }
- Circle()
- .fill(primary.opacity(0.22))
- .frame(width: 360, height: 360)
- .blur(radius: 90)
- .offset(x: -160, y: -240)
+ private func paletteRow(for item: CommandPaletteItem) -> some View {
+ let index = items.firstIndex(where: { $0.id == item.id }) ?? 0
- Circle()
- .fill(secondary.opacity(0.20))
- .frame(width: 300, height: 300)
- .blur(radius: 90)
- .offset(x: 210, y: 260)
+ return Button {
+ selectedIndex = index
+ item.action()
+ model.isCommandPalettePresented = false
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: item.systemImage)
+ .frame(width: 18)
+ Text(item.title)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ KeyboardHint(title: item.keyHint)
+ }
+ .font(.subheadline.weight(.semibold))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 11)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(index == selectedIndex ? SIO.tint.opacity(0.12) : Color.clear)
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("commandPalette.action.\(item.id)")
+ }
- Circle()
- .fill(Color.white.opacity(0.10))
- .frame(width: 220, height: 220)
- .blur(radius: 70)
- .offset(x: 180, y: -220)
+ private var items: [CommandPaletteItem] {
+ let selectedThreadID = model.selectedThreadID
+
+ return [
+ CommandPaletteItem(id: "reply", section: "Actions", title: "Reply", systemImage: "arrowshape.turn.up.left", keyHint: "R") {
+ if let selectedThreadID {
+ model.startReply(to: selectedThreadID)
+ }
+ },
+ CommandPaletteItem(id: "replyAll", section: "Actions", title: "Reply all", systemImage: "arrowshape.turn.up.left.2", keyHint: "⇧R") {
+ if let selectedThreadID {
+ model.startReply(to: selectedThreadID, replyAll: true)
+ }
+ },
+ CommandPaletteItem(id: "forward", section: "Actions", title: "Forward", systemImage: "arrowshape.turn.up.right", keyHint: "F") {
+ if let selectedThreadID {
+ model.startReply(to: selectedThreadID, forward: true)
+ }
+ },
+ CommandPaletteItem(id: "archive", section: "Actions", title: "Archive", systemImage: "archivebox", keyHint: "E") {
+ if let selectedThreadID {
+ model.moveThread(withID: selectedThreadID, to: .archive)
+ }
+ },
+ CommandPaletteItem(id: "jumpPeople", section: "Jump to", title: "Inbox - People", systemImage: "person.2", keyHint: "G I") {
+ model.selectMailbox(.inbox)
+ model.setLaneFilter(.people)
+ },
+ CommandPaletteItem(id: "jumpStarred", section: "Jump to", title: "Starred", systemImage: "star", keyHint: "G S") {
+ model.selectMailbox(.starred)
+ },
+ CommandPaletteItem(id: "jumpSent", section: "Jump to", title: "Sent", systemImage: "paperplane", keyHint: "G T") {
+ model.selectMailbox(.sent)
+ },
+ CommandPaletteItem(id: "snoozeTomorrow", section: "Snooze", title: "Tomorrow morning", systemImage: "clock.badge", keyHint: "H 1") {
+ if let selectedThreadID {
+ model.snoozeThread(withID: selectedThreadID)
+ }
+ },
+ CommandPaletteItem(id: "snoozeEvening", section: "Snooze", title: "This evening", systemImage: "moon.stars", keyHint: "H 2") {
+ if let selectedThreadID {
+ model.snoozeThread(withID: selectedThreadID)
+ }
+ },
+ CommandPaletteItem(id: "snoozeNextWeek", section: "Snooze", title: "Next week", systemImage: "calendar", keyHint: "H 3") {
+ if let selectedThreadID {
+ model.snoozeThread(withID: selectedThreadID)
+ }
+ }
+ ]
+ }
+
+ private var sectionedItems: [CommandPaletteSection] {
+ ["Actions", "Jump to", "Snooze"].compactMap { title in
+ let matchingItems = items.filter { $0.section == title }
+ guard !matchingItems.isEmpty else { return nil }
+ return CommandPaletteSection(title: title, items: matchingItems)
}
}
}
-private struct AdaptiveGlassGroup: View {
- let spacing: CGFloat?
- let content: Content
-
- init(spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
- self.spacing = spacing
- self.content = content()
- }
+private struct CommandPaletteShortcut: View {
+ @Bindable var model: AppViewModel
var body: some View {
- if #available(iOS 26.0, macOS 26.0, *) {
- GlassEffectContainer(spacing: spacing) {
- content
+ Button("Command Palette") {
+ model.isCommandPalettePresented = true
+ }
+ .keyboardShortcut("k", modifiers: .command)
+ .opacity(0.01)
+ .frame(width: 1, height: 1)
+ .accessibilityHidden(true)
+ }
+}
+
+private struct CommandPaletteItem: Identifiable {
+ let id: String
+ let section: String
+ let title: String
+ let systemImage: String
+ let keyHint: String
+ let action: () -> Void
+}
+
+private struct CommandPaletteSection {
+ let title: String
+ let items: [CommandPaletteItem]
+}
+
+struct AppearanceSettingsView: View {
+ var body: some View {
+ Form {
+ AppearanceSettingsContent()
+ }
+ .formStyle(.grouped)
+ }
+}
+
+private struct AppearanceSettingsCard: View {
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Appearance")
+ .font(.headline)
+ AppearanceSettingsContent()
+ }
+ .padding(16)
+ .sioCardBackground(tint: SIO.tint)
+ }
+}
+
+private struct AppearanceSettingsContent: View {
+ @AppStorage("sio.density") private var densityRawValue = ""
+ @AppStorage("sio.theme") private var themeRawValue = ThemePreference.system.rawValue
+ @AppStorage("sio.readingPane") private var readingPaneRawValue = ReadingPanePreference.right.rawValue
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ preferenceRow(title: "Theme") {
+ Picker("Theme", selection: $themeRawValue) {
+ ForEach(ThemePreference.allCases) { preference in
+ Text(preference.label).tag(preference.rawValue)
+ }
+ }
+ .pickerStyle(.segmented)
}
- } else {
- content
+
+ preferenceRow(title: "Density") {
+ Picker("Density", selection: densityBinding) {
+ ForEach(ThreadRowDensity.allCases) { density in
+ Text(density.rawValue.capitalized).tag(density.rawValue)
+ }
+ }
+ .pickerStyle(.segmented)
+ }
+
+ preferenceRow(title: "Reading Pane") {
+ Picker("Reading Pane", selection: $readingPaneRawValue) {
+ ForEach(ReadingPanePreference.allCases) { preference in
+ Text(preference.label).tag(preference.rawValue)
+ }
+ }
+ .pickerStyle(.segmented)
+ }
+ }
+ }
+
+ private var densityBinding: Binding {
+ Binding(
+ get: {
+ if let stored = ThreadRowDensity(rawValue: densityRawValue) {
+ return stored.rawValue
+ }
+
+ #if os(macOS)
+ return ThreadRowDensity.cozy.rawValue
+ #else
+ return ThreadRowDensity.comfortable.rawValue
+ #endif
+ },
+ set: { densityRawValue = $0 }
+ )
+ }
+
+ private func preferenceRow(title: String, @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ content()
}
}
}
+#if os(macOS)
+private let platformBackground = Color(nsColor: .windowBackgroundColor)
+#else
+private let platformBackground = Color(uiColor: .systemBackground)
+#endif
+
private extension View {
@ViewBuilder
- func socialGlass(
- in shape: S,
- tint: Color? = nil,
- interactive: Bool = false
- ) -> some View {
- if #available(iOS 26.0, macOS 26.0, *) {
- glassEffect(
- Glass.regular.tint(tint).interactive(interactive),
- in: shape
- )
- } else {
- background(.ultraThinMaterial, in: shape)
- .overlay(
- shape.stroke(Color.white.opacity(0.16), lineWidth: 1)
- )
- }
- }
-
- func mailPanelBackground(
- in shape: S,
- highlight: Color = Color.white.opacity(0.10)
- ) -> some View {
- background(.regularMaterial, in: shape)
- .overlay(
- shape.stroke(highlight, lineWidth: 1)
- )
- }
-
- func stableControlPill(tint: Color?) -> some View {
- background {
- Capsule()
- .fill(.ultraThinMaterial)
- .overlay(
- Capsule()
- .fill(tint ?? .clear)
- )
- .overlay(
- Capsule()
- .stroke(Color.white.opacity(0.16), lineWidth: 1)
- )
- }
- }
-
- @ViewBuilder
- func mailNavigationChrome() -> some View {
- #if os(iOS)
- toolbarBackground(.hidden, for: .navigationBar)
- #else
- self
- #endif
- }
-
- @ViewBuilder
- func mailInlineNavigationTitle() -> some View {
+ func compactInboxNavigation(searchText: Binding) -> some View {
#if os(iOS)
navigationBarTitleDisplayMode(.inline)
+ .searchable(text: searchText, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search mail")
#else
self
#endif
}
+
+ @ViewBuilder
+ func recipientAutocapitalization() -> some View {
+ #if os(iOS)
+ textInputAutocapitalization(.never)
+ #else
+ self
+ #endif
+ }
+
+ @ViewBuilder
+ func readingPaneNavigationStyle(_ preference: ReadingPanePreference) -> some View {
+ if preference == .bottom {
+ navigationSplitViewStyle(.prominentDetail)
+ } else {
+ navigationSplitViewStyle(.balanced)
+ }
+ }
}
-private var platformBackgroundColor: Color {
- #if os(macOS)
- Color(nsColor: .windowBackgroundColor)
- #else
- Color(uiColor: .systemBackground)
- #endif
+#Preview("Thread List Light") {
+ NavigationStack {
+ ThreadListView(model: previewModel(), layoutMode: .regular)
+ }
+}
+
+#Preview("Thread List Dark") {
+ NavigationStack {
+ ThreadListView(model: previewModel(), layoutMode: .regular)
+ .preferredColorScheme(.dark)
+ }
+}
+
+#Preview("Thread List XL") {
+ NavigationStack {
+ ThreadListView(model: previewModel(), layoutMode: .regular)
+ .dynamicTypeSize(.xxxLarge)
+ }
+}
+
+#Preview("Thread Reading") {
+ NavigationStack {
+ ThreadReadingView(model: previewSelectedModel())
+ }
+}
+
+#Preview("Compose") {
+ ComposeView(model: previewModel())
+}
+
+#Preview("Sidebar") {
+ SidebarView(model: previewModel())
+ .frame(width: 320)
+}
+
+#Preview("Command Palette") {
+ ZStack {
+ platformBackground
+ CommandPaletteView(model: previewSelectedModel())
+ }
+}
+
+@MainActor
+private func previewModel() -> AppViewModel {
+ let model = AppViewModel(service: MockMailService(), controlService: StubPreviewControlService())
+ model.threads = MockMailService().previewThreads()
+ model.selectMailbox(.inbox)
+ return model
+}
+
+@MainActor
+private func previewSelectedModel() -> AppViewModel {
+ let model = previewModel()
+ if let firstThread = model.threads.first {
+ model.openThread(withID: firstThread.id)
+ }
+ return model
+}
+
+private struct StubPreviewControlService: AppControlServicing {
+ func commands() -> AsyncStream {
+ AsyncStream { continuation in
+ continuation.finish()
+ }
+ }
}
diff --git a/swift/WatchApp/Design/SIOTokens.swift b/swift/WatchApp/Design/SIOTokens.swift
new file mode 100644
index 0000000..93a18a7
--- /dev/null
+++ b/swift/WatchApp/Design/SIOTokens.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+
+enum SIO {
+ static let tint = Color(red: 0.184, green: 0.420, blue: 1.0)
+ static let laneFeed = Color(red: 0.184, green: 0.420, blue: 1.0)
+ static let lanePaper = Color(red: 1.0, green: 0.624, blue: 0.039)
+ static let lanePeople = Color(red: 0.188, green: 0.819, blue: 0.345)
+}
+
+enum Lane: String, CaseIterable, Codable, Identifiable {
+ case feed
+ case paper
+ case people
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .feed: "Feed"
+ case .paper: "Paper"
+ case .people: "People"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .feed: SIO.laneFeed
+ case .paper: SIO.lanePaper
+ case .people: SIO.lanePeople
+ }
+ }
+}
diff --git a/swift/WatchApp/SocialIOWatchApp.swift b/swift/WatchApp/SocialIOWatchApp.swift
new file mode 100644
index 0000000..c9a93dc
--- /dev/null
+++ b/swift/WatchApp/SocialIOWatchApp.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+
+@main
+struct SocialIOWatchApp: App {
+ @State private var store = WatchInboxStore()
+
+ var body: some Scene {
+ WindowGroup {
+ NavigationStack {
+ WatchInboxView(store: store)
+ }
+ }
+ }
+}
diff --git a/swift/WatchApp/WatchInboxView.swift b/swift/WatchApp/WatchInboxView.swift
new file mode 100644
index 0000000..9a31686
--- /dev/null
+++ b/swift/WatchApp/WatchInboxView.swift
@@ -0,0 +1,201 @@
+import Foundation
+import Observation
+import SwiftUI
+
+@MainActor
+@Observable
+final class WatchInboxStore {
+ var threads: [MailThread] = []
+ var selectedThreadID: MailThread.ID?
+
+ private let service = MockMailService()
+
+ var visibleThreads: [MailThread] {
+ Array(
+ threads
+ .filter { $0.mailbox == .inbox }
+ .sorted { $0.lastUpdated > $1.lastUpdated }
+ .prefix(4)
+ )
+ }
+
+ var selectedThread: MailThread? {
+ visibleThreads.first(where: { $0.id == selectedThreadID }) ?? visibleThreads.first
+ }
+
+ func load() async {
+ guard threads.isEmpty else { return }
+ threads = (try? await service.loadThreads()) ?? service.previewThreads()
+ selectedThreadID = visibleThreads.first?.id
+ }
+
+ func select(_ thread: MailThread) {
+ selectedThreadID = thread.id
+ }
+
+ func archive(_ thread: MailThread) {
+ guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ threads[index].mailbox = .archive
+ if selectedThreadID == thread.id {
+ selectedThreadID = visibleThreads.first?.id
+ }
+ }
+}
+
+struct WatchInboxView: View {
+ @Bindable var store: WatchInboxStore
+
+ var body: some View {
+ List(store.visibleThreads) { thread in
+ NavigationLink {
+ WatchThreadView(thread: thread, store: store)
+ } label: {
+ WatchInboxRow(
+ thread: thread,
+ isHighlighted: thread.id == (store.selectedThread?.id ?? store.visibleThreads.first?.id)
+ )
+ }
+ .buttonStyle(.plain)
+ .simultaneousGesture(TapGesture().onEnded {
+ store.select(thread)
+ })
+ }
+ .listStyle(.carousel)
+ .navigationTitle("Inbox")
+ .task {
+ await store.load()
+ }
+ }
+}
+
+private struct WatchInboxRow: View {
+ let thread: MailThread
+ let isHighlighted: Bool
+
+ var body: some View {
+ HStack(spacing: 10) {
+ AvatarCircle(name: senderName, color: thread.lane.color)
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text(senderName)
+ .font(.headline)
+ .lineLimit(1)
+ if thread.isUnread {
+ Circle()
+ .fill(SIO.tint)
+ .frame(width: 6, height: 6)
+ }
+ }
+
+ Text(thread.subject)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.vertical, 4)
+ .padding(.horizontal, 6)
+ .background(isHighlighted ? SIO.tint.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+
+ private var senderName: String {
+ thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown"
+ }
+}
+
+struct WatchThreadView: View {
+ let thread: MailThread
+ @Bindable var store: WatchInboxStore
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 10) {
+ AvatarCircle(name: senderName, color: thread.lane.color)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(senderName)
+ .font(.headline)
+ Text(thread.lane.label)
+ .font(.caption2)
+ .foregroundStyle(thread.lane.color)
+ }
+ }
+
+ Text(thread.subject)
+ .font(.headline)
+ .lineLimit(2)
+
+ Text(thread.previewText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(4)
+
+ Button("Reply") {
+ openURL(URL(string: "socialio://compose?to=\(replyTarget)&subject=Re:%20\(thread.subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? thread.subject)")!)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(SIO.tint)
+
+ Button {
+ store.archive(thread)
+ } label: {
+ Image(systemName: "archivebox")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding(.horizontal, 6)
+ }
+ }
+
+ private var senderName: String {
+ thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown"
+ }
+
+ private var replyTarget: String {
+ (thread.latestMessage?.sender.email ?? thread.participants.first?.email ?? "hello@social.io")
+ .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "hello@social.io"
+ }
+}
+
+private struct AvatarCircle: View {
+ let name: String
+ let color: Color
+
+ var body: some View {
+ Text(initials)
+ .font(.system(size: 9, weight: .semibold, design: .rounded))
+ .foregroundStyle(color)
+ .frame(width: 22, height: 22)
+ .background(color.opacity(0.14), in: Circle())
+ }
+
+ private var initials: String {
+ String(name.split(separator: " ").prefix(2).compactMap { $0.first })
+ .uppercased()
+ }
+}
+
+#Preview("Watch Inbox") {
+ NavigationStack {
+ WatchInboxView(store: previewStore())
+ }
+}
+
+#Preview("Watch Thread") {
+ NavigationStack {
+ if let thread = previewStore().visibleThreads.first {
+ WatchThreadView(thread: thread, store: previewStore())
+ }
+ }
+}
+
+@MainActor
+private func previewStore() -> WatchInboxStore {
+ let store = WatchInboxStore()
+ store.threads = MockMailService().previewThreads()
+ store.selectedThreadID = store.visibleThreads.first?.id
+ return store
+}
diff --git a/swift/WatchApp/Widgets/SocialIOWatchWidgets-Info.plist b/swift/WatchApp/Widgets/SocialIOWatchWidgets-Info.plist
new file mode 100644
index 0000000..d4d8495
--- /dev/null
+++ b/swift/WatchApp/Widgets/SocialIOWatchWidgets-Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ MinimumOSVersion
+ $(WATCHOS_DEPLOYMENT_TARGET)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/swift/WatchApp/Widgets/SocialIOWatchWidgets.swift b/swift/WatchApp/Widgets/SocialIOWatchWidgets.swift
new file mode 100644
index 0000000..376e04d
--- /dev/null
+++ b/swift/WatchApp/Widgets/SocialIOWatchWidgets.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+import WidgetKit
+
+struct WatchUnreadEntry: TimelineEntry {
+ let date: Date
+ let unreadCount: Int
+}
+
+struct WatchUnreadProvider: TimelineProvider {
+ func placeholder(in context: Context) -> WatchUnreadEntry {
+ WatchUnreadEntry(date: .now, unreadCount: 3)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (WatchUnreadEntry) -> Void) {
+ completion(makeEntry())
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ completion(Timeline(entries: [makeEntry()], policy: .after(.now.addingTimeInterval(900))))
+ }
+
+ private func makeEntry() -> WatchUnreadEntry {
+ let unreadCount = MockMailService().previewThreads().filter { $0.mailbox == .inbox && $0.isUnread }.count
+ return WatchUnreadEntry(date: .now, unreadCount: unreadCount)
+ }
+}
+
+struct WatchUnreadComplication: Widget {
+ let kind = "SocialIOWatchUnreadComplication"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: WatchUnreadProvider()) { entry in
+ WatchUnreadComplicationView(entry: entry)
+ }
+ .configurationDisplayName("social.io Inbox")
+ .description("Unread social.io mail at a glance.")
+ .supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryCorner])
+ }
+}
+
+private struct WatchUnreadComplicationView: View {
+ let entry: WatchUnreadEntry
+
+ var body: some View {
+ switch widgetFamily {
+ case .accessoryCircular:
+ ZStack {
+ AccessoryWidgetBackground()
+ VStack(spacing: 2) {
+ Image(systemName: "envelope.fill")
+ Text(entry.unreadCount, format: .number)
+ .font(.caption2.weight(.bold))
+ }
+ }
+ .widgetURL(URL(string: "socialio://mailbox/inbox"))
+
+ case .accessoryCorner:
+ Text("\(entry.unreadCount)")
+ .font(.caption.weight(.bold))
+ .widgetURL(URL(string: "socialio://mailbox/inbox"))
+
+ default:
+ VStack(alignment: .leading, spacing: 4) {
+ Text("social.io")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text("\(entry.unreadCount) unread")
+ .font(.caption.weight(.semibold))
+ }
+ .widgetURL(URL(string: "socialio://mailbox/inbox"))
+ }
+ }
+
+ @Environment(\.widgetFamily) private var widgetFamily
+}
+
+@main
+struct SocialIOWatchWidgets: WidgetBundle {
+ var body: some Widget {
+ WatchUnreadComplication()
+ }
+}