Compare commits
12 Commits
1.0.1
...
8d89137037
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d89137037 | ||
|
|
74f488f242 | ||
| 92cc6e779a | |||
|
|
92eb626cb9 | ||
|
|
882d2767f9 | ||
| c0b1e95204 | |||
| 914c98852b | |||
|
|
21cc89f0af | ||
|
|
8453819f50 | ||
| 5304ab1d6d | |||
|
|
69469fa2ad | ||
|
|
142d4a3fc3 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,5 +4,5 @@ node_modules/
|
|||||||
*.map
|
*.map
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.sourcemaps
|
.sourcemaps
|
||||||
dist/
|
/dist/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
@@ -5,9 +5,13 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"allowsBackForwardNavigationGestures": true
|
"allowsBackForwardNavigationGestures": true
|
||||||
},
|
},
|
||||||
|
"server": {
|
||||||
|
"url": "http://sam.local:5173",
|
||||||
|
"cleartext": true
|
||||||
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"SplashScreen": {
|
"SplashScreen": {
|
||||||
"launchAutoHide": true
|
"launchAutoHide": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,9 +14,35 @@
|
|||||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
|
5ADE813A2FAD0ACB008DDDE2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADE81392FAD0ACB008DDDE2 /* NotificationService.swift */; };
|
||||||
|
5ADE813E2FAD0ACB008DDDE2 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
5ADE813C2FAD0ACB008DDDE2 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 5ADE81362FAD0ACA008DDDE2;
|
||||||
|
remoteInfo = NotificationService;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
5ADE813F2FAD0ACB008DDDE2 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
5ADE813E2FAD0ACB008DDDE2 /* NotificationService.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
22352DD22F74F93C0052EF07 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
22352DD22F74F93C0052EF07 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||||
@@ -28,6 +54,9 @@
|
|||||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
|
5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
5ADE81392FAD0ACB008DDDE2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
|
5ADE813B2FAD0ACB008DDDE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
@@ -42,6 +71,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
5ADE81342FAD0ACA008DDDE2 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -57,6 +93,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
504EC3061FED79650016851F /* App */,
|
504EC3061FED79650016851F /* App */,
|
||||||
|
5ADE81382FAD0ACB008DDDE2 /* NotificationService */,
|
||||||
504EC3051FED79650016851F /* Products */,
|
504EC3051FED79650016851F /* Products */,
|
||||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||||
@@ -67,6 +104,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
504EC3041FED79650016851F /* App.app */,
|
504EC3041FED79650016851F /* App.app */,
|
||||||
|
5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -87,6 +125,15 @@
|
|||||||
path = App;
|
path = App;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
5ADE81382FAD0ACB008DDDE2 /* NotificationService */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5ADE81392FAD0ACB008DDDE2 /* NotificationService.swift */,
|
||||||
|
5ADE813B2FAD0ACB008DDDE2 /* Info.plist */,
|
||||||
|
);
|
||||||
|
path = NotificationService;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
7F8756D8B27F46E3366F6CEA /* Pods */ = {
|
7F8756D8B27F46E3366F6CEA /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -109,23 +156,42 @@
|
|||||||
504EC3021FED79650016851F /* Resources */,
|
504EC3021FED79650016851F /* Resources */,
|
||||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
|
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
|
||||||
E3424294FACA667401D6F46C /* [CP] Copy Pods Resources */,
|
E3424294FACA667401D6F46C /* [CP] Copy Pods Resources */,
|
||||||
|
5ADE813F2FAD0ACB008DDDE2 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
5ADE813D2FAD0ACB008DDDE2 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = App;
|
name = App;
|
||||||
productName = App;
|
productName = App;
|
||||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
5ADE81362FAD0ACA008DDDE2 /* NotificationService */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 5ADE81422FAD0ACB008DDDE2 /* Build configuration list for PBXNativeTarget "NotificationService" */;
|
||||||
|
buildPhases = (
|
||||||
|
5ADE81332FAD0ACA008DDDE2 /* Sources */,
|
||||||
|
5ADE81342FAD0ACA008DDDE2 /* Frameworks */,
|
||||||
|
5ADE81352FAD0ACA008DDDE2 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = NotificationService;
|
||||||
|
productName = NotificationService;
|
||||||
|
productReference = 5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
504EC2FC1FED79650016851F /* Project object */ = {
|
504EC2FC1FED79650016851F /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastSwiftUpdateCheck = 0920;
|
LastSwiftUpdateCheck = 1500;
|
||||||
LastUpgradeCheck = 0920;
|
LastUpgradeCheck = 0920;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
504EC3031FED79650016851F = {
|
504EC3031FED79650016851F = {
|
||||||
@@ -133,6 +199,10 @@
|
|||||||
LastSwiftMigration = 1100;
|
LastSwiftMigration = 1100;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
|
5ADE81362FAD0ACA008DDDE2 = {
|
||||||
|
CreatedOnToolsVersion = 15.0;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||||
@@ -149,6 +219,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
504EC3031FED79650016851F /* App */,
|
504EC3031FED79650016851F /* App */,
|
||||||
|
5ADE81362FAD0ACA008DDDE2 /* NotificationService */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -167,6 +238,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
5ADE81352FAD0ACA008DDDE2 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
@@ -229,8 +307,24 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
5ADE81332FAD0ACA008DDDE2 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
5ADE813A2FAD0ACB008DDDE2 /* NotificationService.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
5ADE813D2FAD0ACB008DDDE2 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 5ADE81362FAD0ACA008DDDE2 /* NotificationService */;
|
||||||
|
targetProxy = 5ADE813C2FAD0ACB008DDDE2 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||||
isa = PBXVariantGroup;
|
isa = PBXVariantGroup;
|
||||||
@@ -362,6 +456,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
|
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -370,7 +465,7 @@
|
|||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.6;
|
MARKETING_VERSION = 1.0.8;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
|
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -384,6 +479,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
|
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -392,7 +488,7 @@
|
|||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 1.0.6;
|
MARKETING_VERSION = 1.0.8;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
|
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -401,6 +497,72 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
5ADE81402FAD0ACB008DDDE2 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 53DK57C7ZF;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationService;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.nse;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
5ADE81412FAD0ACB008DDDE2 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 53DK57C7ZF;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = NotificationService;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.nse;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -422,6 +584,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
5ADE81422FAD0ACB008DDDE2 /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
5ADE81402FAD0ACB008DDDE2 /* Debug */,
|
||||||
|
5ADE81412FAD0ACB008DDDE2 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.usernotifications.communication</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "Group 74.png",
|
"filename" : "Screenshot 2026-06-22 at 5.56.53 AM.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Group 75.png",
|
"filename" : "Screenshot 2026-06-22 at 5.56.53 AM 1.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "Group 76.png",
|
"filename" : "Screenshot 2026-06-22 at 5.56.53 AM 2.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Screenshot 2026-06-22 at 5.56.53 AM 1.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Screenshot 2026-06-22 at 5.56.53 AM 1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Screenshot 2026-06-22 at 5.56.53 AM 2.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Screenshot 2026-06-22 at 5.56.53 AM 2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Screenshot 2026-06-22 at 5.56.53 AM.png
vendored
Normal file
BIN
ios/App/App/Assets.xcassets/Splash.imageset/Screenshot 2026-06-22 at 5.56.53 AM.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
@@ -35,6 +35,10 @@
|
|||||||
<string>Used to find forums and communities near you</string>
|
<string>Used to find forums and communities near you</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Access your photos to set a profile picture and share with others</string>
|
<string>Access your photos to set a profile picture and share with others</string>
|
||||||
|
<key>NSUserActivityTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
|
|||||||
53
ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md
Normal file
53
ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Communication Notifications (avatar + app-icon badge)
|
||||||
|
|
||||||
|
The Notification Service Extension (`NotificationService.swift`) turns incoming
|
||||||
|
pushes into **communication notifications** on iOS 15+: a large circular avatar
|
||||||
|
(the sending network's photo) with the app icon badged in the corner.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. The server (`frm.so`, `notifications.js`) sets `mutableContent: 1` and adds to
|
||||||
|
the push payload:
|
||||||
|
- `imageUrl` — a PNG of the network's logo (the server rasterizes SVG logos to
|
||||||
|
PNG via `sharp`, since iOS can't render SVG avatars).
|
||||||
|
- `senderName` — the network name (used as the conversation title; falls back
|
||||||
|
to the notification's own title).
|
||||||
|
- `isGroup` — `true`, so iOS uses the group-avatar style.
|
||||||
|
2. The extension downloads `imageUrl`, builds an `INSendMessageIntent` with the
|
||||||
|
image as the sender/group avatar, donates it, and returns
|
||||||
|
`content.updating(from: intent)`.
|
||||||
|
3. On iOS 14 the extension falls back to attaching the image as a plain thumbnail.
|
||||||
|
|
||||||
|
## Required setup (both pieces are mandatory)
|
||||||
|
|
||||||
|
Two things must be in place or the avatar silently won't render:
|
||||||
|
|
||||||
|
### 1. `NSUserActivityTypes` declares the intent (already in this repo)
|
||||||
|
|
||||||
|
`INSendMessageIntent` is declared under `NSUserActivityTypes` in **both**
|
||||||
|
`App/Info.plist` and `NotificationService/Info.plist`. Without this the donation
|
||||||
|
fails with *"Donating intent 'INSendMessageIntent' is not supported by this
|
||||||
|
extension"* and the communication styling never applies.
|
||||||
|
|
||||||
|
### 2. Communication Notifications capability (one-time, in Xcode — cannot be scripted)
|
||||||
|
|
||||||
|
The capability must be registered on the provisioning profile, not just present
|
||||||
|
in the entitlements file:
|
||||||
|
|
||||||
|
1. Open `ios/App/App.xcworkspace` in Xcode.
|
||||||
|
2. Select the **App** target → **Signing & Capabilities** → **+ Capability** →
|
||||||
|
add **Communication Notifications**.
|
||||||
|
- This adds `com.apple.developer.usernotifications.communication` to
|
||||||
|
`App.entitlements` (already in this repo) and, crucially, enables the
|
||||||
|
capability on the App ID / provisioning profile. Requires a paid Apple
|
||||||
|
Developer account.
|
||||||
|
3. If the avatar still doesn't appear, add the same capability to the
|
||||||
|
**NotificationService** target as well.
|
||||||
|
4. Rebuild and reinstall on a device (communication notifications don't reliably
|
||||||
|
render on the Simulator).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Requires iOS 15.0+ for the communication style; older versions get the plain
|
||||||
|
attachment fallback automatically.
|
||||||
|
- The app's deployment target is currently 14.0, so no bump is required.
|
||||||
17
ios/App/NotificationService/Info.plist
Normal file
17
ios/App/NotificationService/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.usernotifications.service</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSUserActivityTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>INSendMessageIntent</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
120
ios/App/NotificationService/NotificationService.swift
Normal file
120
ios/App/NotificationService/NotificationService.swift
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
//
|
||||||
|
// NotificationService.swift
|
||||||
|
// NotificationService
|
||||||
|
//
|
||||||
|
// Created by Matias Carulli on 5/7/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UserNotifications
|
||||||
|
import Intents
|
||||||
|
|
||||||
|
class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
|
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||||
|
var bestAttemptContent: UNMutableNotificationContent?
|
||||||
|
|
||||||
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||||
|
self.contentHandler = contentHandler
|
||||||
|
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
|
||||||
|
|
||||||
|
guard
|
||||||
|
let content = bestAttemptContent,
|
||||||
|
let imageUrlString = request.content.userInfo["imageUrl"] as? String,
|
||||||
|
let imageUrl = URL(string: imageUrlString)
|
||||||
|
else {
|
||||||
|
contentHandler(request.content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let userInfo = request.content.userInfo
|
||||||
|
|
||||||
|
// Download the avatar (the sending network's photo), then render the
|
||||||
|
// notification. A data task lets us hand the bytes straight to INImage.
|
||||||
|
URLSession.shared.dataTask(with: imageUrl) { data, _, error in
|
||||||
|
guard let data = data, !data.isEmpty, error == nil else {
|
||||||
|
contentHandler(content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS 15+: render as a communication notification — large circular
|
||||||
|
// avatar with the app icon badged in the corner.
|
||||||
|
if #available(iOS 15.0, *) {
|
||||||
|
self.deliverAsCommunicationNotification(content: content, imageData: data, userInfo: userInfo, contentHandler: contentHandler)
|
||||||
|
} else {
|
||||||
|
// iOS 14 fallback: attach the image as a plain thumbnail.
|
||||||
|
let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar.png")
|
||||||
|
try? data.write(to: tmpFile)
|
||||||
|
if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) {
|
||||||
|
content.attachments = [attachment]
|
||||||
|
}
|
||||||
|
contentHandler(content)
|
||||||
|
}
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
private func deliverAsCommunicationNotification(
|
||||||
|
content: UNMutableNotificationContent,
|
||||||
|
imageData: Data,
|
||||||
|
userInfo: [AnyHashable: Any],
|
||||||
|
contentHandler: @escaping (UNNotificationContent) -> Void
|
||||||
|
) {
|
||||||
|
// The conversation title shown in bold. Defaults to the notification's own
|
||||||
|
// title so existing copy (e.g. "Group | Alice") is preserved.
|
||||||
|
let senderName = (userInfo["senderName"] as? String) ?? content.title
|
||||||
|
let isGroup = (userInfo["isGroup"] as? Bool) ?? false
|
||||||
|
// Group related notifications into one conversation so iOS stacks them.
|
||||||
|
let conversationId = (userInfo["conversationId"] as? String)
|
||||||
|
?? (userInfo["threadId"] as? String)
|
||||||
|
?? content.threadIdentifier
|
||||||
|
|
||||||
|
let avatar = INImage(imageData: imageData)
|
||||||
|
|
||||||
|
let handle = INPersonHandle(value: conversationId, type: .unknown)
|
||||||
|
let sender = INPerson(
|
||||||
|
personHandle: handle,
|
||||||
|
nameComponents: nil,
|
||||||
|
displayName: senderName,
|
||||||
|
image: avatar,
|
||||||
|
contactIdentifier: nil,
|
||||||
|
customIdentifier: conversationId
|
||||||
|
)
|
||||||
|
|
||||||
|
let intent = INSendMessageIntent(
|
||||||
|
recipients: nil,
|
||||||
|
outgoingMessageType: .outgoingMessageText,
|
||||||
|
content: content.body,
|
||||||
|
speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: senderName) : nil,
|
||||||
|
conversationIdentifier: conversationId,
|
||||||
|
serviceName: nil,
|
||||||
|
sender: sender,
|
||||||
|
attachments: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// For a group conversation the avatar comes from the group image; for a
|
||||||
|
// 1:1 it comes from the sender's INPerson image (set above).
|
||||||
|
if isGroup {
|
||||||
|
intent.setImage(avatar, forParameterNamed: \.speakableGroupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
let interaction = INInteraction(intent: intent, response: nil)
|
||||||
|
interaction.direction = .incoming
|
||||||
|
interaction.donate(completion: nil)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let updated = try content.updating(from: intent)
|
||||||
|
contentHandler(updated)
|
||||||
|
} catch {
|
||||||
|
// If the intent can't be applied, fall back to the unmodified content
|
||||||
|
// so the notification still shows.
|
||||||
|
contentHandler(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func serviceExtensionTimeWillExpire() {
|
||||||
|
if let contentHandler = contentHandler, let content = bestAttemptContent {
|
||||||
|
contentHandler(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ def capacitor_pods
|
|||||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||||
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
|
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
|
||||||
|
pod 'ForumCapacitorNativeInput', :path => '../../native-input'
|
||||||
|
pod 'ForumCapacitorNativeSidebar', :path => '../../native-sidebar'
|
||||||
|
pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'App' do
|
target 'App' do
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Capacitor (7.4.4):
|
- Capacitor (7.6.6):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorCamera (7.0.2):
|
- CapacitorCamera (7.0.5):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorCordova (7.4.4)
|
- CapacitorCordova (7.6.6)
|
||||||
- CapacitorFilesystem (7.1.8):
|
- CapacitorFilesystem (7.1.8):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- IONFilesystemLib (~> 1.1.1)
|
- IONFilesystemLib (~> 1.1.1)
|
||||||
- CapacitorGeolocation (7.1.5):
|
- CapacitorGeolocation (7.1.8):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- IONGeolocationLib (= 1.0.1)
|
- IONGeolocationLib (= 1.0.1)
|
||||||
- CapacitorGoogleMaps (7.2.0):
|
- CapacitorGoogleMaps (7.2.2):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- Google-Maps-iOS-Utils (~> 5.0)
|
- Google-Maps-iOS-Utils (~> 5.0)
|
||||||
- GoogleMaps (~> 8.4)
|
- GoogleMaps (~> 8.4)
|
||||||
- CapacitorHaptics (7.0.3):
|
- CapacitorHaptics (7.0.5):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorPreferences (7.0.4):
|
- CapacitorPreferences (7.0.4):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorPushNotifications (7.0.6):
|
- CapacitorPushNotifications (7.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorSplashScreen (7.0.3):
|
- CapacitorSplashScreen (7.0.5):
|
||||||
|
- Capacitor
|
||||||
|
- ForumCapacitorNativeInput (1.0.0):
|
||||||
|
- Capacitor
|
||||||
|
- ForumCapacitorNativeSidebar (1.0.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- Google-Maps-iOS-Utils (5.0.0):
|
- Google-Maps-iOS-Utils (5.0.0):
|
||||||
- GoogleMaps (~> 8.0)
|
- GoogleMaps (~> 8.0)
|
||||||
@@ -31,6 +35,8 @@ PODS:
|
|||||||
- GoogleMaps/Base
|
- GoogleMaps/Base
|
||||||
- IONFilesystemLib (1.1.2)
|
- IONFilesystemLib (1.1.2)
|
||||||
- IONGeolocationLib (1.0.1)
|
- IONGeolocationLib (1.0.1)
|
||||||
|
- NotnotsamuelCapacitorSwipeBack (3.0.2):
|
||||||
|
- Capacitor
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||||
@@ -43,6 +49,9 @@ DEPENDENCIES:
|
|||||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||||
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
|
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
|
||||||
|
- ForumCapacitorNativeInput (from `../../native-input`)
|
||||||
|
- ForumCapacitorNativeSidebar (from `../../native-sidebar`)
|
||||||
|
- "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)"
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -72,23 +81,32 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@capacitor/push-notifications"
|
:path: "../../node_modules/@capacitor/push-notifications"
|
||||||
CapacitorSplashScreen:
|
CapacitorSplashScreen:
|
||||||
:path: "../../node_modules/@capacitor/splash-screen"
|
:path: "../../node_modules/@capacitor/splash-screen"
|
||||||
|
ForumCapacitorNativeInput:
|
||||||
|
:path: "../../native-input"
|
||||||
|
ForumCapacitorNativeSidebar:
|
||||||
|
:path: "../../native-sidebar"
|
||||||
|
NotnotsamuelCapacitorSwipeBack:
|
||||||
|
:path: "../../node_modules/@notnotsamuel/capacitor-swipe-back"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Capacitor: 09d9ff8e9618e8c4b3cab2bbee34a17215dd2fef
|
Capacitor: 961efd309ab0e390a8081b63c561186f61a6714d
|
||||||
CapacitorCamera: 6e18d54c8ab30d7dc7b8cd93d96f9b4f57e9202a
|
CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
|
||||||
CapacitorCordova: bf648a636f3c153f652d312ae145fb508b6ffced
|
CapacitorCordova: 3a4e0027a89a59421c918d19aee9d6ba1c7a3556
|
||||||
CapacitorFilesystem: f54cd6b76be06fa7ceb219cf313d32e0d626ea87
|
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
|
||||||
CapacitorGeolocation: b96474c3259dd4a294227ea8ec19140b1837cceb
|
CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a
|
||||||
CapacitorGoogleMaps: 20b5445a532f80dbb120fa99941fd094bcc88af6
|
CapacitorGoogleMaps: e3a1c0b501daa84a327b6ee807ee515a7f890403
|
||||||
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
|
CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5
|
||||||
CapacitorPreferences: d82a7e3b95fcab43a553268b803356522910d153
|
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
|
||||||
CapacitorPushNotifications: c6158ba6f3777f281a675aa43e4011e9723e822b
|
CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c
|
||||||
CapacitorSplashScreen: d06ae8804808e9f649a08e7bb7f283c77b688084
|
CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb
|
||||||
|
ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b
|
||||||
|
ForumCapacitorNativeSidebar: 2b93b6d0c8ae25a08437fe98bccfc528d8f9626a
|
||||||
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
|
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
|
||||||
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
|
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
|
||||||
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
||||||
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
|
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
|
||||||
|
NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e
|
||||||
|
|
||||||
PODFILE CHECKSUM: 32ad5bbf56056f7ee8d159f9eae42fe03e911a61
|
PODFILE CHECKSUM: 5005cc1c35ad0396f6966ccf4d0c4d4790dac751
|
||||||
|
|
||||||
COCOAPODS: 1.15.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
17
native-input/ForumCapacitorNativeInput.podspec
Normal file
17
native-input/ForumCapacitorNativeInput.podspec
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'ForumCapacitorNativeInput'
|
||||||
|
s.version = package['version']
|
||||||
|
s.summary = package['description']
|
||||||
|
s.license = package['license']
|
||||||
|
s.homepage = 'https://frm.so'
|
||||||
|
s.author = package['author']
|
||||||
|
s.source = { :git => 'https://frm.so', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
|
||||||
|
s.ios.deployment_target = '14.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
65
native-input/dist/esm/definitions.d.ts
vendored
Normal file
65
native-input/dist/esm/definitions.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colors are CSS color strings resolved on the web side from the chat theme
|
||||||
|
* variables, so the native bar matches light/dark mode and the in-app palette.
|
||||||
|
*/
|
||||||
|
export interface NativeInputTheme {
|
||||||
|
/** Bar / text field background. From `--chat-input-bg`. */
|
||||||
|
inputBackground?: string;
|
||||||
|
/** Text + caret color. From `--chat-input-text`. */
|
||||||
|
text?: string;
|
||||||
|
/** Placeholder color. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Accent used for the "+" button. From `--accent`. */
|
||||||
|
accent?: string;
|
||||||
|
/** Send button background. From `--chat-sent-bg`. */
|
||||||
|
sendBackground?: string;
|
||||||
|
/** Send button glyph color. From `--chat-sent-text`. */
|
||||||
|
sendText?: string;
|
||||||
|
/** Whether the app is currently in dark mode (drives the glass/blur tint). */
|
||||||
|
dark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresentOptions {
|
||||||
|
/** Identifies the active chat so re-presents for a different chat reset text. */
|
||||||
|
chatId: string;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Pre-fill text (e.g. a half-typed draft when re-mounting). */
|
||||||
|
text?: string;
|
||||||
|
/** When false, the field is read-only and the send button is disabled. */
|
||||||
|
canSend?: boolean;
|
||||||
|
theme?: NativeInputTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeInputPlugin {
|
||||||
|
/** Create + show the native input bar pinned above the keyboard. */
|
||||||
|
present(options: PresentOptions): Promise<void>;
|
||||||
|
/** Remove the native input bar. */
|
||||||
|
dismiss(): Promise<void>;
|
||||||
|
setText(options: { text: string }): Promise<void>;
|
||||||
|
getText(): Promise<{ text: string }>;
|
||||||
|
focus(): Promise<void>;
|
||||||
|
blur(): Promise<void>;
|
||||||
|
setPlaceholder(options: { placeholder: string }): Promise<void>;
|
||||||
|
setEnabled(options: { enabled: boolean }): Promise<void>;
|
||||||
|
setTheme(options: { theme: NativeInputTheme }): Promise<void>;
|
||||||
|
/** Hide/show without tearing down (e.g. when navigating away briefly). */
|
||||||
|
setVisible(options: { visible: boolean }): Promise<void>;
|
||||||
|
|
||||||
|
/** Fires on every text edit. */
|
||||||
|
addListener(eventName: 'inputChange', listenerFunc: (data: { text: string }) => void): Promise<PluginListenerHandle>;
|
||||||
|
/** Fires when the user taps send / hits the send key. */
|
||||||
|
addListener(eventName: 'send', listenerFunc: (data: { text: string }) => void): Promise<PluginListenerHandle>;
|
||||||
|
/** Fires when the "+" attach button is tapped. */
|
||||||
|
addListener(eventName: 'attach', listenerFunc: () => void): Promise<PluginListenerHandle>;
|
||||||
|
/** Fires on focus / blur of the native field. */
|
||||||
|
addListener(eventName: 'focusChange', listenerFunc: (data: { focused: boolean }) => void): Promise<PluginListenerHandle>;
|
||||||
|
/**
|
||||||
|
* Fires when the space the bar (and keyboard) occupies changes, in CSS px,
|
||||||
|
* so the web layout can pad its scroll area / position the reply strip.
|
||||||
|
*/
|
||||||
|
addListener(eventName: 'heightChange', listenerFunc: (data: { bottomInset: number }) => void): Promise<PluginListenerHandle>;
|
||||||
|
|
||||||
|
removeAllListeners(): Promise<void>;
|
||||||
|
}
|
||||||
1
native-input/dist/esm/definitions.js
vendored
Normal file
1
native-input/dist/esm/definitions.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
4
native-input/dist/esm/index.d.ts
vendored
Normal file
4
native-input/dist/esm/index.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { NativeInputPlugin } from './definitions';
|
||||||
|
declare const NativeInput: NativeInputPlugin;
|
||||||
|
export * from './definitions';
|
||||||
|
export { NativeInput };
|
||||||
8
native-input/dist/esm/index.js
vendored
Normal file
8
native-input/dist/esm/index.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
const NativeInput = registerPlugin('NativeInput', {
|
||||||
|
web: () => import('./web').then(m => new m.NativeInputWeb()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export * from './definitions';
|
||||||
|
export { NativeInput };
|
||||||
15
native-input/dist/esm/web.d.ts
vendored
Normal file
15
native-input/dist/esm/web.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { WebPlugin } from '@capacitor/core';
|
||||||
|
import type { NativeInputPlugin } from './definitions';
|
||||||
|
export declare class NativeInputWeb extends WebPlugin implements NativeInputPlugin {
|
||||||
|
present(): Promise<void>;
|
||||||
|
dismiss(): Promise<void>;
|
||||||
|
setText(): Promise<void>;
|
||||||
|
getText(): Promise<{ text: string }>;
|
||||||
|
focus(): Promise<void>;
|
||||||
|
blur(): Promise<void>;
|
||||||
|
setPlaceholder(): Promise<void>;
|
||||||
|
setEnabled(): Promise<void>;
|
||||||
|
setTheme(): Promise<void>;
|
||||||
|
setVisible(): Promise<void>;
|
||||||
|
addListener(eventName: any, listenerFunc: any): Promise<any>;
|
||||||
|
}
|
||||||
17
native-input/dist/esm/web.js
vendored
Normal file
17
native-input/dist/esm/web.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { WebPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
// No-op web implementation so the plugin import resolves in the browser build.
|
||||||
|
// The HTML composer (textarea) is used on web; these methods are never the
|
||||||
|
// active path there, but must exist to avoid runtime crashes.
|
||||||
|
export class NativeInputWeb extends WebPlugin {
|
||||||
|
async present() { /* no-op on web */ }
|
||||||
|
async dismiss() { /* no-op on web */ }
|
||||||
|
async setText() { /* no-op on web */ }
|
||||||
|
async getText() { return { text: '' }; }
|
||||||
|
async focus() { /* no-op on web */ }
|
||||||
|
async blur() { /* no-op on web */ }
|
||||||
|
async setPlaceholder() { /* no-op on web */ }
|
||||||
|
async setEnabled() { /* no-op on web */ }
|
||||||
|
async setTheme() { /* no-op on web */ }
|
||||||
|
async setVisible() { /* no-op on web */ }
|
||||||
|
}
|
||||||
25
native-input/dist/plugin.cjs.js
vendored
Normal file
25
native-input/dist/plugin.cjs.js
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
Object.defineProperty(exports, '__esModule', { value: true });
|
||||||
|
|
||||||
|
const core = require('@capacitor/core');
|
||||||
|
|
||||||
|
class NativeInputWeb extends core.WebPlugin {
|
||||||
|
async present() {}
|
||||||
|
async dismiss() {}
|
||||||
|
async setText() {}
|
||||||
|
async getText() { return { text: '' }; }
|
||||||
|
async focus() {}
|
||||||
|
async blur() {}
|
||||||
|
async setPlaceholder() {}
|
||||||
|
async setEnabled() {}
|
||||||
|
async setTheme() {}
|
||||||
|
async setVisible() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NativeInput = core.registerPlugin('NativeInput', {
|
||||||
|
web: () => Promise.resolve(new NativeInputWeb()),
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.NativeInput = NativeInput;
|
||||||
|
exports.NativeInputWeb = NativeInputWeb;
|
||||||
493
native-input/ios/Plugin/ChatInputBar.swift
Normal file
493
native-input/ios/Plugin/ChatInputBar.swift
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// A floating chat input bar (Liquid Glass on iOS 26+, blur material fallback)
|
||||||
|
/// that pins itself above the keyboard and reports the bottom inset it occupies
|
||||||
|
/// so the web layout can pad its scroll area accordingly.
|
||||||
|
class ChatInputBar: UIView, UITextViewDelegate {
|
||||||
|
|
||||||
|
// MARK: Callbacks to the plugin
|
||||||
|
var onTextChange: ((String) -> Void)?
|
||||||
|
var onSend: ((String) -> Void)?
|
||||||
|
var onAttach: (() -> Void)?
|
||||||
|
var onFocusChange: ((Bool) -> Void)?
|
||||||
|
/// Reports the bottom inset (in points == CSS px for this viewport) that the
|
||||||
|
/// bar + keyboard occupy at the bottom of the host view.
|
||||||
|
var onInsetChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
|
// MARK: Tunables
|
||||||
|
private let minTextHeight: CGFloat = 45 // ~25% taller than the old 36
|
||||||
|
private let maxTextHeight: CGFloat = 150
|
||||||
|
private let fieldVerticalPadding: CGFloat = 8
|
||||||
|
private let barVerticalPadding: CGFloat = 8
|
||||||
|
|
||||||
|
// MARK: Views
|
||||||
|
// Hosts the controls in a scroll view with `.interactive` keyboard dismiss
|
||||||
|
// so dragging the bar down lets the keyboard follow the finger.
|
||||||
|
private let panScroll = UIScrollView()
|
||||||
|
private let contentView = UIView()
|
||||||
|
private let field = UIView()
|
||||||
|
private let fieldEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect())
|
||||||
|
private let plusEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect())
|
||||||
|
private let textView = UITextView()
|
||||||
|
private let placeholderLabel = UILabel()
|
||||||
|
private let plusButton = UIButton(type: .system)
|
||||||
|
private let sendButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
private weak var hostView: UIView?
|
||||||
|
private var textHeightConstraint: NSLayoutConstraint!
|
||||||
|
// Pins the *visible* bar (the field) above the keyboard. The bar view itself
|
||||||
|
// spans the whole host so its scroll view can drive interactive dismissal.
|
||||||
|
private var fieldBottomConstraint: NSLayoutConstraint!
|
||||||
|
private var canSend = true
|
||||||
|
/// True while the keyboard is up — used to widen the bar's touch-catch area
|
||||||
|
/// (see hitTest) only when there's actually a keyboard to dismiss.
|
||||||
|
private var keyboardUp = false
|
||||||
|
|
||||||
|
/// True when the running OS gave us a real, interactive `UIGlassEffect`
|
||||||
|
/// (so the glass surfaces animate themselves on press). When false we fall
|
||||||
|
/// back to a manual press animation on the buttons.
|
||||||
|
private static var supportsInteractiveGlass = false
|
||||||
|
|
||||||
|
// MARK: Lifecycle
|
||||||
|
|
||||||
|
init(hostView: UIView) {
|
||||||
|
self.hostView = hostView
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
|
||||||
|
|
||||||
|
private static func makeEffect() -> UIVisualEffect {
|
||||||
|
// Use real Liquid Glass on iOS 26+. Resolved dynamically via the
|
||||||
|
// Objective-C runtime so the plugin still compiles against older SDKs
|
||||||
|
// (where the UIGlassEffect symbol does not yet exist).
|
||||||
|
if #available(iOS 26.0, *),
|
||||||
|
let glassClass = NSClassFromString("UIGlassEffect") as? NSObject.Type {
|
||||||
|
let glass = glassClass.init()
|
||||||
|
// Opt into the system's built-in touch animation (the scale + tint
|
||||||
|
// "squish" on press). Set via KVC behind a responds(to:) guard so it
|
||||||
|
// compiles against SDKs that lack UIGlassEffect and never crashes if
|
||||||
|
// the property is renamed/absent — it just stays static.
|
||||||
|
let setter = Selector("setInteractive:")
|
||||||
|
if glass.responds(to: setter) {
|
||||||
|
glass.setValue(true, forKey: "interactive")
|
||||||
|
supportsInteractiveGlass = true
|
||||||
|
}
|
||||||
|
if let effect = glass as? UIVisualEffect { return effect }
|
||||||
|
}
|
||||||
|
return UIBlurEffect(style: .systemUltraThinMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the bar to the host view. The bar view fills the whole host (so its
|
||||||
|
/// scroll view's bounds overlap the keyboard and can drive interactive
|
||||||
|
/// dismissal); only the visible field/"+" capture touches (see hitTest), and
|
||||||
|
/// the visible field is pinned just above the keyboard.
|
||||||
|
func attach() {
|
||||||
|
guard let host = hostView else { return }
|
||||||
|
translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
host.addSubview(self)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||||
|
trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||||
|
topAnchor.constraint(equalTo: host.topAnchor),
|
||||||
|
bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
if #available(iOS 15.0, *) {
|
||||||
|
// keyboardLayoutGuide rides the keyboard and rests at the safe-area
|
||||||
|
// bottom when the keyboard is dismissed.
|
||||||
|
host.keyboardLayoutGuide.followsUndockedKeyboard = true
|
||||||
|
fieldBottomConstraint = field.bottomAnchor.constraint(
|
||||||
|
equalTo: host.keyboardLayoutGuide.topAnchor, constant: -barVerticalPadding)
|
||||||
|
} else {
|
||||||
|
fieldBottomConstraint = field.bottomAnchor.constraint(
|
||||||
|
equalTo: host.safeAreaLayoutGuide.bottomAnchor, constant: -barVerticalPadding)
|
||||||
|
}
|
||||||
|
fieldBottomConstraint.isActive = true
|
||||||
|
|
||||||
|
observeKeyboard()
|
||||||
|
host.layoutIfNeeded() // so the field has a real frame before measuring
|
||||||
|
reportInset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeFromHost() {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
removeFromSuperview()
|
||||||
|
onInsetChange?(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar view spans the entire host so its scroll view can drive
|
||||||
|
/// interactive keyboard dismissal, but only the visible field/"+" should
|
||||||
|
/// swallow touches — every other point falls through to the web view beneath.
|
||||||
|
/// (A touch that begins on the bar is still tracked by the scroll view's pan
|
||||||
|
/// as the finger then drags down across the rest of the screen.)
|
||||||
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
|
guard let hit = super.hitTest(point, with: event) else { return nil }
|
||||||
|
// Always let the visible controls handle their own touches.
|
||||||
|
if hit == field || hit == plusEffect
|
||||||
|
|| hit.isDescendant(of: field) || hit.isDescendant(of: plusEffect) {
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
// While the keyboard is up, also claim an invisible strip ~1.5× the input
|
||||||
|
// height above the field (and everything below it) so a downward drag
|
||||||
|
// begun just above the bar still drives the interactive dismissal. Above
|
||||||
|
// the strip, fall through to the web view.
|
||||||
|
if keyboardUp {
|
||||||
|
let fieldInSelf = field.convert(field.bounds, to: self)
|
||||||
|
let catchTop = fieldInSelf.minY - 1.5 * minTextHeight
|
||||||
|
if point.y >= catchTop { return panScroll }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Setup
|
||||||
|
|
||||||
|
private func setupViews() {
|
||||||
|
// The bar itself is transparent so only the field + "+" button float.
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
// Scroll view that drives the native interactive keyboard dismissal.
|
||||||
|
// It fills the whole host so its bounds overlap the keyboard region: a
|
||||||
|
// downward drag that begins on the bar stays inside the scroll view as it
|
||||||
|
// travels toward the keyboard, which is what `.interactive` needs to track
|
||||||
|
// the finger. It never really scrolls (content == frame) but
|
||||||
|
// `alwaysBounceVertical` keeps its pan recognizer live.
|
||||||
|
panScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
panScroll.keyboardDismissMode = .interactive
|
||||||
|
panScroll.alwaysBounceVertical = true
|
||||||
|
panScroll.showsVerticalScrollIndicator = false
|
||||||
|
panScroll.showsHorizontalScrollIndicator = false
|
||||||
|
panScroll.delaysContentTouches = false
|
||||||
|
panScroll.backgroundColor = .clear
|
||||||
|
if #available(iOS 11.0, *) { panScroll.contentInsetAdjustmentBehavior = .never }
|
||||||
|
addSubview(panScroll)
|
||||||
|
|
||||||
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
panScroll.addSubview(contentView)
|
||||||
|
|
||||||
|
// Circular "+" attach button — same glass + border as the input field.
|
||||||
|
// Interaction is enabled so the interactive glass effect can observe
|
||||||
|
// touches and run its built-in press animation.
|
||||||
|
plusEffect.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
plusEffect.isUserInteractionEnabled = true
|
||||||
|
plusEffect.layer.cornerRadius = minTextHeight / 2
|
||||||
|
plusEffect.clipsToBounds = true
|
||||||
|
plusEffect.layer.borderWidth = 1
|
||||||
|
plusEffect.layer.borderColor = UIColor.separator.cgColor
|
||||||
|
contentView.addSubview(plusEffect)
|
||||||
|
|
||||||
|
// The button lives inside the effect's contentView so its touches are
|
||||||
|
// delivered through the glass (required for the interactive animation).
|
||||||
|
plusButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
plusButton.setImage(UIImage(systemName: "plus"), for: .normal)
|
||||||
|
plusButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside)
|
||||||
|
plusEffect.contentView.addSubview(plusButton)
|
||||||
|
addPressAnimation(to: plusButton, enabled: !ChatInputBar.supportsInteractiveGlass)
|
||||||
|
|
||||||
|
// Floating rounded input field.
|
||||||
|
field.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
field.layer.cornerRadius = 18
|
||||||
|
field.layer.borderWidth = 1
|
||||||
|
field.layer.borderColor = UIColor.separator.cgColor
|
||||||
|
field.clipsToBounds = true
|
||||||
|
contentView.addSubview(field)
|
||||||
|
|
||||||
|
// Glass / blur background fills the field (behind the content).
|
||||||
|
// Interaction is enabled so tapping the input runs the glass's built-in
|
||||||
|
// press animation; the text view and placeholder live in its contentView.
|
||||||
|
fieldEffect.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
fieldEffect.isUserInteractionEnabled = true
|
||||||
|
fieldEffect.layer.cornerRadius = 18
|
||||||
|
fieldEffect.clipsToBounds = true
|
||||||
|
field.addSubview(fieldEffect)
|
||||||
|
|
||||||
|
// Growing text view.
|
||||||
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.font = .systemFont(ofSize: 17)
|
||||||
|
textView.isScrollEnabled = false
|
||||||
|
textView.textContainerInset = UIEdgeInsets(top: 11, left: 6, bottom: 11, right: 6)
|
||||||
|
textView.delegate = self
|
||||||
|
fieldEffect.contentView.addSubview(textView)
|
||||||
|
|
||||||
|
// Placeholder overlay.
|
||||||
|
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
placeholderLabel.font = .systemFont(ofSize: 17)
|
||||||
|
placeholderLabel.textColor = UIColor.placeholderText
|
||||||
|
placeholderLabel.isUserInteractionEnabled = false
|
||||||
|
fieldEffect.contentView.addSubview(placeholderLabel)
|
||||||
|
|
||||||
|
// Send button — a solid (non-glass) button overlaid inside the field,
|
||||||
|
// bottom-right. Since it is not a glass surface it gets a manual press
|
||||||
|
// animation so it still scales/dims like the glass controls.
|
||||||
|
sendButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
sendButton.setImage(UIImage(systemName: "arrow.up"), for: .normal)
|
||||||
|
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
|
||||||
|
sendButton.layer.cornerRadius = 18
|
||||||
|
sendButton.clipsToBounds = true
|
||||||
|
field.addSubview(sendButton)
|
||||||
|
addPressAnimation(to: sendButton, enabled: true)
|
||||||
|
|
||||||
|
textHeightConstraint = textView.heightAnchor.constraint(equalToConstant: minTextHeight)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
// Scroll view fills the bar; content view matches its frame so it
|
||||||
|
// never scrolls but its pan stays active for interactive dismiss.
|
||||||
|
panScroll.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
panScroll.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
panScroll.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
panScroll.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
|
contentView.topAnchor.constraint(equalTo: panScroll.contentLayoutGuide.topAnchor),
|
||||||
|
contentView.leadingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.leadingAnchor),
|
||||||
|
contentView.trailingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.trailingAnchor),
|
||||||
|
contentView.bottomAnchor.constraint(equalTo: panScroll.contentLayoutGuide.bottomAnchor),
|
||||||
|
contentView.widthAnchor.constraint(equalTo: panScroll.frameLayoutGuide.widthAnchor),
|
||||||
|
contentView.heightAnchor.constraint(equalTo: panScroll.frameLayoutGuide.heightAnchor),
|
||||||
|
|
||||||
|
// Circular "+" button sized to the input's resting height, aligned
|
||||||
|
// to the field's bottom.
|
||||||
|
plusEffect.widthAnchor.constraint(equalToConstant: minTextHeight),
|
||||||
|
plusEffect.heightAnchor.constraint(equalToConstant: minTextHeight),
|
||||||
|
plusEffect.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
|
||||||
|
plusEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor),
|
||||||
|
plusButton.leadingAnchor.constraint(equalTo: plusEffect.leadingAnchor),
|
||||||
|
plusButton.trailingAnchor.constraint(equalTo: plusEffect.trailingAnchor),
|
||||||
|
plusButton.topAnchor.constraint(equalTo: plusEffect.topAnchor),
|
||||||
|
plusButton.bottomAnchor.constraint(equalTo: plusEffect.bottomAnchor),
|
||||||
|
|
||||||
|
field.leadingAnchor.constraint(equalTo: plusEffect.trailingAnchor, constant: 8),
|
||||||
|
field.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
|
||||||
|
// field.bottom is pinned to the keyboard guide in attach(); its top is
|
||||||
|
// driven by the text view's height. Its vertical position is therefore
|
||||||
|
// independent of the (full-host) content view.
|
||||||
|
|
||||||
|
fieldEffect.topAnchor.constraint(equalTo: field.topAnchor),
|
||||||
|
fieldEffect.leadingAnchor.constraint(equalTo: field.leadingAnchor),
|
||||||
|
fieldEffect.trailingAnchor.constraint(equalTo: field.trailingAnchor),
|
||||||
|
fieldEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor),
|
||||||
|
|
||||||
|
// Send button overlaid inside the field, bottom-right.
|
||||||
|
sendButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
|
sendButton.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
sendButton.trailingAnchor.constraint(equalTo: field.trailingAnchor, constant: -4),
|
||||||
|
sendButton.bottomAnchor.constraint(equalTo: field.bottomAnchor, constant: -4),
|
||||||
|
|
||||||
|
textView.leadingAnchor.constraint(equalTo: field.leadingAnchor, constant: 6),
|
||||||
|
textView.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -4),
|
||||||
|
textView.topAnchor.constraint(equalTo: field.topAnchor),
|
||||||
|
textView.bottomAnchor.constraint(equalTo: field.bottomAnchor),
|
||||||
|
textHeightConstraint,
|
||||||
|
|
||||||
|
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 11),
|
||||||
|
placeholderLabel.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
sendButton.backgroundColor = tintColor
|
||||||
|
updateTextHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Public API
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
get { textView.text ?? "" }
|
||||||
|
set {
|
||||||
|
textView.text = newValue
|
||||||
|
textChanged(notify: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPlaceholder(_ value: String) {
|
||||||
|
placeholderLabel.text = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
canSend = enabled
|
||||||
|
textView.isEditable = enabled
|
||||||
|
sendButton.isEnabled = enabled
|
||||||
|
plusButton.isEnabled = enabled
|
||||||
|
alpha = enabled ? 1.0 : 0.6
|
||||||
|
}
|
||||||
|
|
||||||
|
func focus() { textView.becomeFirstResponder() }
|
||||||
|
func blur() { textView.resignFirstResponder() }
|
||||||
|
|
||||||
|
func setVisible(_ visible: Bool) {
|
||||||
|
isHidden = !visible
|
||||||
|
if !visible { onInsetChange?(0) } else { reportInset() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Theme
|
||||||
|
|
||||||
|
func applyTheme(_ theme: [String: Any]) {
|
||||||
|
// The field/"+" keep a clear background so the glass material shows
|
||||||
|
// through and they read as floating; only content colors are themed.
|
||||||
|
if let txt = (theme["text"] as? String).flatMap(UIColor.fromCSS) {
|
||||||
|
textView.textColor = txt
|
||||||
|
textView.tintColor = txt
|
||||||
|
}
|
||||||
|
if let ph = (theme["placeholder"] as? String).flatMap(UIColor.fromCSS) {
|
||||||
|
placeholderLabel.textColor = ph
|
||||||
|
}
|
||||||
|
if let accent = (theme["accent"] as? String).flatMap(UIColor.fromCSS) {
|
||||||
|
plusButton.tintColor = accent
|
||||||
|
}
|
||||||
|
if let sendBg = (theme["sendBackground"] as? String).flatMap(UIColor.fromCSS) {
|
||||||
|
sendButton.backgroundColor = sendBg
|
||||||
|
}
|
||||||
|
if let sendTxt = (theme["sendText"] as? String).flatMap(UIColor.fromCSS) {
|
||||||
|
sendButton.tintColor = sendTxt
|
||||||
|
}
|
||||||
|
if let dark = theme["dark"] as? Bool {
|
||||||
|
overrideUserInterfaceStyle = dark ? .dark : .light
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Actions
|
||||||
|
|
||||||
|
@objc private func attachTapped() { onAttach?() }
|
||||||
|
|
||||||
|
@objc private func sendTapped() {
|
||||||
|
guard canSend else { return }
|
||||||
|
onSend?(textView.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Press animation
|
||||||
|
// Manual scale + dim used for the solid send button, and for the "+" when
|
||||||
|
// the OS doesn't provide interactive glass (so it still feels pressable).
|
||||||
|
|
||||||
|
private func addPressAnimation(to button: UIButton, enabled: Bool) {
|
||||||
|
guard enabled else { return }
|
||||||
|
button.addTarget(self, action: #selector(pressDown(_:)),
|
||||||
|
for: [.touchDown, .touchDragEnter])
|
||||||
|
button.addTarget(self, action: #selector(pressUp(_:)),
|
||||||
|
for: [.touchUpInside, .touchUpOutside, .touchCancel, .touchDragExit])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func pressDown(_ button: UIButton) {
|
||||||
|
UIView.animate(withDuration: 0.12, delay: 0,
|
||||||
|
options: [.allowUserInteraction, .beginFromCurrentState]) {
|
||||||
|
button.transform = CGAffineTransform(scaleX: 0.88, y: 0.88)
|
||||||
|
button.alpha = 0.7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func pressUp(_ button: UIButton) {
|
||||||
|
UIView.animate(withDuration: 0.28, delay: 0,
|
||||||
|
usingSpringWithDamping: 0.5, initialSpringVelocity: 0.6,
|
||||||
|
options: [.allowUserInteraction, .beginFromCurrentState]) {
|
||||||
|
button.transform = .identity
|
||||||
|
button.alpha = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UITextViewDelegate
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
textChanged(notify: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) { onFocusChange?(true) }
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) { onFocusChange?(false) }
|
||||||
|
|
||||||
|
private func textChanged(notify: Bool) {
|
||||||
|
placeholderLabel.isHidden = !(textView.text ?? "").isEmpty
|
||||||
|
updateTextHeight()
|
||||||
|
if notify { onTextChange?(textView.text ?? "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTextHeight() {
|
||||||
|
let fitting = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude))
|
||||||
|
let clamped = min(max(fitting.height, minTextHeight), maxTextHeight)
|
||||||
|
if abs(textHeightConstraint.constant - clamped) > 0.5 {
|
||||||
|
textHeightConstraint.constant = clamped
|
||||||
|
textView.isScrollEnabled = fitting.height > maxTextHeight
|
||||||
|
superview?.layoutIfNeeded()
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.reportInset() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Inset reporting
|
||||||
|
|
||||||
|
private func observeKeyboard() {
|
||||||
|
// Observe the *will* notifications — they fire at the START of the
|
||||||
|
// keyboard animation with the final frame, so the web layout can move in
|
||||||
|
// step with the keyboard instead of lagging until the animation ends
|
||||||
|
// (which is what the *did* notifications would do).
|
||||||
|
let names: [NSNotification.Name] = [
|
||||||
|
UIResponder.keyboardWillChangeFrameNotification,
|
||||||
|
UIResponder.keyboardWillShowNotification,
|
||||||
|
UIResponder.keyboardWillHideNotification,
|
||||||
|
]
|
||||||
|
for name in names {
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChange(_:)),
|
||||||
|
name: name, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardWillChange(_ note: Notification) {
|
||||||
|
guard let host = hostView, !isHidden else { return }
|
||||||
|
guard let end = (note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {
|
||||||
|
reportInset(); return
|
||||||
|
}
|
||||||
|
// Where the bar will rest: just above the keyboard, but never below the
|
||||||
|
// safe-area bottom (its resting place when the keyboard is dismissed).
|
||||||
|
let kbTop = host.convert(end, from: nil).minY
|
||||||
|
let safeBottom = host.bounds.height - host.safeAreaInsets.bottom
|
||||||
|
let barBottom = min(kbTop, safeBottom)
|
||||||
|
keyboardUp = barBottom < safeBottom - 0.5
|
||||||
|
|
||||||
|
if #available(iOS 15.0, *) {
|
||||||
|
// keyboardLayoutGuide animates the bar itself; nothing to drive here.
|
||||||
|
} else {
|
||||||
|
fieldBottomConstraint.constant = (barBottom - safeBottom) - barVerticalPadding
|
||||||
|
host.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report the *target* inset (computed from the keyboard's end frame, since
|
||||||
|
// the field hasn't moved yet on a *will* notification) so the web updates
|
||||||
|
// at the animation's start.
|
||||||
|
let fieldTop = barBottom - barVerticalPadding - field.bounds.height
|
||||||
|
let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding)
|
||||||
|
onInsetChange?(inset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reportInset() {
|
||||||
|
guard let host = hostView, !isHidden else { return }
|
||||||
|
// The bar view spans the whole host, so measure the *visible* field's top
|
||||||
|
// (in host coordinates) and report from there down to the host bottom.
|
||||||
|
let fieldTop = field.convert(field.bounds, to: host).minY
|
||||||
|
let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding)
|
||||||
|
onInsetChange?(inset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CSS color parsing
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
/// Parses the common CSS color strings that `getComputedStyle` returns
|
||||||
|
/// (`rgb(...)`, `rgba(...)`) plus `#rgb`/`#rrggbb`.
|
||||||
|
static func fromCSS(_ string: String) -> UIColor? {
|
||||||
|
let s = string.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
if s.hasPrefix("#") {
|
||||||
|
var hex = String(s.dropFirst())
|
||||||
|
if hex.count == 3 { hex = hex.map { "\($0)\($0)" }.joined() }
|
||||||
|
guard hex.count == 6, let v = UInt32(hex, radix: 16) else { return nil }
|
||||||
|
return UIColor(red: CGFloat((v >> 16) & 0xff) / 255,
|
||||||
|
green: CGFloat((v >> 8) & 0xff) / 255,
|
||||||
|
blue: CGFloat(v & 0xff) / 255, alpha: 1)
|
||||||
|
}
|
||||||
|
if s.hasPrefix("rgb") {
|
||||||
|
let inner = s.drop { $0 != "(" }.dropFirst().prefix { $0 != ")" }
|
||||||
|
let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count >= 3,
|
||||||
|
let r = Double(parts[0]), let g = Double(parts[1]), let b = Double(parts[2]) else { return nil }
|
||||||
|
let a = parts.count >= 4 ? (Double(parts[3]) ?? 1) : 1
|
||||||
|
return UIColor(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
24
native-input/ios/Plugin/Info.plist
Normal file
24
native-input/ios/Plugin/Info.plist
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
7
native-input/ios/Plugin/NativeInputPlugin.h
Normal file
7
native-input/ios/Plugin/NativeInputPlugin.h
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
//! Project version number for Plugin.
|
||||||
|
FOUNDATION_EXPORT double PluginVersionNumber;
|
||||||
|
|
||||||
|
//! Project version string for Plugin.
|
||||||
|
FOUNDATION_EXPORT const unsigned char PluginVersionString[];
|
||||||
17
native-input/ios/Plugin/NativeInputPlugin.m
Normal file
17
native-input/ios/Plugin/NativeInputPlugin.m
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <Capacitor/Capacitor.h>
|
||||||
|
|
||||||
|
// Define the plugin using the CAP_PLUGIN Macro, and
|
||||||
|
// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
|
||||||
|
CAP_PLUGIN(NativeInputPlugin, "NativeInput",
|
||||||
|
CAP_PLUGIN_METHOD(present, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setText, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(getText, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(focus, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(blur, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setPlaceholder, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setVisible, CAPPluginReturnPromise);
|
||||||
|
)
|
||||||
117
native-input/ios/Plugin/NativeInputPlugin.swift
Normal file
117
native-input/ios/Plugin/NativeInputPlugin.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
|
||||||
|
@objc(NativeInputPlugin)
|
||||||
|
public class NativeInputPlugin: CAPPlugin {
|
||||||
|
|
||||||
|
private var bar: ChatInputBar?
|
||||||
|
private var currentChatId: String?
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func hostView() -> UIView? {
|
||||||
|
// Pin the bar as a sibling of the web view inside the same parent so it
|
||||||
|
// can float above the web content and track the keyboard.
|
||||||
|
return webView?.superview ?? webView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureBar() -> ChatInputBar? {
|
||||||
|
if let bar = bar { return bar }
|
||||||
|
guard let host = hostView() else { return nil }
|
||||||
|
let bar = ChatInputBar(hostView: host)
|
||||||
|
bar.onTextChange = { [weak self] text in
|
||||||
|
self?.notifyListeners("inputChange", data: ["text": text])
|
||||||
|
}
|
||||||
|
bar.onSend = { [weak self] text in
|
||||||
|
self?.notifyListeners("send", data: ["text": text])
|
||||||
|
}
|
||||||
|
bar.onAttach = { [weak self] in
|
||||||
|
self?.notifyListeners("attach", data: [:])
|
||||||
|
}
|
||||||
|
bar.onFocusChange = { [weak self] focused in
|
||||||
|
self?.notifyListeners("focusChange", data: ["focused": focused])
|
||||||
|
}
|
||||||
|
bar.onInsetChange = { [weak self] inset in
|
||||||
|
self?.notifyListeners("heightChange", data: ["bottomInset": Double(inset)])
|
||||||
|
}
|
||||||
|
bar.attach()
|
||||||
|
self.bar = bar
|
||||||
|
return bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Plugin methods
|
||||||
|
|
||||||
|
@objc func present(_ call: CAPPluginCall) {
|
||||||
|
let chatId = call.getString("chatId") ?? ""
|
||||||
|
let placeholder = call.getString("placeholder") ?? ""
|
||||||
|
let text = call.getString("text") ?? ""
|
||||||
|
let canSend = call.getBool("canSend") ?? true
|
||||||
|
let theme = call.getObject("theme")
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let bar = self.ensureBar() else { call.reject("No host view"); return }
|
||||||
|
bar.setVisible(true)
|
||||||
|
if let theme = theme { bar.applyTheme(theme) }
|
||||||
|
bar.setPlaceholder(placeholder)
|
||||||
|
bar.setEnabled(canSend)
|
||||||
|
// Only reset text when switching to a different chat; otherwise keep
|
||||||
|
// whatever the user already had typed in this bar.
|
||||||
|
if self.currentChatId != chatId {
|
||||||
|
bar.text = text
|
||||||
|
self.currentChatId = chatId
|
||||||
|
}
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func dismiss(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bar?.removeFromHost()
|
||||||
|
self.bar = nil
|
||||||
|
self.currentChatId = nil
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setText(_ call: CAPPluginCall) {
|
||||||
|
let text = call.getString("text") ?? ""
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bar?.text = text
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func getText(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
call.resolve(["text": self.bar?.text ?? ""])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func focus(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async { self.bar?.focus(); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func blur(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async { self.bar?.blur(); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setPlaceholder(_ call: CAPPluginCall) {
|
||||||
|
let placeholder = call.getString("placeholder") ?? ""
|
||||||
|
DispatchQueue.main.async { self.bar?.setPlaceholder(placeholder); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setEnabled(_ call: CAPPluginCall) {
|
||||||
|
let enabled = call.getBool("enabled") ?? true
|
||||||
|
DispatchQueue.main.async { self.bar?.setEnabled(enabled); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setTheme(_ call: CAPPluginCall) {
|
||||||
|
let theme = call.getObject("theme") ?? [:]
|
||||||
|
DispatchQueue.main.async { self.bar?.applyTheme(theme); call.resolve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setVisible(_ call: CAPPluginCall) {
|
||||||
|
let visible = call.getBool("visible") ?? true
|
||||||
|
DispatchQueue.main.async { self.bar?.setVisible(visible); call.resolve() }
|
||||||
|
}
|
||||||
|
}
|
||||||
31
native-input/package.json
Normal file
31
native-input/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@forum/capacitor-native-input",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Native iOS chat input bar (Liquid Glass) for the Forum app.",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/Plugin/",
|
||||||
|
"ForumCapacitorNativeInput.podspec"
|
||||||
|
],
|
||||||
|
"author": "Forum",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"capacitor",
|
||||||
|
"plugin",
|
||||||
|
"native",
|
||||||
|
"ios",
|
||||||
|
"keyboard",
|
||||||
|
"input"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
native-sidebar/ForumCapacitorNativeSidebar.podspec
Normal file
17
native-sidebar/ForumCapacitorNativeSidebar.podspec
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
require 'json'
|
||||||
|
|
||||||
|
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||||
|
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'ForumCapacitorNativeSidebar'
|
||||||
|
s.version = package['version']
|
||||||
|
s.summary = package['description']
|
||||||
|
s.license = package['license']
|
||||||
|
s.homepage = 'https://frm.so'
|
||||||
|
s.author = package['author']
|
||||||
|
s.source = { :git => 'https://frm.so', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
|
||||||
|
s.ios.deployment_target = '14.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
65
native-sidebar/dist/esm/definitions.d.ts
vendored
Normal file
65
native-sidebar/dist/esm/definitions.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
|
/** A network row rendered in the sidebar. Shapes match `global.networks`. */
|
||||||
|
export interface SidebarNetwork {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
logo?: string | null;
|
||||||
|
displayOrder?: number;
|
||||||
|
data?: {
|
||||||
|
announcements?: { text: string }[];
|
||||||
|
members?: { id: number }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProfile {
|
||||||
|
id: number;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
email?: string;
|
||||||
|
image_path?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarData {
|
||||||
|
/** Origin that serves the UI (quill.js, Sidebar.js, host page). */
|
||||||
|
uiOrigin: string;
|
||||||
|
/** Origin that serves images (logos, avatars). */
|
||||||
|
serverOrigin: string;
|
||||||
|
profile: SidebarProfile;
|
||||||
|
networks: SidebarNetwork[];
|
||||||
|
currentNetworkId?: number | null;
|
||||||
|
/** Current theme key: light | parchment | dark | red. */
|
||||||
|
theme?: string;
|
||||||
|
/** Whether the current user may create networks. */
|
||||||
|
canCreateNetwork?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An action forwarded from the sidebar's content web view to the main app. */
|
||||||
|
export interface SidebarAction {
|
||||||
|
type: 'selectNetwork' | 'openProfile' | 'createNetwork' | 'setTheme' | 'logout';
|
||||||
|
abbreviation?: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeSidebarPlugin {
|
||||||
|
/** Create (pre-warm) the drawer if needed and push the latest data into it. */
|
||||||
|
setData(data: SidebarData): Promise<void>;
|
||||||
|
/** Update just the theme (key) without re-sending all data. */
|
||||||
|
setTheme(options: { theme: string }): Promise<void>;
|
||||||
|
/** Animate the drawer open. */
|
||||||
|
open(): Promise<void>;
|
||||||
|
/** Animate the drawer closed. */
|
||||||
|
close(): Promise<void>;
|
||||||
|
/** Enable/disable the edge-drag gesture (disabled on deep routes). */
|
||||||
|
setEnabled(options: { enabled: boolean }): Promise<void>;
|
||||||
|
/** Tear down the drawer entirely. */
|
||||||
|
dismiss(): Promise<void>;
|
||||||
|
|
||||||
|
/** Fires when the user taps something in the sidebar content. */
|
||||||
|
addListener(eventName: 'action', listenerFunc: (data: SidebarAction) => void): Promise<PluginListenerHandle>;
|
||||||
|
/** Fires when the drawer finishes opening/closing. */
|
||||||
|
addListener(eventName: 'stateChange', listenerFunc: (data: { open: boolean }) => void): Promise<PluginListenerHandle>;
|
||||||
|
|
||||||
|
removeAllListeners(): Promise<void>;
|
||||||
|
}
|
||||||
1
native-sidebar/dist/esm/definitions.js
vendored
Normal file
1
native-sidebar/dist/esm/definitions.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
4
native-sidebar/dist/esm/index.d.ts
vendored
Normal file
4
native-sidebar/dist/esm/index.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { NativeSidebarPlugin } from './definitions';
|
||||||
|
declare const NativeSidebar: NativeSidebarPlugin;
|
||||||
|
export * from './definitions';
|
||||||
|
export { NativeSidebar };
|
||||||
8
native-sidebar/dist/esm/index.js
vendored
Normal file
8
native-sidebar/dist/esm/index.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
const NativeSidebar = registerPlugin('NativeSidebar', {
|
||||||
|
web: () => import('./web').then(m => new m.NativeSidebarWeb()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export * from './definitions';
|
||||||
|
export { NativeSidebar };
|
||||||
11
native-sidebar/dist/esm/web.d.ts
vendored
Normal file
11
native-sidebar/dist/esm/web.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { WebPlugin } from '@capacitor/core';
|
||||||
|
import type { NativeSidebarPlugin } from './definitions';
|
||||||
|
export declare class NativeSidebarWeb extends WebPlugin implements NativeSidebarPlugin {
|
||||||
|
setData(): Promise<void>;
|
||||||
|
setTheme(): Promise<void>;
|
||||||
|
open(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
setEnabled(): Promise<void>;
|
||||||
|
dismiss(): Promise<void>;
|
||||||
|
addListener(eventName: any, listenerFunc: any): Promise<any>;
|
||||||
|
}
|
||||||
13
native-sidebar/dist/esm/web.js
vendored
Normal file
13
native-sidebar/dist/esm/web.js
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { WebPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
// No-op web implementation so the plugin import resolves in the browser build.
|
||||||
|
// The drawer is iOS-native only; on web these methods are never the active path
|
||||||
|
// but must exist to avoid runtime crashes.
|
||||||
|
export class NativeSidebarWeb extends WebPlugin {
|
||||||
|
async setData() { /* no-op on web */ }
|
||||||
|
async setTheme() { /* no-op on web */ }
|
||||||
|
async open() { /* no-op on web */ }
|
||||||
|
async close() { /* no-op on web */ }
|
||||||
|
async setEnabled() { /* no-op on web */ }
|
||||||
|
async dismiss() { /* no-op on web */ }
|
||||||
|
}
|
||||||
21
native-sidebar/dist/plugin.cjs.js
vendored
Normal file
21
native-sidebar/dist/plugin.cjs.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
Object.defineProperty(exports, '__esModule', { value: true });
|
||||||
|
|
||||||
|
const core = require('@capacitor/core');
|
||||||
|
|
||||||
|
class NativeSidebarWeb extends core.WebPlugin {
|
||||||
|
async setData() {}
|
||||||
|
async setTheme() {}
|
||||||
|
async open() {}
|
||||||
|
async close() {}
|
||||||
|
async setEnabled() {}
|
||||||
|
async dismiss() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NativeSidebar = core.registerPlugin('NativeSidebar', {
|
||||||
|
web: () => Promise.resolve(new NativeSidebarWeb()),
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.NativeSidebar = NativeSidebar;
|
||||||
|
exports.NativeSidebarWeb = NativeSidebarWeb;
|
||||||
24
native-sidebar/ios/Plugin/Info.plist
Normal file
24
native-sidebar/ios/Plugin/Info.plist
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
13
native-sidebar/ios/Plugin/NativeSidebarPlugin.m
Normal file
13
native-sidebar/ios/Plugin/NativeSidebarPlugin.m
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <Capacitor/Capacitor.h>
|
||||||
|
|
||||||
|
// Define the plugin using the CAP_PLUGIN Macro, and
|
||||||
|
// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
|
||||||
|
CAP_PLUGIN(NativeSidebarPlugin, "NativeSidebar",
|
||||||
|
CAP_PLUGIN_METHOD(setData, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(open, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(close, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise);
|
||||||
|
)
|
||||||
88
native-sidebar/ios/Plugin/NativeSidebarPlugin.swift
Normal file
88
native-sidebar/ios/Plugin/NativeSidebarPlugin.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
|
||||||
|
/// Hosts a native left-edge drawer (the "sidebar") that floats above the
|
||||||
|
/// Capacitor web view. The drawer's *content* is a second, transparent
|
||||||
|
/// WKWebView that renders the existing `Sidebar.js` from data pushed in via
|
||||||
|
/// `setData`. Taps inside that content web view are forwarded back to the main
|
||||||
|
/// app as an `action` event, mirroring how the NativeInput plugin works.
|
||||||
|
@objc(NativeSidebarPlugin)
|
||||||
|
public class NativeSidebarPlugin: CAPPlugin {
|
||||||
|
|
||||||
|
private var drawer: SidebarDrawer?
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func hostView() -> UIView? {
|
||||||
|
// Pin the drawer as a sibling of the web view inside the same parent so
|
||||||
|
// it can float above the web content (same pattern as NativeInput).
|
||||||
|
return webView?.superview ?? webView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureDrawer() -> SidebarDrawer? {
|
||||||
|
if let drawer = drawer { return drawer }
|
||||||
|
guard let host = hostView() else { return nil }
|
||||||
|
let drawer = SidebarDrawer(hostView: host, mainWebView: webView, parentVC: bridge?.viewController)
|
||||||
|
drawer.onAction = { [weak self] data in
|
||||||
|
self?.notifyListeners("action", data: data)
|
||||||
|
}
|
||||||
|
drawer.onStateChange = { [weak self] open in
|
||||||
|
self?.notifyListeners("stateChange", data: ["open": open])
|
||||||
|
}
|
||||||
|
drawer.attach()
|
||||||
|
self.drawer = drawer
|
||||||
|
return drawer
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Plugin methods
|
||||||
|
|
||||||
|
@objc func setData(_ call: CAPPluginCall) {
|
||||||
|
// Forward the whole options object (minus Capacitor's callbackId) to the
|
||||||
|
// content web view as the sidebar payload.
|
||||||
|
var payload = (call.options as? [String: Any]) ?? [:]
|
||||||
|
payload.removeValue(forKey: "callbackId")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let drawer = self.ensureDrawer() else { call.reject("No host view"); return }
|
||||||
|
drawer.setData(payload)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setTheme(_ call: CAPPluginCall) {
|
||||||
|
let theme = call.getString("theme") ?? ""
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.drawer?.setTheme(theme)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func open(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.ensureDrawer()?.open()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func close(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.drawer?.close()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func setEnabled(_ call: CAPPluginCall) {
|
||||||
|
let enabled = call.getBool("enabled") ?? true
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.drawer?.setEnabled(enabled)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func dismiss(_ call: CAPPluginCall) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.drawer?.removeFromHost()
|
||||||
|
self.drawer = nil
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
365
native-sidebar/ios/Plugin/SidebarDrawer.swift
Normal file
365
native-sidebar/ios/Plugin/SidebarDrawer.swift
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// A native left-edge drawer that *pushes* the app content aside (rather than
|
||||||
|
/// overlaying it). The sidebar panel sits at a fixed position on the left,
|
||||||
|
/// behind the content; opening slides the whole content layer (the Capacitor
|
||||||
|
/// web view plus any sibling overlays such as the native chat input bar) to the
|
||||||
|
/// right to reveal it.
|
||||||
|
///
|
||||||
|
/// View layers in the host (`webView.superview`):
|
||||||
|
/// • `panelHost` — the sidebar, inserted *below* the main web view. Fixed at
|
||||||
|
/// the left, ~85% width. Hosts a transparent `contentWebView`
|
||||||
|
/// that renders the existing `Sidebar.js`.
|
||||||
|
/// • content — the main web view + other native overlays. Translated
|
||||||
|
/// right by `currentOffset` when open (visual transform only,
|
||||||
|
/// so the web layout never reflows).
|
||||||
|
/// • `self` — a transparent gesture layer on top. Catches the left-edge
|
||||||
|
/// open drag when closed, and tap / drag-to-close over the
|
||||||
|
/// shifted content when open. Everything else falls through.
|
||||||
|
class SidebarDrawer: UIView, WKScriptMessageHandler, UIGestureRecognizerDelegate {
|
||||||
|
|
||||||
|
// MARK: Callbacks to the plugin
|
||||||
|
var onAction: (([String: Any]) -> Void)?
|
||||||
|
var onStateChange: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
// MARK: Tunables
|
||||||
|
private let panelRatio: CGFloat = 0.85
|
||||||
|
private let edgeStripWidth: CGFloat = 20
|
||||||
|
private let velocityToOpen: CGFloat = 500 // pt/s shortcut to decide snap
|
||||||
|
|
||||||
|
// MARK: Views
|
||||||
|
/// The sidebar, kept behind the content and revealed as content slides away.
|
||||||
|
private let panelHost = UIView()
|
||||||
|
private var contentWebView: WKWebView!
|
||||||
|
/// Owns the content web view so WebKit can find a presenting view controller
|
||||||
|
/// for native form controls (e.g. the theme `<select>`'s picker).
|
||||||
|
private let contentVC = UIViewController()
|
||||||
|
|
||||||
|
private weak var host: UIView?
|
||||||
|
private weak var mainWebView: WKWebView?
|
||||||
|
private weak var parentVC: UIViewController?
|
||||||
|
|
||||||
|
private var pan: UIPanGestureRecognizer!
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
private var isOpen = false
|
||||||
|
private var isDragging = false
|
||||||
|
private var isAnimating = false
|
||||||
|
private var gestureEnabled = true
|
||||||
|
/// How far (pt) the content is currently pushed right. 0 = closed.
|
||||||
|
private var currentOffset: CGFloat = 0
|
||||||
|
private var dragStartOffset: CGFloat = 0
|
||||||
|
|
||||||
|
/// Origin currently loaded into the content web view, so we only load once.
|
||||||
|
private var loadedOrigin: String?
|
||||||
|
/// True once the content page has signalled it is ready to receive data.
|
||||||
|
private var contentReady = false
|
||||||
|
/// Latest payload, buffered until the content page is ready.
|
||||||
|
private var pendingData: [String: Any]?
|
||||||
|
|
||||||
|
private var panelWidth: CGFloat { (host?.bounds.width ?? bounds.width) * panelRatio }
|
||||||
|
|
||||||
|
// MARK: Lifecycle
|
||||||
|
|
||||||
|
init(hostView: UIView, mainWebView: WKWebView?, parentVC: UIViewController?) {
|
||||||
|
self.host = hostView
|
||||||
|
self.mainWebView = mainWebView
|
||||||
|
self.parentVC = parentVC
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
|
||||||
|
|
||||||
|
func attach() {
|
||||||
|
guard let host = host else { return }
|
||||||
|
|
||||||
|
// Sidebar goes *below* the content so the content covers it when closed.
|
||||||
|
panelHost.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
if let webView = mainWebView, webView.superview === host {
|
||||||
|
host.insertSubview(panelHost, belowSubview: webView)
|
||||||
|
} else {
|
||||||
|
host.insertSubview(panelHost, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transparent gesture layer on top of everything.
|
||||||
|
translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
host.addSubview(self)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
panelHost.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||||
|
panelHost.topAnchor.constraint(equalTo: host.topAnchor),
|
||||||
|
panelHost.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
||||||
|
panelHost.widthAnchor.constraint(equalTo: host.widthAnchor, multiplier: panelRatio),
|
||||||
|
|
||||||
|
contentVC.view.leadingAnchor.constraint(equalTo: panelHost.leadingAnchor),
|
||||||
|
contentVC.view.trailingAnchor.constraint(equalTo: panelHost.trailingAnchor),
|
||||||
|
contentVC.view.topAnchor.constraint(equalTo: panelHost.topAnchor),
|
||||||
|
contentVC.view.bottomAnchor.constraint(equalTo: panelHost.bottomAnchor),
|
||||||
|
|
||||||
|
contentWebView.leadingAnchor.constraint(equalTo: contentVC.view.leadingAnchor),
|
||||||
|
contentWebView.trailingAnchor.constraint(equalTo: contentVC.view.trailingAnchor),
|
||||||
|
contentWebView.topAnchor.constraint(equalTo: contentVC.view.topAnchor),
|
||||||
|
contentWebView.bottomAnchor.constraint(equalTo: contentVC.view.bottomAnchor),
|
||||||
|
|
||||||
|
leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||||
|
trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||||
|
topAnchor.constraint(equalTo: host.topAnchor),
|
||||||
|
bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Register the content view controller so WebKit can find a presenter
|
||||||
|
// for native UI (the theme <select> picker, etc.).
|
||||||
|
if let parentVC = parentVC {
|
||||||
|
parentVC.addChild(contentVC)
|
||||||
|
contentVC.didMove(toParent: parentVC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeFromHost() {
|
||||||
|
contentWebView?.configuration.userContentController.removeScriptMessageHandler(forName: "sidebar")
|
||||||
|
contentVC.willMove(toParent: nil)
|
||||||
|
contentVC.removeFromParent()
|
||||||
|
applyOffset(0) // restore content to its resting position
|
||||||
|
panelHost.removeFromSuperview()
|
||||||
|
removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Setup
|
||||||
|
|
||||||
|
private func setupViews() {
|
||||||
|
backgroundColor = .clear
|
||||||
|
panelHost.backgroundColor = .clear
|
||||||
|
panelHost.clipsToBounds = true
|
||||||
|
|
||||||
|
// Transparent content web view hosting Sidebar.js (it paints its own
|
||||||
|
// themed, opaque background).
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = .default()
|
||||||
|
config.userContentController.add(WeakScriptMessageProxy(self), name: "sidebar")
|
||||||
|
contentWebView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
contentWebView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
contentWebView.isOpaque = false
|
||||||
|
contentWebView.backgroundColor = .clear
|
||||||
|
contentWebView.scrollView.backgroundColor = .clear
|
||||||
|
if #available(iOS 11.0, *) { contentWebView.scrollView.contentInsetAdjustmentBehavior = .never }
|
||||||
|
|
||||||
|
// The web view lives inside the content view controller's view so it has
|
||||||
|
// an owning VC for presenting native controls.
|
||||||
|
let container = contentVC.view!
|
||||||
|
container.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
container.backgroundColor = .clear
|
||||||
|
panelHost.addSubview(container)
|
||||||
|
container.addSubview(contentWebView)
|
||||||
|
|
||||||
|
pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||||
|
pan.delegate = self
|
||||||
|
addGestureRecognizer(pan)
|
||||||
|
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||||
|
tap.delegate = self
|
||||||
|
addGestureRecognizer(tap)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
// Keep the resting position correct as the host sizes (and on rotation),
|
||||||
|
// but never fight an in-flight drag/animation.
|
||||||
|
if !isDragging && !isAnimating {
|
||||||
|
applyOffset(isOpen ? panelWidth : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Content translation
|
||||||
|
|
||||||
|
/// All content layers that should slide together — everything in the host
|
||||||
|
/// except the sidebar and this gesture layer.
|
||||||
|
private func contentSiblings() -> [UIView] {
|
||||||
|
guard let host = host else { return [] }
|
||||||
|
return host.subviews.filter { $0 !== self && $0 !== panelHost }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyOffset(_ x: CGFloat) {
|
||||||
|
currentOffset = x
|
||||||
|
let t = x == 0 ? .identity : CGAffineTransform(translationX: x, y: 0)
|
||||||
|
for v in contentSiblings() { v.transform = t }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Public API
|
||||||
|
|
||||||
|
func setData(_ payload: [String: Any]) {
|
||||||
|
pendingData = payload
|
||||||
|
if let origin = payload["uiOrigin"] as? String, origin != loadedOrigin {
|
||||||
|
loadedOrigin = origin
|
||||||
|
contentReady = false
|
||||||
|
if let url = URL(string: "\(origin)/83947261/sidebar-host.html") {
|
||||||
|
contentWebView.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushDataIfReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTheme(_ theme: String) {
|
||||||
|
guard contentReady, !theme.isEmpty else { return }
|
||||||
|
let safe = theme.replacingOccurrences(of: "'", with: "")
|
||||||
|
contentWebView.evaluateJavaScript("window.__sidebarSetTheme && window.__sidebarSetTheme('\(safe)')")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(_ enabled: Bool) {
|
||||||
|
gestureEnabled = enabled
|
||||||
|
pan.isEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func open() { animate(open: true, velocity: 0) }
|
||||||
|
func close() { animate(open: false, velocity: 0) }
|
||||||
|
|
||||||
|
// MARK: Data injection
|
||||||
|
|
||||||
|
private func flushDataIfReady() {
|
||||||
|
guard contentReady, let payload = pendingData,
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: payload),
|
||||||
|
let json = String(data: data, encoding: .utf8) else { return }
|
||||||
|
contentWebView.evaluateJavaScript("window.__sidebarSetData && window.__sidebarSetData(\(json))")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: WKScriptMessageHandler
|
||||||
|
|
||||||
|
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
guard let body = message.body as? [String: Any],
|
||||||
|
let action = body["action"] as? String else { return }
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "ready":
|
||||||
|
contentReady = true
|
||||||
|
flushDataIfReady()
|
||||||
|
case "selectNetwork":
|
||||||
|
onAction?(["type": "selectNetwork", "abbreviation": body["abbreviation"] as? String ?? ""])
|
||||||
|
close()
|
||||||
|
case "openProfile":
|
||||||
|
onAction?(["type": "openProfile"])
|
||||||
|
close()
|
||||||
|
case "createNetwork":
|
||||||
|
onAction?(["type": "createNetwork"])
|
||||||
|
close()
|
||||||
|
case "logout":
|
||||||
|
onAction?(["type": "logout"])
|
||||||
|
close()
|
||||||
|
case "setTheme":
|
||||||
|
// The content page applies the theme to itself locally; just relay
|
||||||
|
// so the main app updates globally. Keep the drawer open.
|
||||||
|
onAction?(["type": "setTheme", "value": body["value"] as? String ?? ""])
|
||||||
|
case "close":
|
||||||
|
close()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Gestures
|
||||||
|
|
||||||
|
@objc private func handleTap() {
|
||||||
|
// A tap only reaches us (see hitTest) over the shifted content when open.
|
||||||
|
if isOpen { close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handlePan(_ g: UIPanGestureRecognizer) {
|
||||||
|
guard let host = host else { return }
|
||||||
|
switch g.state {
|
||||||
|
case .began:
|
||||||
|
isDragging = true
|
||||||
|
superview?.bringSubviewToFront(self)
|
||||||
|
dragStartOffset = currentOffset
|
||||||
|
mainWebView?.allowsBackForwardNavigationGestures = false
|
||||||
|
case .changed:
|
||||||
|
let t = g.translation(in: host).x
|
||||||
|
applyOffset(min(max(dragStartOffset + t, 0), panelWidth))
|
||||||
|
case .ended, .cancelled, .failed:
|
||||||
|
isDragging = false
|
||||||
|
let v = g.velocity(in: host).x
|
||||||
|
let target: Bool
|
||||||
|
if abs(v) > velocityToOpen {
|
||||||
|
target = v > 0
|
||||||
|
} else if isOpen {
|
||||||
|
// Was open → close once dragged left past ~1/3 (stay open otherwise).
|
||||||
|
target = currentOffset > panelWidth * 2 / 3
|
||||||
|
} else {
|
||||||
|
// Was closed → open once dragged right past ~1/3.
|
||||||
|
target = currentOffset > panelWidth / 3
|
||||||
|
}
|
||||||
|
animate(open: target, velocity: v)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animate(open: Bool, velocity: CGFloat) {
|
||||||
|
isAnimating = true
|
||||||
|
if open {
|
||||||
|
superview?.bringSubviewToFront(self)
|
||||||
|
mainWebView?.allowsBackForwardNavigationGestures = false
|
||||||
|
}
|
||||||
|
let target: CGFloat = open ? panelWidth : 0
|
||||||
|
let remaining = max(abs(target - currentOffset), 1)
|
||||||
|
let springVelocity = min(abs(velocity) / remaining, 4)
|
||||||
|
UIView.animate(withDuration: 0.32, delay: 0,
|
||||||
|
usingSpringWithDamping: 0.9, initialSpringVelocity: springVelocity,
|
||||||
|
options: [.allowUserInteraction, .beginFromCurrentState]) {
|
||||||
|
self.applyOffset(target)
|
||||||
|
} completion: { _ in
|
||||||
|
self.isAnimating = false
|
||||||
|
let changed = self.isOpen != open
|
||||||
|
self.isOpen = open
|
||||||
|
if !open { self.mainWebView?.allowsBackForwardNavigationGestures = true }
|
||||||
|
if changed { self.onStateChange?(open) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UIGestureRecognizerDelegate
|
||||||
|
|
||||||
|
override func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
|
||||||
|
guard gestureEnabled else { return g is UITapGestureRecognizer ? isOpen : false }
|
||||||
|
guard let pan = g as? UIPanGestureRecognizer else { return true }
|
||||||
|
// Start position is already gated by hitTest (closed → only the edge
|
||||||
|
// strip reaches us; open → only the shifted content). Require a
|
||||||
|
// horizontal-dominant drag so vertical scrolls pass through.
|
||||||
|
let vel = pan.velocity(in: self)
|
||||||
|
return abs(vel.x) >= abs(vel.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(_ g: UIGestureRecognizer,
|
||||||
|
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: hitTest — gate which touches the gesture layer claims
|
||||||
|
|
||||||
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
|
if isHidden || alpha == 0 { return nil }
|
||||||
|
|
||||||
|
if isOpen || isDragging {
|
||||||
|
// The revealed sidebar (left of the content's left edge) stays
|
||||||
|
// interactive — fall through to the sidebar web view beneath.
|
||||||
|
if point.x < currentOffset { return nil }
|
||||||
|
// Over the shifted content: claim the touch (tap / drag to close).
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed: only the left strip is live (to start the open drag); every
|
||||||
|
// other touch falls through to the content beneath.
|
||||||
|
if gestureEnabled && point.x <= edgeStripWidth { return self }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Weak message-handler proxy
|
||||||
|
|
||||||
|
/// `WKUserContentController` retains its message handlers strongly. This weak
|
||||||
|
/// proxy breaks the drawer ⇄ web view ⇄ controller retain cycle.
|
||||||
|
private final class WeakScriptMessageProxy: NSObject, WKScriptMessageHandler {
|
||||||
|
weak var target: WKScriptMessageHandler?
|
||||||
|
init(_ target: WKScriptMessageHandler) { self.target = target }
|
||||||
|
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
target?.userContentController(controller, didReceive: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
native-sidebar/package.json
Normal file
31
native-sidebar/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@forum/capacitor-native-sidebar",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Native iOS left-edge drawer (sidebar) for the Forum app.",
|
||||||
|
"main": "dist/plugin.cjs.js",
|
||||||
|
"module": "dist/esm/index.js",
|
||||||
|
"types": "dist/esm/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/",
|
||||||
|
"ios/Plugin/",
|
||||||
|
"ForumCapacitorNativeSidebar.podspec"
|
||||||
|
],
|
||||||
|
"author": "Forum",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"capacitor",
|
||||||
|
"plugin",
|
||||||
|
"native",
|
||||||
|
"ios",
|
||||||
|
"sidebar",
|
||||||
|
"drawer"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^7.0.0"
|
||||||
|
},
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,13 @@
|
|||||||
"@capacitor/geolocation": "^7.1.5",
|
"@capacitor/geolocation": "^7.1.5",
|
||||||
"@capacitor/google-maps": "^7.2.0",
|
"@capacitor/google-maps": "^7.2.0",
|
||||||
"@capacitor/haptics": "^7.0.3",
|
"@capacitor/haptics": "^7.0.3",
|
||||||
"@capacitor/ios": "^7.4.4",
|
"@capacitor/ios": "^7.6.6",
|
||||||
"@capacitor/preferences": "^7.0.4",
|
"@capacitor/preferences": "^7.0.4",
|
||||||
"@capacitor/push-notifications": "^7.0.6",
|
"@capacitor/push-notifications": "^7.0.6",
|
||||||
"@capacitor/splash-screen": "^7.0.3"
|
"@capacitor/splash-screen": "^7.0.3",
|
||||||
|
"@forum/capacitor-native-input": "file:native-input",
|
||||||
|
"@forum/capacitor-native-sidebar": "file:native-sidebar",
|
||||||
|
"@notnotsamuel/capacitor-swipe-back": "^3.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "latest",
|
"@capacitor/cli": "latest",
|
||||||
|
|||||||
44
readme.md
44
readme.md
@@ -2,28 +2,15 @@
|
|||||||
|
|
||||||
```npm run start```
|
```npm run start```
|
||||||
|
|
||||||
### Browser: Dev Frontend and Dev Backend (localhost)
|
## Install Native Tools
|
||||||
This option should be at the top level of capacitor.config.json
|
|
||||||
"server": {
|
|
||||||
"url": "http://sam.local:5173",
|
|
||||||
"cleartext": true
|
|
||||||
},
|
|
||||||
|
|
||||||
### Browser: Prod Frontend and Prod Backend
|
|
||||||
Run:
|
|
||||||
vite build
|
|
||||||
npx serve dist
|
|
||||||
|
|
||||||
If you need to login again:
|
|
||||||
run localStorage.clear() in the browser dev tools console and then refresh the page.
|
|
||||||
|
|
||||||
## Run On Device
|
|
||||||
https://capacitorjs.com/docs/ios#adding-the-ios-platform
|
https://capacitorjs.com/docs/ios#adding-the-ios-platform
|
||||||
|
|
||||||
One-Time Install:
|
|
||||||
npm install @capacitor/ios
|
npm install @capacitor/ios
|
||||||
npx cap add ios
|
npx cap add ios
|
||||||
|
|
||||||
|
## Run On Device
|
||||||
|
|
||||||
To Open XCode:
|
To Open XCode:
|
||||||
npx cap open ios
|
npx cap open ios
|
||||||
|
|
||||||
@@ -33,24 +20,41 @@ npm run build && npx cap copy ios
|
|||||||
If getting black screen:
|
If getting black screen:
|
||||||
npx cap sync iOS
|
npx cap sync iOS
|
||||||
|
|
||||||
### iOS: Dev Frontend and Dev Backend (localhost)
|
|
||||||
|
### Browser: Dev Front and Dev Back (localhost)
|
||||||
This option should be at the top level of capacitor.config.json
|
This option should be at the top level of capacitor.config.json
|
||||||
"server": {
|
"server": {
|
||||||
"url": "http://sam.local:5173",
|
"url": "http://sam.local:5173",
|
||||||
"cleartext": true
|
"cleartext": true
|
||||||
},
|
},
|
||||||
|
|
||||||
### iOS: Dev Frontend with Prod Backend (frm.so)
|
### Browser: Prod Front and Prod Back
|
||||||
|
Run:
|
||||||
|
vite build
|
||||||
|
npx serve dist
|
||||||
|
|
||||||
|
If you need to login again:
|
||||||
|
run localStorage.clear() in the browser dev tools console and then refresh the page.
|
||||||
|
|
||||||
|
## iOS:
|
||||||
|
|
||||||
|
### Dev Front, Dev Back (localhost)
|
||||||
|
This option should be at the top level of capacitor.config.json
|
||||||
|
"server": {
|
||||||
|
"url": "http://sam.local:5173",
|
||||||
|
"cleartext": true
|
||||||
|
},
|
||||||
|
|
||||||
|
### Dev Front, Prod Back (frm.so)
|
||||||
Add "https://frm.so" to VITE_API_URL in .env.development
|
Add "https://frm.so" to VITE_API_URL in .env.development
|
||||||
|
|
||||||
### iOS: Prod Frontend and Prod Backend (frm.so)
|
### Prod Front, Prod Back (frm.so)
|
||||||
Remove the "server" object from capacitor.config.
|
Remove the "server" object from capacitor.config.
|
||||||
|
|
||||||
### Various Commands
|
### Various Commands
|
||||||
npx cap config - this will list the full configuration currently being used
|
npx cap config - this will list the full configuration currently being used
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
In Development, API routes are routed using the vite.config.js.
|
In Development, API routes are routed using the vite.config.js.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
VITE_API_URL=http://localhost:10002
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
VITE_API_URL=https://frm.so
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
:root {
|
|
||||||
--main: #FFE9C8;
|
|
||||||
--accent: #570b0b;
|
|
||||||
--lightaccent: #760f0f;
|
|
||||||
--darkaccent: #dfc9ac;
|
|
||||||
--text: #340000;
|
|
||||||
--yellow: #f1f3c3;
|
|
||||||
--bone: #fff2e7;
|
|
||||||
--gold: #FEBA7D;
|
|
||||||
--divider: #bb7c36;
|
|
||||||
--green: #0857265c;
|
|
||||||
--red: #ff0000;
|
|
||||||
--quillred: #DE3F3F;
|
|
||||||
--darkred: #6b2c1d;
|
|
||||||
--brown: #812A18;
|
|
||||||
|
|
||||||
--sidebar: #698b6f;
|
|
||||||
--divider: #523636;
|
|
||||||
--lightDivider: #52363681;
|
|
||||||
--darkbrown: #3f0808;
|
|
||||||
--darkgrey: #5c4646;
|
|
||||||
|
|
||||||
--headertext: #433c36e2;
|
|
||||||
--searchbackground: #ffeed8;
|
|
||||||
--loginButton: var(--main);
|
|
||||||
--loginBackground: #d96b6b;
|
|
||||||
|
|
||||||
--home-src: /_/icons/home.svg;
|
|
||||||
--home-selected-src: /_/icons/homelightselected.svg;
|
|
||||||
--people-src: /_/icons/people.svg;
|
|
||||||
--people-selected-src: /_/icons/peoplelightselected.svg;
|
|
||||||
--settings-src: /_/icons/settings.svg;
|
|
||||||
--settings-selected-src: /_/icons/settingslightselected.svg;
|
|
||||||
--events-src: /_/icons/events.svg;
|
|
||||||
--events-selected-src: /_/icons/eventslightselected.svg;
|
|
||||||
--jobs-src: /_/icons/jobs.svg;
|
|
||||||
--jobs-selected-src: /_/icons/jobslightselected.svg;
|
|
||||||
|
|
||||||
--column-src: /_/icons/column2.svg;
|
|
||||||
--nodes-src: /_/icons/nodes.svg;
|
|
||||||
--forum-src: /_/icons/forum.svg;
|
|
||||||
--trash-src: /_/icons/trash.svg;
|
|
||||||
|
|
||||||
--pin-src: /_/icons/pin.svg;
|
|
||||||
--time-src: /_/icons/time.svg;
|
|
||||||
|
|
||||||
--top-inset: env(safe-area-inset-top, 0px);
|
|
||||||
--bottom-inset: env(safe-area-inset-bottom, 0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--main: #2A150E;
|
|
||||||
--lightaccent: #69413a;
|
|
||||||
--accent: #3D2622;
|
|
||||||
--darkaccent: #240609;
|
|
||||||
--text: #FADFB6;
|
|
||||||
|
|
||||||
--sidebar: #240609;
|
|
||||||
--divider: #523636;
|
|
||||||
--lightDivider: #52363680;
|
|
||||||
--darktext: #62473E;
|
|
||||||
--headertext: #ffd8bb;
|
|
||||||
--darkred: #6b2c1d;
|
|
||||||
--searchbackground: #260F0C;
|
|
||||||
--loginButton: var(--darkaccent);
|
|
||||||
--loginBackground: var(--accent);
|
|
||||||
|
|
||||||
|
|
||||||
--home-src: /_/icons/homelight.svg;
|
|
||||||
--home-selected-src: /_/icons/homelightselected.svg;
|
|
||||||
--people-src: /_/icons/peoplelight.svg;
|
|
||||||
--people-selected-src: /_/icons/peoplelightselected.svg;
|
|
||||||
--settings-src: /_/icons/settingslight.svg;
|
|
||||||
--settings-selected-src: /_/icons/settingslightselected.svg;
|
|
||||||
--events-src: /_/icons/eventslight.svg;
|
|
||||||
--events-selected-src: /_/icons/eventslightselected.svg;
|
|
||||||
--jobs-src: /_/icons/jobslight.svg;
|
|
||||||
--jobs-selected-src: /_/icons/jobslightselected.svg;
|
|
||||||
|
|
||||||
--column-src: /_/icons/column2.svg;
|
|
||||||
--nodes-src: /_/icons/nodes.svg;
|
|
||||||
--forum-src: /_/icons/forum.svg;
|
|
||||||
--trash-src: /_/icons/trash.svg;
|
|
||||||
|
|
||||||
--pin-src: /_/icons/pinlight.svg;
|
|
||||||
--time-src: /_/icons/timelight.svg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
outline: none;
|
|
||||||
caret-color: var(--text); /* hide real caret */
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
font-family: Arial;
|
|
||||||
color: #5C504D;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-family: Arial;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--main);
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
padding-left: env(safe-area-inset-left);
|
|
||||||
padding-right: env(safe-area-inset-right);
|
|
||||||
}
|
|
||||||
@@ -10,9 +10,10 @@
|
|||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<meta name="msapplication-tap-highlight" content="no" />
|
<meta name="msapplication-tap-highlight" content="no" />
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<link rel="manifest" href="./manifest.json" />
|
||||||
<link rel="stylesheet" href="./_/code/shared.css" />
|
<link rel="stylesheet" href="" />
|
||||||
|
<script>window.config = { UI: 'https://frm.so', SERVER: 'https://frm.so' }</script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
await import('./util.js')
|
await import('./mobileutil.js')
|
||||||
|
|
||||||
function appendScript(src, isModule = false) {
|
function appendScript(src, isModule = false) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -26,9 +27,25 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendStylesheet(href, { replaceExisting = false } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (replaceExisting) {
|
||||||
|
document.querySelector('link[rel="stylesheet"]')?.remove();
|
||||||
|
}
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = href;
|
||||||
|
link.onload = resolve;
|
||||||
|
link.onerror = reject;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await appendScript(window.util.HOST + '/_/code/quill.js')
|
await appendStylesheet(`${window.config.UI}/_/code/shared.css`, { replaceExisting: true });
|
||||||
await appendScript(window.util.HOST + '/83947261/index.js', true)
|
await appendScript(window.config.UI + '/_/code/quill.js')
|
||||||
|
await appendScript(window.config.UI + '/_/lib/hls.min.js')
|
||||||
|
await appendScript(window.config.UI + '/83947261/index.js', true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
|||||||
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
|
||||||
import { Geolocation } from '@capacitor/geolocation';
|
import { Geolocation } from '@capacitor/geolocation';
|
||||||
import { SplashScreen } from '@capacitor/splash-screen';
|
import { SplashScreen } from '@capacitor/splash-screen';
|
||||||
|
import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back';
|
||||||
|
import { NativeInput } from '@forum/capacitor-native-input';
|
||||||
|
import { NativeSidebar } from '@forum/capacitor-native-sidebar';
|
||||||
|
|
||||||
window.capacitor = {
|
window.capacitor = {
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -18,15 +21,12 @@ window.capacitor = {
|
|||||||
CameraSource,
|
CameraSource,
|
||||||
Geolocation,
|
Geolocation,
|
||||||
SplashScreen,
|
SplashScreen,
|
||||||
|
CapacitorSwipeBackPlugin,
|
||||||
|
NativeInput,
|
||||||
|
NativeSidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = import.meta.env
|
window.mobileUtil = class mobileUtil {
|
||||||
|
|
||||||
window.util = class util {
|
|
||||||
|
|
||||||
static HOST = env.VITE_API_URL
|
|
||||||
static PushNotifications = PushNotifications
|
|
||||||
static Preferences = Preferences
|
|
||||||
|
|
||||||
static async authFetch(url, options = {}) {
|
static async authFetch(url, options = {}) {
|
||||||
const { value: token } = await Preferences.get({ key: 'auth_token' });
|
const { value: token } = await Preferences.get({ key: 'auth_token' });
|
||||||
@@ -44,10 +44,4 @@ window.util = class util {
|
|||||||
static async removeAuthToken() {
|
static async removeAuthToken() {
|
||||||
await Preferences.remove({ key: 'auth_token'})
|
await Preferences.remove({ key: 'auth_token'})
|
||||||
}
|
}
|
||||||
|
|
||||||
static cssVariable(value) {
|
|
||||||
return getComputedStyle(document.documentElement)
|
|
||||||
.getPropertyValue("--" + value)
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user