diff --git a/packages/frontend/apps/electron/renderer/app.tsx b/packages/frontend/apps/electron/renderer/app.tsx index be4fc2ba5e994..a9d04be2b1551 100644 --- a/packages/frontend/apps/electron/renderer/app.tsx +++ b/packages/frontend/apps/electron/renderer/app.tsx @@ -133,12 +133,9 @@ events?.applicationMenu.onNewPageAction(() => { .get(GlobalContextService) .globalContext.workspaceId.get(); const workspacesService = frameworkProvider.get(WorkspacesService); - const workspaceMetadata = currentWorkspaceId - ? workspacesService.list.workspace$(currentWorkspaceId).value + const workspaceRef = currentWorkspaceId + ? workspacesService.openByWorkspaceId(currentWorkspaceId) : null; - const workspaceRef = - workspaceMetadata && - workspacesService.open({ metadata: workspaceMetadata }); if (!workspaceRef) { return; } diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 7964dc8f36b89..fe3bec13be596 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -7,10 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; }; + 5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */; }; + 50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; }; + 50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; }; + 50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; }; + 50A285DC2D112B24000D5A6D /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50A285DB2D112B24000D5A6D /* Intelligents */; }; 9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */; }; 9D90BE252CCB9876006677DB /* CookieManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE172CCB9876006677DB /* CookieManager.swift */; }; 9D90BE262CCB9876006677DB /* CookiePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE182CCB9876006677DB /* CookiePlugin.swift */; }; - 9D90BE272CCB9876006677DB /* AFFiNEViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE1B2CCB9876006677DB /* AFFiNEViewController.swift */; }; + 9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE1B2CCB9876006677DB /* AffineViewController.swift */; }; 9D90BE282CCB9876006677DB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D90BE1C2CCB9876006677DB /* AppDelegate.swift */; }; 9D90BE292CCB9876006677DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE1D2CCB9876006677DB /* Assets.xcassets */; }; 9D90BE2A2CCB9876006677DB /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 9D90BE1E2CCB9876006677DB /* capacitor.config.json */; }; @@ -27,10 +33,15 @@ /* Begin PBXFileReference section */ 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 507513692D1924C600AD60C0 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; + 5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntelligentsPlugin.swift; sourceTree = ""; }; + 50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = ""; }; + 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashcashPlugin.swift; sourceTree = ""; }; 9D90BE172CCB9876006677DB /* CookieManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieManager.swift; sourceTree = ""; }; 9D90BE182CCB9876006677DB /* CookiePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookiePlugin.swift; sourceTree = ""; }; - 9D90BE1B2CCB9876006677DB /* AFFiNEViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AFFiNEViewController.swift; sourceTree = ""; }; + 9D90BE1B2CCB9876006677DB /* AffineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AffineViewController.swift; sourceTree = ""; }; 9D90BE1C2CCB9876006677DB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 9D90BE1D2CCB9876006677DB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9D90BE1E2CCB9876006677DB /* capacitor.config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; @@ -55,6 +66,8 @@ buildActionMask = 2147483647; files = ( C4C97C6E2D0304D100BC2AD1 /* libaffine_mobile_native.a in Frameworks */, + 50A285DC2D112B24000D5A6D /* Intelligents in Frameworks */, + 50802D612D112F8700694021 /* Intelligents in Frameworks */, C4C413792CBE705D00337889 /* Pods_App.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -77,6 +90,7 @@ children = ( C4C97C722D0307B700BC2AD1 /* uniffi */, 9D90BE242CCB9876006677DB /* App */, + 50802D5F2D112F7D00694021 /* Packages */, 504EC3051FED79650016851F /* Products */, 7F8756D8B27F46E3366F6CEA /* Pods */, 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, @@ -93,6 +107,14 @@ name = Products; sourceTree = ""; }; + 50802D5F2D112F7D00694021 /* Packages */ = { + isa = PBXGroup; + children = ( + 50802D5E2D112F7D00694021 /* Intelligents */, + ); + path = Packages; + sourceTree = ""; + }; 7F8756D8B27F46E3366F6CEA /* Pods */ = { isa = PBXGroup; children = ( @@ -105,32 +127,37 @@ 9D90BE192CCB9876006677DB /* Cookie */ = { isa = PBXGroup; children = ( + 9D1C07262CEC3E8200E1C502 /* IntelligentsPlugin.swift */, 9D90BE172CCB9876006677DB /* CookieManager.swift */, 9D90BE182CCB9876006677DB /* CookiePlugin.swift */, 9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */, + 5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */, ); path = Cookie; sourceTree = ""; }; - 9D90BE1A2CCB9876006677DB /* plugins */ = { + 9D90BE1A2CCB9876006677DB /* Plugins */ = { isa = PBXGroup; children = ( E93B276A2CED9298001409B8 /* NavigationGesture */, 9D90BE192CCB9876006677DB /* Cookie */, ); - path = plugins; + path = Plugins; sourceTree = ""; }; 9D90BE242CCB9876006677DB /* App */ = { isa = PBXGroup; children = ( - 9D90BE1A2CCB9876006677DB /* plugins */, - 9D90BE1B2CCB9876006677DB /* AFFiNEViewController.swift */, + 9D90BE1A2CCB9876006677DB /* Plugins */, 9D90BE1C2CCB9876006677DB /* AppDelegate.swift */, + 507513692D1924C600AD60C0 /* RootViewController.swift */, + 9D90BE1B2CCB9876006677DB /* AffineViewController.swift */, 9D90BE1D2CCB9876006677DB /* Assets.xcassets */, 9D90BE1E2CCB9876006677DB /* capacitor.config.json */, 9D90BE1F2CCB9876006677DB /* config.xml */, 9D90BE202CCB9876006677DB /* Info.plist */, + 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */, + 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */, 9D90BE222CCB9876006677DB /* Main.storyboard */, 9D90BE232CCB9876006677DB /* public */, ); @@ -218,6 +245,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */, + 50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */, 9D90BE292CCB9876006677DB /* Assets.xcassets in Resources */, 9D90BE2A2CCB9876006677DB /* capacitor.config.json in Resources */, 9D90BE2B2CCB9876006677DB /* config.xml in Resources */, @@ -287,6 +316,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */, + 5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */, C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */, C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */, C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */, @@ -294,7 +325,7 @@ 9D90BE252CCB9876006677DB /* CookieManager.swift in Sources */, 9D90BE262CCB9876006677DB /* CookiePlugin.swift in Sources */, 9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */, - 9D90BE272CCB9876006677DB /* AFFiNEViewController.swift in Sources */, + 9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */, 9D90BE282CCB9876006677DB /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -441,7 +472,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -476,7 +507,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 73YMMDVT2M; INFOPLIST_FILE = App/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - IPHONEOS_DEPLOYMENT_TARGET = 16.6; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -521,6 +552,17 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 50802D602D112F8700694021 /* Intelligents */ = { + isa = XCSwiftPackageProductDependency; + productName = Intelligents; + }; + 50A285DB2D112B24000D5A6D /* Intelligents */ = { + isa = XCSwiftPackageProductDependency; + productName = Intelligents; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; } diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/frontend/apps/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000000..2ab295976096e --- /dev/null +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,50 @@ +{ + "pins" : [ + { + "identity" : "msdisplaylink", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/MSDisplayLink", + "state" : { + "revision" : "c2fcd28cb99300d83acc30860ce252ef97c20b61", + "version" : "1.1.1" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "springinterpolation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SpringInterpolation", + "state" : { + "revision" : "f9d1ee3d2466bdb00fd0ade7f256ed20229c8413", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", + "version" : "0.5.0" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000000..2ab295976096e --- /dev/null +++ b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,50 @@ +{ + "pins" : [ + { + "identity" : "msdisplaylink", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/MSDisplayLink", + "state" : { + "revision" : "c2fcd28cb99300d83acc30860ce252ef97c20b61", + "version" : "1.1.1" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "springinterpolation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SpringInterpolation", + "state" : { + "revision" : "f9d1ee3d2466bdb00fd0ade7f256ed20229c8413", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", + "version" : "0.5.0" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/packages/frontend/apps/ios/App/App/AFFiNEViewController.swift b/packages/frontend/apps/ios/App/App/AFFiNEViewController.swift deleted file mode 100644 index c95e3866f565f..0000000000000 --- a/packages/frontend/apps/ios/App/App/AFFiNEViewController.swift +++ /dev/null @@ -1,17 +0,0 @@ -import UIKit -import Capacitor - -class AFFiNEViewController: CAPBridgeViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // disable by default, enable manually when there is a "back" button in page-header - webView?.allowsBackForwardNavigationGestures = false - } - - override func capacitorDidLoad() { - bridge?.registerPluginInstance(CookiePlugin()) - bridge?.registerPluginInstance(HashcashPlugin()) - bridge?.registerPluginInstance(NavigationGesturePlugin()) - } -} diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift new file mode 100644 index 0000000000000..3f3e25d7b8cc6 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -0,0 +1,113 @@ +import Capacitor +import Intelligents +import UIKit + +class AFFiNEViewController: CAPBridgeViewController { + override func viewDidLoad() { + super.viewDidLoad() + // disable by default, enable manually when there is a "back" button in page-header + webView?.allowsBackForwardNavigationGestures = false + navigationController?.navigationBar.isHidden = true + extendedLayoutIncludesOpaqueBars = false + edgesForExtendedLayout = [] + let intelligentsButton = installIntelligentsButton() + intelligentsButton.delegate = self + dismissIntelligentsButton() + } + + override func capacitorDidLoad() { + let plugins: [CAPPlugin] = [ + CookiePlugin(), + HashcashPlugin(), + NavigationGesturePlugin(), + IntelligentsPlugin(representController: self), + ] + plugins.forEach { bridge?.registerPluginInstance($0) } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + } +} + +extension AFFiNEViewController: IntelligentsButtonDelegate, IntelligentsFocusApertureViewDelegate { + func onIntelligentsButtonTapped(_ button: IntelligentsButton) { + guard let webView else { + assertionFailure() // ? wdym ? + return + } + + button.beginProgress() + + let script = "return await window.getCurrentDocContentInMarkdown();" + webView.callAsyncJavaScript( + script, + arguments: [:], + in: nil, + in: .page + ) { result in + button.stopProgress() + webView.resignFirstResponder() + + if case let .failure(error) = result { + print("[?] \(self) script error: \(error.localizedDescription)") + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if case let .success(content) = result, + let res = content as? String + { + print("[*] \(self) received document with \(res.count) characters") + DispatchQueue.main.async { + self.openIntelligentsSheet(withContext: res) + } + } else { + DispatchQueue.main.async { + self.openSimpleChat() + } + } + } + } + } + + func openIntelligentsSheet(withContext context: String) { + guard let view = webView?.subviews.first else { + assertionFailure() + return + } + assert(view is UIScrollView) + _ = context + let focus = IntelligentsFocusApertureView() + focus.prepareAnimationWith( + capturingTargetContentView: view, + coveringRootViewController: self + ) + focus.delegate = self + focus.executeAnimationKickIn() + dismissIntelligentsButton() + } + + func openSimpleChat() { + let targetController = IntelligentsChatController() + presentIntoCurrentContext(withTargetController: targetController) + } + + func focusApertureRequestAction(actionType: IntelligentsFocusApertureViewActionType) { + switch actionType { + case .translateTo: + fatalError("not implemented") + case .summary: + fatalError("not implemented") + case .chatWithAI: + let controller = IntelligentsChatController() + presentIntoCurrentContext(withTargetController: controller) + case .dismiss: + presentIntelligentsButton() + } + } +} diff --git a/packages/frontend/apps/ios/App/App/AppDelegate.swift b/packages/frontend/apps/ios/App/App/AppDelegate.swift index c3cd83b5c0a62..fae3240822cf8 100644 --- a/packages/frontend/apps/ios/App/App/AppDelegate.swift +++ b/packages/frontend/apps/ios/App/App/AppDelegate.swift @@ -1,49 +1,47 @@ -import UIKit import Capacitor +import UIKit -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - // Called when the app was launched with a url. Feel free to add additional processing here, - // but if you want the App API to support tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(app, open: url, options: options) - } - - func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { - // Called when the app was launched with an activity, including Universal Links. - // Feel free to add additional processing here, but if you want the App API to support - // tracking app url opens, make sure to keep this call - return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) - } - + var window: UIWindow? + + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + true + } + + func applicationWillResignActive(_: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } } diff --git a/packages/frontend/apps/ios/App/App/Base.lproj/Main.storyboard b/packages/frontend/apps/ios/App/App/Base.lproj/Main.storyboard index c12077feea5a7..999015b50281e 100644 --- a/packages/frontend/apps/ios/App/App/Base.lproj/Main.storyboard +++ b/packages/frontend/apps/ios/App/App/Base.lproj/Main.storyboard @@ -1,15 +1,15 @@ - + - + - + - + diff --git a/packages/frontend/apps/ios/App/App/InfoPlist.xcstrings b/packages/frontend/apps/ios/App/App/InfoPlist.xcstrings new file mode 100644 index 0000000000000..6cd6cdef4b5bd --- /dev/null +++ b/packages/frontend/apps/ios/App/App/InfoPlist.xcstrings @@ -0,0 +1,30 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "AFFiNE" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "App" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/packages/frontend/apps/ios/App/App/Localizable.xcstrings b/packages/frontend/apps/ios/App/App/Localizable.xcstrings new file mode 100644 index 0000000000000..fa56d1619968c --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Localizable.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/packages/frontend/apps/ios/App/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/App/Packages/Intelligents/Package.swift new file mode 100644 index 0000000000000..30d45e6d13f9d --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Packages/Intelligents/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Intelligents", + defaultLocalization: "en", + platforms: [ + .iOS(.v15), + .macCatalyst(.v15), + ], + products: [ + .library(name: "Intelligents", targets: ["Intelligents"]), + ], + dependencies: [ + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), + .package(url: "https://github.com/Lakr233/SpringInterpolation", from: "1.1.0"), + .package(url: "https://github.com/Lakr233/MSDisplayLink", from: "1.1.0"), + ], + targets: [ + .target(name: "Intelligents", dependencies: [ + "SpringInterpolation", + "MSDisplayLink", + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + ]), + ] +) diff --git a/packages/frontend/apps/ios/App/App/plugins/Cookie/CookieManager.swift b/packages/frontend/apps/ios/App/App/Plugins/Cookie/CookieManager.swift similarity index 57% rename from packages/frontend/apps/ios/App/App/plugins/Cookie/CookieManager.swift rename to packages/frontend/apps/ios/App/App/Plugins/Cookie/CookieManager.swift index 10538ca637748..61622258a83f8 100644 --- a/packages/frontend/apps/ios/App/App/plugins/Cookie/CookieManager.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Cookie/CookieManager.swift @@ -7,24 +7,24 @@ public class CookieManager: NSObject { let jar = HTTPCookieStorage.shared guard let url = getServerUrl(urlString) else { return [:] } if let cookies = jar.cookies(for: url) { - for cookie in cookies { - cookiesMap[cookie.name] = cookie.value - } + for cookie in cookies { + cookiesMap[cookie.name] = cookie.value + } } return cookiesMap } - + private func isUrlSanitized(_ urlString: String) -> Bool { - return urlString.hasPrefix("http://") || urlString.hasPrefix("https://") + urlString.hasPrefix("http://") || urlString.hasPrefix("https://") } - + public func getServerUrl(_ urlString: String) -> URL? { - let validUrlString = (isUrlSanitized(urlString)) ? urlString : "http://\(urlString)" + let validUrlString = isUrlSanitized(urlString) ? urlString : "http://\(urlString)" - guard let url = URL(string: validUrlString) else { - return nil - } + guard let url = URL(string: validUrlString) else { + return nil + } - return url + return url } } diff --git a/packages/frontend/apps/ios/App/App/plugins/Cookie/CookiePlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Cookie/CookiePlugin.swift similarity index 84% rename from packages/frontend/apps/ios/App/App/plugins/Cookie/CookiePlugin.swift rename to packages/frontend/apps/ios/App/App/Plugins/Cookie/CookiePlugin.swift index 637414fb706ca..74655616ccab0 100644 --- a/packages/frontend/apps/ios/App/App/plugins/Cookie/CookiePlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Cookie/CookiePlugin.swift @@ -1,21 +1,21 @@ -import Foundation import Capacitor +import Foundation @objc(CookiePlugin) public class CookiePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "CookiePlugin" public let jsName = "Cookie" public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "getCookies", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "getCookies", returnType: CAPPluginReturnPromise), ] - + let cookieManager = CookieManager() @objc public func getCookies(_ call: CAPPluginCall) { guard let url = call.getString("url") else { return call.resolve([:]) } - + call.resolve(cookieManager.getCookies(url)) } } diff --git a/packages/frontend/apps/ios/App/App/Plugins/Cookie/HashcashPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Cookie/HashcashPlugin.swift new file mode 100644 index 0000000000000..93aaae0fa5740 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Plugins/Cookie/HashcashPlugin.swift @@ -0,0 +1,18 @@ +import Capacitor + +@objc(HashcashPlugin) +public class HashcashPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "HashcashPlugin" + public let jsName = "Hashcash" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "hash", returnType: CAPPluginReturnPromise), + ] + + @objc func hash(_ call: CAPPluginCall) { + DispatchQueue.global(qos: .default).async { + let challenge = call.getString("challenge") ?? "" + let bits = call.getInt("bits") ?? 20 + call.resolve(["value": hashcashMint(resource: challenge, bits: UInt32(bits))]) + } + } +} diff --git a/packages/frontend/apps/ios/App/App/Plugins/Cookie/IntelligentsPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Cookie/IntelligentsPlugin.swift new file mode 100644 index 0000000000000..f348b0629406b --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Plugins/Cookie/IntelligentsPlugin.swift @@ -0,0 +1,36 @@ +import Capacitor +import Foundation + +@objc(IntelligentsPlugin) +public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "IntelligentsPlugin" + public let jsName = "Intelligents" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "presentIntelligentsButton", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "dismissIntelligentsButton", returnType: CAPPluginReturnPromise), + ] + public private(set) weak var representController: UIViewController? + + init(representController: UIViewController) { + self.representController = representController + super.init() + } + + deinit { + representController = nil + } + + @objc public func presentIntelligentsButton(_ call: CAPPluginCall) { + DispatchQueue.main.async { + self.representController?.presentIntelligentsButton() + call.resolve() + } + } + + @objc public func dismissIntelligentsButton(_ call: CAPPluginCall) { + DispatchQueue.main.async { + self.representController?.dismissIntelligentsButton() + call.resolve() + } + } +} diff --git a/packages/frontend/apps/ios/App/App/plugins/NavigationGesture/NavigationGesturePlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/NavigationGesture/NavigationGesturePlugin.swift similarity index 69% rename from packages/frontend/apps/ios/App/App/plugins/NavigationGesture/NavigationGesturePlugin.swift rename to packages/frontend/apps/ios/App/App/Plugins/NavigationGesture/NavigationGesturePlugin.swift index 045fce128862e..85690746d8d0b 100644 --- a/packages/frontend/apps/ios/App/App/plugins/NavigationGesture/NavigationGesturePlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/NavigationGesture/NavigationGesturePlugin.swift @@ -1,28 +1,28 @@ -import Foundation import Capacitor +import Foundation @objc(NavigationGesturePlugin) public class NavigationGesturePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "NavigationGesturePlugin" public let jsName = "NavigationGesture" public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "isEnabled", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "disable", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "isEnabled", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "enable", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "disable", returnType: CAPPluginReturnPromise), ] - + @objc func isEnabled(_ call: CAPPluginCall) { - let enabled = self.bridge?.webView?.allowsBackForwardNavigationGestures ?? true + let enabled = bridge?.webView?.allowsBackForwardNavigationGestures ?? true call.resolve(["value": enabled]) } - + @objc func enable(_ call: CAPPluginCall) { DispatchQueue.main.sync { self.bridge?.webView?.allowsBackForwardNavigationGestures = true call.resolve([:]) } } - + @objc func disable(_ call: CAPPluginCall) { DispatchQueue.main.sync { self.bridge?.webView?.allowsBackForwardNavigationGestures = false diff --git a/packages/frontend/apps/ios/App/App/RootViewController.swift b/packages/frontend/apps/ios/App/App/RootViewController.swift new file mode 100644 index 0000000000000..bc0df08b861d7 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/RootViewController.swift @@ -0,0 +1,38 @@ +// +// RootViewController.swift +// App +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +@objc +class RootViewController: UINavigationController { + override init(rootViewController _: UIViewController) { + fatalError() // "you are not allowed to call this" + } + + override init(navigationBarClass _: AnyClass?, toolbarClass _: AnyClass?) { + fatalError() // "you are not allowed to call this" + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commitInit() + } + + override init(nibName _: String?, bundle _: Bundle?) { + fatalError() // "you are not allowed to call this" + } + + func commitInit() { + assert(viewControllers.isEmpty) + viewControllers = [AFFiNEViewController()] + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + } +} diff --git a/packages/frontend/apps/ios/App/App/plugins/Cookie/HashcashPlugin.swift b/packages/frontend/apps/ios/App/App/plugins/Cookie/HashcashPlugin.swift deleted file mode 100644 index 808749ff50f14..0000000000000 --- a/packages/frontend/apps/ios/App/App/plugins/Cookie/HashcashPlugin.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Capacitor - -@objc(HashcashPlugin) -public class HashcashPlugin: CAPPlugin, CAPBridgedPlugin { - public let identifier = "HashcashPlugin" - public let jsName = "Hashcash" - public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "hash", returnType: CAPPluginReturnPromise) - ] - - @objc func hash(_ call: CAPPluginCall) { - DispatchQueue.global(qos: .default).async { - let challenge = call.getString("challenge") ?? "" - let bits = call.getInt("bits") ?? 20; - call.resolve(["value": hashcashMint(resource: challenge, bits: UInt32(bits))]) - } - } -} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/.gitignore b/packages/frontend/apps/ios/App/Packages/Intelligents/.gitignore new file mode 100644 index 0000000000000..0023a53406379 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.resolved b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.resolved new file mode 100644 index 0000000000000..bf00e11417287 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", + "version" : "0.5.0" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift new file mode 100644 index 0000000000000..30d45e6d13f9d --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Intelligents", + defaultLocalization: "en", + platforms: [ + .iOS(.v15), + .macCatalyst(.v15), + ], + products: [ + .library(name: "Intelligents", targets: ["Intelligents"]), + ], + dependencies: [ + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), + .package(url: "https://github.com/Lakr233/SpringInterpolation", from: "1.1.0"), + .package(url: "https://github.com/Lakr233/MSDisplayLink", from: "1.1.0"), + ], + targets: [ + .target(name: "Intelligents", dependencies: [ + "SpringInterpolation", + "MSDisplayLink", + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + ]), + ] +) diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Constant/Constant.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Constant/Constant.swift new file mode 100644 index 0000000000000..a981a7929b43b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Constant/Constant.swift @@ -0,0 +1,13 @@ +// +// Constant.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +enum Constant { + static let affineTabbarHeight: CGFloat = 44 + static let affineTintColor: UIColor = .init(red: 30 / 255, green: 150 / 255, blue: 235 / 255, alpha: 1.0) +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+String.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+String.swift new file mode 100644 index 0000000000000..7feddb601ec5d --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+String.swift @@ -0,0 +1,19 @@ +// +// Ext+String.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import Foundation + +extension String { + func localized() -> String { + let ans = NSLocalizedString(self, bundle: Bundle.module, comment: "") + guard !ans.isEmpty else { + assertionFailure() + return self + } + return ans + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIColor.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIColor.swift new file mode 100644 index 0000000000000..42cd8063109a6 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIColor.swift @@ -0,0 +1,18 @@ +// +// Ext+UIColor.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/13. +// + +import UIKit + +extension UIColor { + static var accent: UIColor { + guard let color = UIColor(named: "accent", in: .module, compatibleWith: nil) else { + assertionFailure() + return .systemBlue + } + return color + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIFont.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIFont.swift new file mode 100644 index 0000000000000..b94160a5c2e04 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIFont.swift @@ -0,0 +1,33 @@ +// +// Ext+UIFont.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import UIKit + +extension UIFont { + static func preferredFont(for style: TextStyle, weight: Weight, italic: Bool = false) -> UIFont { + // Get the style's default pointSize + let traits = UITraitCollection(preferredContentSizeCategory: .large) + let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style, compatibleWith: traits) + + // Get the font at the default size and preferred weight + var font = UIFont.systemFont(ofSize: desc.pointSize, weight: weight) + if italic == true { + font = font.with([.traitItalic]) + } + + // Setup the font to be auto-scalable + let metrics = UIFontMetrics(forTextStyle: style) + return metrics.scaledFont(for: font) + } + + private func with(_ traits: UIFontDescriptor.SymbolicTraits...) -> UIFont { + guard let descriptor = fontDescriptor.withSymbolicTraits(UIFontDescriptor.SymbolicTraits(traits).union(fontDescriptor.symbolicTraits)) else { + return self + } + return UIFont(descriptor: descriptor, size: 0) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIView.swift new file mode 100644 index 0000000000000..1da7e3b992af1 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIView.swift @@ -0,0 +1,46 @@ +// +// Ext+UIView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +extension UIView { + var parentViewController: UIViewController? { + var responder: UIResponder? = self + while responder != nil { + if let responder = responder as? UIViewController { + return responder + } + responder = responder?.next + } + return nil + } + + func removeEveryAutoResizingMasks() { + var views: [UIView] = [self] + while let view = views.first { + views.removeFirst() + view.translatesAutoresizingMaskIntoConstraints = false + view.subviews.forEach { views.append($0) } + } + } + + #if DEBUG + func debugFrame() { + layer.borderWidth = 1 + layer.borderColor = [ + UIColor.red, + .green, + .blue, + .yellow, + .cyan, + .magenta, + .orange, + ].map(\.cgColor).randomElement() + subviews.forEach { $0.debugFrame() } + } + #endif +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIViewController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIViewController.swift new file mode 100644 index 0000000000000..f1463d906426e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+UIViewController.swift @@ -0,0 +1,38 @@ +// +// Ext+UIViewController.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +public extension UIViewController { + func presentIntoCurrentContext(withTargetController targetController: UIViewController, animated: Bool = true) { + if let nav = self as? UINavigationController { + nav.pushViewController(targetController, animated: animated) + } else if let nav = navigationController { + nav.pushViewController(targetController, animated: animated) + } else { + present(targetController, animated: animated, completion: nil) + } + } + + func dismissInContext() { + if let nav = navigationController { + nav.popViewController(animated: true) + } else { + dismiss(animated: true, completion: nil) + } + } + + func hideKeyboardWhenTappedAround() { + let tap = UITapGestureRecognizer(target: self, action: #selector(UIViewController.dismissKeyboard)) + tap.cancelsTouchesInView = false + view.addGestureRecognizer(tap) + } + + @objc func dismissKeyboard() { + view.endEditing(true) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+print.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+print.swift new file mode 100644 index 0000000000000..1959304e7ef8e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Extension/Ext+print.swift @@ -0,0 +1,14 @@ +// +// Ext+print.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import Foundation + +public func print(_ items: Any..., separator: String = " ", terminator: String = "\n") { + #if DEBUG + Swift.print(items, separator: separator, terminator: terminator) + #endif +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Intelligents.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Intelligents.swift new file mode 100644 index 0000000000000..35011f582577b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Intelligents.swift @@ -0,0 +1,4 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +enum Intelligents {} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton+Control.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton+Control.swift new file mode 100644 index 0000000000000..0c7e0f62b6ed5 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton+Control.swift @@ -0,0 +1,84 @@ +// +// IntelligentsButton+Control.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +public extension UIViewController { + @discardableResult + func installIntelligentsButton() -> IntelligentsButton { + print("[*] \(#function)") + if let button = findIntelligentsButton() { return button } + + let button = IntelligentsButton() + view.addSubview(button) + view.bringSubviewToFront(button) + button.translatesAutoresizingMaskIntoConstraints = false + [ + button.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20), + button.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20 - Constant.affineTabbarHeight), + button.widthAnchor.constraint(equalToConstant: 50), + button.heightAnchor.constraint(equalToConstant: 50), + ].forEach { $0.isActive = true } + button.transform = .init(scaleX: 0, y: 0) + view.layoutIfNeeded() + return button + } + + private func findIntelligentsButton() -> IntelligentsButton? { + for subview in view.subviews { // for for depth 1 + if let button = subview as? IntelligentsButton { + return button + } + } + return nil + } + + func presentIntelligentsButton() { + guard let button = findIntelligentsButton() else { return } + print("[*] \(button) is calling \(#function)") + + button.alpha = 0 + button.isHidden = false + button.setNeedsLayout() + button.stopProgress() + view.layoutIfNeeded() + + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 1.0, + initialSpringVelocity: 0.8 + ) { + button.alpha = 1 + button.transform = .identity + button.setNeedsLayout() + self.view.layoutIfNeeded() + } + } + + func dismissIntelligentsButton() { + guard let button = findIntelligentsButton() else { return } + print("[*] \(button) is calling \(#function)") + + button.stopProgress() + button.setNeedsLayout() + view.layoutIfNeeded() + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 1.0, + initialSpringVelocity: 0.8 + ) { + button.alpha = 0 + button.transform = .init(scaleX: 0, y: 0) + button.setNeedsLayout() + self.view.layoutIfNeeded() + } completion: { _ in + button.isHidden = true + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton+Delegate.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton+Delegate.swift new file mode 100644 index 0000000000000..084be65002408 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton+Delegate.swift @@ -0,0 +1,12 @@ +// +// IntelligentsButton+Delegate.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import Foundation + +public protocol IntelligentsButtonDelegate: AnyObject { + func onIntelligentsButtonTapped(_ button: IntelligentsButton) +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton.swift new file mode 100644 index 0000000000000..38d0292b7a0a7 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsButton/IntelligentsButton.swift @@ -0,0 +1,90 @@ +// +// IntelligentsButton.swift +// +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +// floating button to open intelligent panel +public class IntelligentsButton: UIView { + let image = UIImageView() + let background = UIView() + let activityIndicator = UIActivityIndicatorView() + + public weak var delegate: (any IntelligentsButtonDelegate)? = nil { + didSet { assert(Thread.isMainThread) } + } + + public init() { + super.init(frame: .zero) + + background.backgroundColor = .white + addSubview(background) + background.translatesAutoresizingMaskIntoConstraints = false + [ + background.leadingAnchor.constraint(equalTo: leadingAnchor), + background.trailingAnchor.constraint(equalTo: trailingAnchor), + background.topAnchor.constraint(equalTo: topAnchor), + background.bottomAnchor.constraint(equalTo: bottomAnchor), + ].forEach { $0.isActive = true } + + image.image = .init(named: "spark", in: .module, with: .none) + image.contentMode = .scaleAspectFit + image.tintColor = Constant.affineTintColor + addSubview(image) + let imageInsetValue: CGFloat = 12 + image.translatesAutoresizingMaskIntoConstraints = false + [ + image.leadingAnchor.constraint(equalTo: leadingAnchor, constant: imageInsetValue), + image.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -imageInsetValue), + image.topAnchor.constraint(equalTo: topAnchor, constant: imageInsetValue), + image.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -imageInsetValue), + ].forEach { $0.isActive = true } + + addSubview(activityIndicator) + [ + activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), + ].forEach { $0.isActive = true } + + clipsToBounds = true + layer.borderWidth = 2 + layer.borderColor = UIColor.gray.withAlphaComponent(0.1).cgColor + + let tap = UITapGestureRecognizer(target: self, action: #selector(tapped)) + addGestureRecognizer(tap) + isUserInteractionEnabled = true + + stopProgress() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + deinit { + delegate = nil + } + + override public func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.width / 2 + } + + @objc func tapped() { + delegate?.onIntelligentsButtonTapped(self) + } + + public func beginProgress() { + activityIndicator.startAnimating() + activityIndicator.isHidden = false + } + + public func stopProgress() { + activityIndicator.stopAnimating() + activityIndicator.isHidden = true + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/Cell/ChatTableView+BaseCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/Cell/ChatTableView+BaseCell.swift new file mode 100644 index 0000000000000..0f2a4b237219a --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/Cell/ChatTableView+BaseCell.swift @@ -0,0 +1,100 @@ +// +// ChatTableView+BaseCell.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +private let initialInsetValue: CGFloat = 24 + +extension ChatTableView { + class BaseCell: UITableViewCell { + var inset: UIEdgeInsets { // available for overrides + .init( + top: initialInsetValue / 2, + left: initialInsetValue, + bottom: initialInsetValue / 2, + right: initialInsetValue + ) + } + + let containerView = UIView() + let roundedBackgroundView = UIView() + + var viewModel: AnyObject? { + didSet { update(via: viewModel) } + } + + enum BackgroundColorType { + case clear + case highlight + case warning + case lightGray + } + + var backgroundColorType: BackgroundColorType = .clear { + didSet { + roundedBackgroundView.backgroundColor = backgroundColorType.color + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + + roundedBackgroundView.clipsToBounds = true + roundedBackgroundView.layer.cornerRadius = 8 + roundedBackgroundView.layer.masksToBounds = true + + contentView.addSubview(roundedBackgroundView) + roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false + [ // inset half of the container view + roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset.left / 2), + roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset.right / 2), + roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset.top / 2), + roundedBackgroundView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset.bottom / 2), + ].forEach { $0.isActive = true } + + contentView.addSubview(containerView) + containerView.translatesAutoresizingMaskIntoConstraints = false + [ + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset.left), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset.right), + containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset.top), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset.bottom), + ].forEach { $0.isActive = true } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + override func prepareForReuse() { + super.prepareForReuse() + viewModel = nil + } + + func update(via object: AnyObject?) { + _ = object + } + } +} + +extension ChatTableView.BaseCell.BackgroundColorType { + var color: UIColor { + switch self { + case .clear: + .clear + case .highlight: + .accent + case .warning: + .systemRed.withAlphaComponent(0.1) + case .lightGray: + .systemGray.withAlphaComponent(0.1) + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/Cell/ChatTableView+ChatCell.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/Cell/ChatTableView+ChatCell.swift new file mode 100644 index 0000000000000..954dfdaac63f1 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/Cell/ChatTableView+ChatCell.swift @@ -0,0 +1,124 @@ +// +// ChatTableView+ChatCell.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import MarkdownUI +import UIKit + +extension ChatTableView { + class ChatCell: BaseCell { + let avatarView = CircleImageView() + let titleLabel = UILabel() + let markdownContainer = UIView() + + var markdownView: UIView? + var removableConstraints: [NSLayoutConstraint] = [] + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + let spacingElement: CGFloat = 12 + let avatarSize: CGFloat = 24 + + containerView.addSubview(avatarView) + avatarView.translatesAutoresizingMaskIntoConstraints = false + [ + avatarView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + avatarView.topAnchor.constraint(equalTo: containerView.topAnchor), + avatarView.widthAnchor.constraint(equalToConstant: avatarSize), + avatarView.heightAnchor.constraint(equalToConstant: avatarSize), + ].forEach { $0.isActive = true } + + titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .bold) + containerView.addSubview(titleLabel) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + [ + titleLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: spacingElement), + titleLabel.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ].forEach { $0.isActive = true } + + containerView.addSubview(markdownContainer) + markdownContainer.translatesAutoresizingMaskIntoConstraints = false + [ + markdownContainer.topAnchor.constraint(greaterThanOrEqualTo: avatarView.bottomAnchor, constant: spacingElement), + markdownContainer.topAnchor.constraint(greaterThanOrEqualTo: titleLabel.bottomAnchor, constant: spacingElement), + markdownContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0), + markdownContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0), + markdownContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0), + ].forEach { $0.isActive = true } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + override func update(via object: AnyObject?) { + super.update(via: object) + + guard let viewModel = object as? ViewModel else { + return + } + + switch viewModel.participant { + case .system: + avatarView.image = UIImage(systemName: "gearshape.fill") + titleLabel.text = "System".localized() + backgroundColorType = .warning + case .assistant: + avatarView.image = UIImage(named: "spark", in: .module, with: .none) + titleLabel.text = "AFFiNE AI".localized() + backgroundColorType = .lightGray + case .user: + avatarView.image = UIImage(systemName: "person.fill") + titleLabel.text = "You".localized() + backgroundColorType = .clear + } + + removableConstraints.forEach { $0.isActive = false } + if let markdownView { markdownView.removeFromSuperview() } + markdownContainer.subviews.forEach { $0.removeFromSuperview() } + + let hostingView: UIView = UIHostingView( + rootView: Markdown(.init(viewModel.markdownDocument)) + ) + defer { markdownView = hostingView } + markdownContainer.addSubview(hostingView) + + hostingView.translatesAutoresizingMaskIntoConstraints = false + [ + hostingView.topAnchor.constraint(equalTo: markdownContainer.topAnchor), + hostingView.leadingAnchor.constraint(equalTo: markdownContainer.leadingAnchor), + hostingView.trailingAnchor.constraint(lessThanOrEqualTo: markdownContainer.trailingAnchor), + hostingView.bottomAnchor.constraint(equalTo: markdownContainer.bottomAnchor), + ].forEach { + $0.isActive = true + removableConstraints.append($0) + } + } + } +} + +extension ChatTableView.ChatCell { + class ViewModel { + let participant: Participant + let markdownDocument: String + + init(participant: Participant, markdownDocument: String) { + self.participant = participant + self.markdownDocument = markdownDocument + } + } +} + +extension ChatTableView.ChatCell.ViewModel { + enum Participant { + case user + case assistant + case system + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView+Data.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView+Data.swift new file mode 100644 index 0000000000000..61faabcddcb92 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView+Data.swift @@ -0,0 +1,57 @@ +// +// ChatTableView+Data.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +extension ChatTableView { + struct DataElement { + enum CellType: String, CaseIterable { + case base + case chat + } + + let type: CellType + let object: AnyObject? + + init(type: CellType, object: AnyObject?) { + self.type = type + self.object = object + } + } +} + +extension ChatTableView.DataElement.CellType { + var cellClassType: ChatTableView.BaseCell.Type { + switch self { + case .base: + ChatTableView.BaseCell.self + case .chat: + ChatTableView.ChatCell.self + } + } + + var cellIdentifier: String { + NSStringFromClass(cellClassType) + } +} + +extension ChatTableView: UITableViewDelegate, UITableViewDataSource { + func numberOfSections(in _: UITableView) -> Int { + 1 + } + + func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { + dataSource.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: dataSource[indexPath.row].type.cellIdentifier, for: indexPath) as! BaseCell + let object = dataSource[indexPath.row].object + cell.update(via: object) + return cell + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView+Scroll.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView+Scroll.swift new file mode 100644 index 0000000000000..747144833f54b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView+Scroll.swift @@ -0,0 +1,105 @@ +// +// ChatTableView+Scroll.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/23. +// + +import MSDisplayLink +import SpringInterpolation +import UIKit + +extension ChatTableView: UIScrollViewDelegate, DisplayLinkDelegate { + func synchronization() { + let now = Date() + defer { scrollAnimationDeltaTimeHolder = now } + var deltaTime = now.timeIntervalSince(scrollAnimationDeltaTimeHolder) + if deltaTime > 0.5 { deltaTime = 0.5 } + guard scrollToBottomEnabled else { return } + DispatchQueue.main.async { self.tikVsync(deltaTime: deltaTime * 2) } + } + + var bottomLocationY: CGFloat { + tableView.contentSize.height - tableView.bounds.height + } + + private func tikVsync(deltaTime: TimeInterval) { + guard scrollToBottomEnabled else { return } + guard scrollToBottomAllowed else { return } + scrollAnimationContext.setTarget(bottomLocationY) + scrollAnimationContext.update(withDeltaTime: deltaTime) + tableView.contentOffset.y = scrollAnimationContext.value + } + + @inline(__always) + private func resetAnimationContext(to offset: CGFloat) { + scrollAnimationContext.context = .init( + currentPos: offset, + currentVel: 0, + targetPos: offset + ) + } + + func presentScrollBottomIfNeeded() { + let visible = !scrollToBottomEnabled && scrollToBottomAllowed + UIView.animate(withDuration: 0.25) { [self] in + scrollDownButton.alpha = visible ? 1 : 0 + } + } + + func animationEnabledToggleDidSet(oldValue: Bool) { + assert(Thread.isMainThread) + guard scrollToBottomEnabled != oldValue else { return } + resetAnimationContext(to: tableView.contentOffset.y) + } + + func animationAllowedToggleDidSet(oldValue: Bool) { + assert(Thread.isMainThread) + guard scrollToBottomAllowed != oldValue else { return } + resetAnimationContext(to: tableView.contentOffset.y) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + processScrollView(scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate _: Bool) { + processScrollView(scrollView) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + processScrollView(scrollView) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + processScrollView(scrollView) + } + + @inline(__always) + private func processScrollView(_ scrollView: UIScrollView) { + guard let tableView = scrollView as? UITableView else { + assertionFailure() + return + } + processTableViewMovements(tableView) + } + + private func processTableViewMovements(_ tableView: UITableView) { + defer { presentScrollBottomIfNeeded() } + if tableView.isDragging { + scrollToBottomEnabled = false + scrollToBottomAllowed = false + } else { + // 如果没在减速 就开启 允许滚动 + scrollToBottomAllowed = !tableView.isDecelerating + // 如果滚到底了 就开启 自动滚动 + let isBottom = tableView.contentOffset.y >= bottomLocationY + if isBottom { scrollToBottomEnabled = true } + } + } + + func scrollToBottom() { + scrollToBottomEnabled = true + scrollToBottomAllowed = true + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView.swift new file mode 100644 index 0000000000000..aadf848a474da --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ChatTableView.swift @@ -0,0 +1,117 @@ +// +// ChatTableView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import MSDisplayLink +import SpringInterpolation +import UIKit + +class ChatTableView: UIView { + let tableView = UITableView() + let footerView = UIView() + let scrollDownButton = ScrollBottom() + + var dataSource: [DataElement] = [] + + // 控制自动滚动 + var scrollToBottomEnabled = false { + didSet { animationEnabledToggleDidSet(oldValue: oldValue) } + } + + // 要让系统自己的动画完成以后再帮用户去挪 + var scrollToBottomAllowed = false { + didSet { animationAllowedToggleDidSet(oldValue: oldValue) } + } + + var scrollAnimationController: DisplayLink = .init() + var scrollAnimationContext: SpringInterpolation = .init() + var scrollAnimationDeltaTimeHolder: Date = .init() + + init() { + super.init(frame: .zero) + + for eachCase in DataElement.CellType.allCases { + let cellClass = eachCase.cellClassType + tableView.register(cellClass, forCellReuseIdentifier: eachCase.cellIdentifier) + } + + tableView.backgroundColor = .clear + + tableView.delegate = self + tableView.dataSource = self + addSubview(tableView) + + tableView.translatesAutoresizingMaskIntoConstraints = false + [ + tableView.topAnchor.constraint(equalTo: topAnchor), + tableView.leadingAnchor.constraint(equalTo: leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: bottomAnchor), + ].forEach { $0.isActive = true } + + footerView.translatesAutoresizingMaskIntoConstraints = false + footerView.heightAnchor.constraint(equalToConstant: 128).isActive = true + footerView.widthAnchor.constraint(equalToConstant: 128).isActive = true + tableView.tableFooterView = footerView + tableView.separatorStyle = .none + + addSubview(scrollDownButton) + scrollDownButton.translatesAutoresizingMaskIntoConstraints = false + [ + // right bottom inset 16 size 32x32 + scrollDownButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + scrollDownButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -32), + scrollDownButton.widthAnchor.constraint(equalToConstant: 32), + scrollDownButton.heightAnchor.constraint(equalToConstant: 32), + ].forEach { $0.isActive = true } + scrollDownButton.alpha = 0 + scrollDownButton.onTap = { [weak self] in + self?.scrollToBottom() + } + + scrollAnimationController.delegatingObject(self) + putMockData() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } +} + +extension ChatTableView { + func putMockData() { + DispatchQueue.main.async { + let json: [String: Any] = ["query": """ + { + currentUser { + email + name + } + } + """, "variables": [:]] + + let jsonData = try? JSONSerialization.data(withJSONObject: json) + + let url = URL(string: "https://affine.fail/graphql")! + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ + "content-type": "application/json", + ] + request.httpBody = jsonData + URLSession.shared.dataTask(with: request) { v1, _, _ in + guard let data = v1 else { return } + let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) + print(json) + }.resume() + + self.tableView.reloadData() + self.scrollToBottom() + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ScrollBottom.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ScrollBottom.swift new file mode 100644 index 0000000000000..02ca5d84de324 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/ChatTableView/ScrollBottom.swift @@ -0,0 +1,63 @@ +// +// ScrollBottom.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/23. +// + +import Foundation +import UIKit + +class ScrollBottom: UIView { + let imageView = UIImageView() + let backgroundView: UIView = UIVisualEffectView( + effect: UIBlurEffect(style: .systemUltraThinMaterialDark) + ) + + var onTap: (() -> Void)? + + init() { + super.init(frame: .zero) + + addSubview(backgroundView) + addSubview(imageView) + + imageView.contentMode = .scaleAspectFit + imageView.image = UIImage(systemName: "arrow.down") + imageView.tintColor = .accent + + isUserInteractionEnabled = true + let tapGesture = UITapGestureRecognizer( + target: self, + action: #selector(tapAction) + ) + addGestureRecognizer(tapGesture) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + override func layoutSubviews() { + super.layoutSubviews() + + clipsToBounds = true + + layer.cornerRadius = (bounds.width + bounds.height) / 4 + layer.masksToBounds = true + + backgroundView.frame = bounds + let imageInset = (bounds.width + bounds.height) / 8 + imageView.frame = CGRect( + x: imageInset, + y: imageInset, + width: bounds.width - 2 * imageInset, + height: bounds.height - 2 * imageInset + ) + } + + @objc private func tapAction() { + onTap?() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/AttachmentBannerView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/AttachmentBannerView.swift new file mode 100644 index 0000000000000..e659ff48ddd3b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/AttachmentBannerView.swift @@ -0,0 +1,163 @@ +// +// AttachmentBannerView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +private let attachmentSize: CGFloat = 100 +private let attachmentSpacing: CGFloat = 16 + +class AttachmentBannerView: UIScrollView { + var readAttachments: (() -> ([UIImage]))? + var onAttachmentsDelete: ((Int) -> Void)? + var attachments: [UIImage] { + get { readAttachments?() ?? [] } + set { assertionFailure() } + } + + override var intrinsicContentSize: CGSize { + if attachments.isEmpty { return .zero } + return .init( + width: (attachmentSize + attachmentSize) * CGFloat(attachments.count) + - attachmentSpacing, + height: attachmentSize + ) + } + + let stackView = UIStackView() + + init() { + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + + stackView.axis = .horizontal + stackView.spacing = attachmentSpacing + stackView.alignment = .center + stackView.distribution = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + [ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ].forEach { $0.isActive = true } + + rebuildViews() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + var reusableViews = [AttachmentPreviewView]() + + func rebuildViews() { + let attachments = attachments + + if reusableViews.count > attachments.count { + for index in attachments.count ..< reusableViews.count { + reusableViews[index].removeFromSuperview() + } + reusableViews.removeLast(reusableViews.count - attachments.count) + } + if reusableViews.count < attachments.count { + for _ in reusableViews.count ..< attachments.count { + let view = AttachmentPreviewView() + view.alpha = 0 + reusableViews.append(view) + } + } + + assert(reusableViews.count == attachments.count) + + for (index, attachment) in attachments.enumerated() { + let view = reusableViews[index] + view.imageView.image = attachment + stackView.addArrangedSubview(view) + view.deleteButtonAction = { [weak self] in + self?.onAttachmentsDelete?(index) + } + } + + invalidateIntrinsicContentSize() + contentSize = intrinsicContentSize + UIView.performWithoutAnimation { + self.layoutIfNeeded() + } + + UIView.animate(withDuration: 0.3) { + for view in self.reusableViews { + view.alpha = 1 + } + } + } +} + +extension AttachmentBannerView { + class AttachmentPreviewView: UIView { + let imageView = UIImageView() + let deleteButton = UIButton() + + var deleteButtonAction: (() -> Void)? + + override var intrinsicContentSize: CGSize { + .init(width: attachmentSize, height: attachmentSize) + } + + init() { + super.init(frame: .zero) + addSubview(imageView) + addSubview(deleteButton) + + layer.cornerRadius = 8 + clipsToBounds = true + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + [ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + ].forEach { $0.isActive = true } + + deleteButton.setImage(.init(named: "close", in: .module, with: nil), for: .normal) + deleteButton.imageView?.contentMode = .scaleAspectFit + deleteButton.tintColor = .white + deleteButton.translatesAutoresizingMaskIntoConstraints = false + [ + deleteButton.topAnchor.constraint(equalTo: topAnchor, constant: 4), + deleteButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + deleteButton.widthAnchor.constraint(equalToConstant: 32), + deleteButton.heightAnchor.constraint(equalToConstant: 32), + ].forEach { $0.isActive = true } + + deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) + + [ + widthAnchor.constraint(equalToConstant: attachmentSize), + heightAnchor.constraint(equalToConstant: attachmentSize), + ].forEach { $0.isActive = true } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + @objc func deleteButtonTapped() { + deleteButtonAction?() + deleteButtonAction = nil + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+Camera.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+Camera.swift new file mode 100644 index 0000000000000..eff4e8002c504 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+Camera.swift @@ -0,0 +1,65 @@ +// +// InputEditView+Camera.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/6. +// + +import AVKit +import UIKit + +extension InputEditView: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + @objc func takePhoto() { + AVCaptureDevice.requestAccess(for: .video) { _ in + DispatchQueue.main.async { + let ctrl = UIImagePickerController() + ctrl.allowsEditing = false + ctrl.sourceType = .camera + ctrl.mediaTypes = [UTType.movie.identifier, UTType.image.identifier] + ctrl.cameraCaptureMode = .photo + ctrl.delegate = self + self.parentViewController?.present(ctrl, animated: true) + } + } + } + + private func processJPEGImageData(_ image: UIImage) throws -> Data? { + guard let data = image.jpegData(compressionQuality: 0.75) else { + throw NSError(domain: "", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to compress image data", + ]) + } + return data + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true) { + var itemUrl: URL? + + if itemUrl == nil, + let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage + { + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("Camera") + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let tempFile = tempDir + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpeg") + try? self.processJPEGImageData(image)?.write(to: tempFile) + itemUrl = tempFile + } + if itemUrl == nil, + let url = info[.mediaURL] as? URL + { + itemUrl = url + } + + guard let url = itemUrl, FileManager.default.fileExists(atPath: url.path) else { + return + } + guard let image = UIImage(contentsOfFile: url.path) else { return } + try? FileManager.default.removeItem(at: url) + self.viewModel.attachments.append(image) + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+Photo.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+Photo.swift new file mode 100644 index 0000000000000..73fca18994152 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+Photo.swift @@ -0,0 +1,38 @@ +// +// InputEditView+Photo.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/6. +// + +import PhotosUI +import UIKit + +extension InputEditView: PHPickerViewControllerDelegate { + @objc func selectPhoto() { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .images + config.selectionLimit = 9 + let picker = PHPickerViewController(configuration: config) + picker.modalPresentationStyle = .formSheet + picker.delegate = self + parentViewController?.present(picker, animated: true, completion: nil) + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + loadPNG(from: results) + } + + private func loadPNG(from results: [PHPickerResult]) { + for result in results { + result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in + if let image = image as? UIImage { + DispatchQueue.main.async { + self?.viewModel.attachments.append(image) + } + } + } + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+ViewModel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+ViewModel.swift new file mode 100644 index 0000000000000..9634cff633b49 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView+ViewModel.swift @@ -0,0 +1,48 @@ +// +// InputEditView+ViewModel.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import Combine +import UIKit + +extension InputEditView { + class ViewModel: ObservableObject { + var cancellables: Set = [] + + @Published var text: String = "" + @Published var attachments: [UIImage] = [] + + init() {} + + deinit { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + func reset() { + text = "" + attachments = [] + } + + func duplicate() -> ViewModel { + let ans = ViewModel() + ans.text = text + ans.attachments = attachments + return ans + } + } +} + +extension InputEditView.ViewModel: Hashable, Equatable { + func hash(into hasher: inout Hasher) { + hasher.combine(text) + hasher.combine(attachments) + } + + static func == (lhs: InputEditView.ViewModel, rhs: InputEditView.ViewModel) -> Bool { + lhs.hashValue == rhs.hashValue + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView.swift new file mode 100644 index 0000000000000..f469698082c55 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/InputEditView.swift @@ -0,0 +1,132 @@ +// +// InputEditView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import Combine +import UIKit + +class InputEditView: UIView, UITextViewDelegate { + let mainStack = UIStackView() + let attachmentsEditor = AttachmentBannerView() + let textEditor = PlainTextEditView() + let placeholderLabel = UILabel() + let controlBanner = TextEditControlBanner() + + let viewModel = ViewModel() + var placeholderText: String = "" { + didSet { + placeholderLabel.text = placeholderText + } + } + + init() { + super.init(frame: .zero) + + addSubview(mainStack) + mainStack.translatesAutoresizingMaskIntoConstraints = false + mainStack.axis = .vertical + mainStack.spacing = 16 + mainStack.alignment = .fill + mainStack.distribution = .equalSpacing + [ + mainStack.topAnchor.constraint(equalTo: topAnchor), + mainStack.leadingAnchor.constraint(equalTo: leadingAnchor), + mainStack.trailingAnchor.constraint(equalTo: trailingAnchor), + mainStack.bottomAnchor.constraint(equalTo: bottomAnchor), + ].forEach { $0.isActive = true } + + textEditor.delegate = self + textEditor.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).isActive = true + + [ + attachmentsEditor, textEditor, controlBanner, + ].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + mainStack.addArrangedSubview($0) + [ + $0.leadingAnchor.constraint(equalTo: mainStack.leadingAnchor), + $0.trailingAnchor.constraint(equalTo: mainStack.trailingAnchor), + ].forEach { $0.isActive = true } + } + + attachmentsEditor.readAttachments = { [weak self] in + self?.viewModel.attachments ?? [] + } + attachmentsEditor.onAttachmentsDelete = { [weak self] index in + self?.viewModel.attachments.remove(at: index) + } + + controlBanner.cameraButton.addTarget( + self, + action: #selector(takePhoto), + for: .touchUpInside + ) + controlBanner.photoButton.addTarget( + self, + action: #selector(selectPhoto), + for: .touchUpInside + ) + + textEditor.returnKeyType = .send + textEditor.addSubview(placeholderLabel) + placeholderLabel.textColor = .label.withAlphaComponent(0.25) + placeholderLabel.font = textEditor.font + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + [ + placeholderLabel.leadingAnchor.constraint(equalTo: textEditor.leadingAnchor, constant: 2), + placeholderLabel.trailingAnchor.constraint(equalTo: textEditor.trailingAnchor, constant: -2), + placeholderLabel.topAnchor.constraint(equalTo: textEditor.topAnchor, constant: 0), + ].forEach { $0.isActive = true } + + viewModel.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateValues() + } + .store(in: &viewModel.cancellables) + + updateValues() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + func textViewDidChange(_ textView: UITextView) { + viewModel.text = textView.text + } + + func textViewDidBeginEditing(_: UITextView) { + updatePlaceholderVisibility() + } + + func textViewDidEndEditing(_: UITextView) { + updatePlaceholderVisibility() + } + + func updatePlaceholderVisibility() { + let visible = viewModel.text.isEmpty && !textEditor.isFirstResponder + UIView.animate(withDuration: 0.25) { + self.placeholderLabel.alpha = visible ? 1 : 0 + } + } + + func updateValues() { + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 1.0, + initialSpringVelocity: 0.8 + ) { [self] in + if textEditor.text != viewModel.text { + textEditor.text = viewModel.text + } + attachmentsEditor.rebuildViews() + parentViewController?.view.layoutIfNeeded() + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/PlainTextEditView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/PlainTextEditView.swift new file mode 100644 index 0000000000000..b314b321232d6 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/PlainTextEditView.swift @@ -0,0 +1,37 @@ +// +// PlainTextEditView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +class PlainTextEditView: UITextView, UITextViewDelegate { + init() { + super.init(frame: .zero, textContainer: nil) + + delegate = self + tintColor = Constant.affineTintColor + + linkTextAttributes = [:] + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + textContainer.lineFragmentPadding = .zero + textAlignment = .natural + backgroundColor = .clear + textContainerInset = .zero + textContainer.lineBreakMode = .byTruncatingTail + isScrollEnabled = false + clipsToBounds = false + + isEditable = true + isSelectable = true + isScrollEnabled = false + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/TextEditControlBanner.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/TextEditControlBanner.swift new file mode 100644 index 0000000000000..7b28e6ba4c752 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/InputEditView/TextEditControlBanner.swift @@ -0,0 +1,62 @@ +// +// TextEditControlBanner.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +class TextEditControlBanner: UIStackView { + static let height: CGFloat = 32 + + let cameraButton = UIButton() + let photoButton = UIButton() + + let spacer = UIView() + + let sendButton = UIButton() + + init() { + super.init(frame: .zero) + + axis = .horizontal + spacing = 16 + alignment = .center + distribution = .fill + + [ + heightAnchor.constraint(equalToConstant: Self.height), + ].forEach { $0.isActive = true } + + [ + cameraButton, photoButton, + sendButton, + ].forEach { + $0.widthAnchor.constraint(equalToConstant: Self.height).isActive = true + $0.heightAnchor.constraint(equalToConstant: Self.height).isActive = true + } + + [ + cameraButton, photoButton, + spacer, + sendButton, + ].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addArrangedSubview($0) + } + + cameraButton.setImage(.init(systemName: "camera"), for: .normal) + cameraButton.tintColor = .label + photoButton.setImage(.init(systemName: "photo"), for: .normal) + photoButton.tintColor = .label + + sendButton.setImage(.init(systemName: "paperplane.fill"), for: .normal) + sendButton.tintColor = .label + } + + @available(*, unavailable) + required init(coder _: NSCoder) { + fatalError() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+Header.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+Header.swift new file mode 100644 index 0000000000000..2d57f076a36bc --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+Header.swift @@ -0,0 +1,108 @@ +// +// IntelligentsChatController+Header.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +extension IntelligentsChatController { + class Header: UIView { + static let height: CGFloat = 44 + + let contentView = UIView() + let titleLabel = UILabel() + let dropMenu = UIButton() + let backButton = UIButton() + let rightBarItemsStack = UIStackView() + let moreMenu = UIButton() + + init() { + super.init(frame: .zero) + setupLayout() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + @objc func navigateActionBack() { + parentViewController?.dismissInContext() + } + } +} + +private extension IntelligentsChatController.Header { + func setupLayout() { + contentView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentView) + [ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + contentView.heightAnchor.constraint(equalToConstant: Self.height), + ].forEach { $0.isActive = true } + + titleLabel.textColor = .label + titleLabel.font = .systemFont( + ofSize: UIFont.labelFontSize, + weight: .semibold + ) + + backButton.setImage( + UIImage(systemName: "chevron.left"), + for: .normal + ) + backButton.tintColor = Constant.affineTintColor + backButton.addTarget(self, action: #selector(navigateActionBack), for: .touchUpInside) + + dropMenu.setImage( + .init(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), + for: .normal + ) + dropMenu.tintColor = .gray.withAlphaComponent(0.5) + + contentView.addSubview(titleLabel) + contentView.addSubview(backButton) + contentView.addSubview(dropMenu) + contentView.addSubview(rightBarItemsStack) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + backButton.translatesAutoresizingMaskIntoConstraints = false + dropMenu.translatesAutoresizingMaskIntoConstraints = false + rightBarItemsStack.translatesAutoresizingMaskIntoConstraints = false + + rightBarItemsStack.axis = .horizontal + rightBarItemsStack.spacing = 10 + rightBarItemsStack.alignment = .center + rightBarItemsStack.distribution = .equalSpacing + + [ + backButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), + backButton.widthAnchor.constraint(equalToConstant: 44), + backButton.heightAnchor.constraint(equalToConstant: 44), + + rightBarItemsStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + rightBarItemsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10), + rightBarItemsStack.heightAnchor.constraint(equalToConstant: 44), + + titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: backButton.trailingAnchor, constant: 10), + + dropMenu.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + dropMenu.widthAnchor.constraint(equalToConstant: 44), + dropMenu.heightAnchor.constraint(equalToConstant: 44), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: dropMenu.leadingAnchor, constant: -10), + ].forEach { $0.isActive = true } + + rightBarItemsStack.addArrangedSubview(moreMenu) + moreMenu.setImage( + .init(systemName: "ellipsis.circle"), + for: .normal + ) + moreMenu.tintColor = Constant.affineTintColor + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+InputBox.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+InputBox.swift new file mode 100644 index 0000000000000..e9be334c25c11 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+InputBox.swift @@ -0,0 +1,61 @@ +// +// IntelligentsChatController+InputBox.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +extension IntelligentsChatController { + class InputBox: UIView { + let backgroundView = UIView() + let editor = InputEditView() + + init() { + super.init(frame: .zero) + + setupLayout() + + editor.textEditor.font = UIFont.systemFont(ofSize: UIFont.labelFontSize) + editor.placeholderText = "Summarize this article for me...".localized() + + backgroundView.backgroundColor = .systemBackground + backgroundView.layer.cornerRadius = 16 + backgroundView.layer.shadowColor = UIColor.black.withAlphaComponent(0.25).cgColor + backgroundView.layer.shadowOffset = .init(width: 0, height: 0) + backgroundView.layer.shadowRadius = 8 + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + } +} + +private extension IntelligentsChatController.InputBox { + func setupLayout() { + addSubview(backgroundView) + backgroundView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(editor) + editor.translatesAutoresizingMaskIntoConstraints = false + + let inset: CGFloat = 16 + + [ + editor.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset), + editor.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset), + editor.topAnchor.constraint(equalTo: topAnchor, constant: inset), + editor.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset), + ].forEach { $0.isActive = true } + + [ + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0), + backgroundView.topAnchor.constraint(equalTo: topAnchor, constant: 0), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 128), + ].forEach { $0.isActive = true } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+Keyboard.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+Keyboard.swift new file mode 100644 index 0000000000000..bb26c8a6d2eb1 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController+Keyboard.swift @@ -0,0 +1,43 @@ +// +// IntelligentsChatController+Keyboard.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/6. +// + +import UIKit + +extension IntelligentsChatController { + @objc func keyboardWillAppear(_ notification: Notification) { + let info = notification.userInfo ?? [:] + let keyboardHeight = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)? + .cgRectValue + .height ?? 0 + inputBoxKeyboardAdapterHeightConstraint.constant = keyboardHeight + view.setNeedsUpdateConstraints() + animateWithKeyboard(userInfo: info) + } + + @objc func keyboardWillDisappear(_ notification: Notification) { + let info = notification.userInfo ?? [:] + inputBoxKeyboardAdapterHeightConstraint.constant = 0 + view.setNeedsUpdateConstraints() + animateWithKeyboard(userInfo: info) + } + + private func animateWithKeyboard(userInfo info: [AnyHashable: Any]) { + let keyboardAnimationDuration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)? + .doubleValue ?? 0 + let keyboardAnimationCurve = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)? + .uintValue ?? 0 + UIView.animate( + withDuration: keyboardAnimationDuration, + delay: 0, + options: UIView.AnimationOptions(rawValue: keyboardAnimationCurve), + animations: { + self.view.layoutIfNeeded() + }, + completion: nil + ) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController.swift new file mode 100644 index 0000000000000..06e1daaeb7f82 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsChatController/IntelligentsChatController.swift @@ -0,0 +1,154 @@ +// +// IntelligentsChatController.swift +// +// +// Created by 秋星桥 on 2024/11/18. +// + +import UIKit + +public class IntelligentsChatController: UIViewController { + let header = Header() + let inputBoxKeyboardAdapter = UIView() + let inputBox = InputBox() + let progressView = UIActivityIndicatorView() + let tableView = ChatTableView() + + var inputBoxKeyboardAdapterHeightConstraint = NSLayoutConstraint() + + override public var title: String? { + set { + super.title = newValue + header.titleLabel.text = newValue + } + get { + super.title + } + } + + public init() { + super.init(nibName: nil, bundle: nil) + title = "Chat with AI".localized() + + overrideUserInterfaceStyle = .dark + + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillDisappear), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillAppear), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override public func viewDidLoad() { + super.viewDidLoad() + assert(navigationController != nil) + view.backgroundColor = .secondarySystemBackground + + hideKeyboardWhenTappedAround() + setupLayout() + } + + func setupLayout() { + view.addSubview(header) + header.translatesAutoresizingMaskIntoConstraints = false + [ + header.topAnchor.constraint(equalTo: view.topAnchor), + header.leadingAnchor.constraint(equalTo: view.leadingAnchor), + header.trailingAnchor.constraint(equalTo: view.trailingAnchor), + header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44), + ].forEach { $0.isActive = true } + + view.addSubview(inputBoxKeyboardAdapter) + inputBoxKeyboardAdapter.translatesAutoresizingMaskIntoConstraints = false + [ + inputBoxKeyboardAdapter.leadingAnchor.constraint(equalTo: view.leadingAnchor), + inputBoxKeyboardAdapter.trailingAnchor.constraint(equalTo: view.trailingAnchor), + inputBoxKeyboardAdapter.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ].forEach { $0.isActive = true } + inputBoxKeyboardAdapterHeightConstraint = inputBoxKeyboardAdapter.heightAnchor.constraint(equalToConstant: 0) + inputBoxKeyboardAdapterHeightConstraint.isActive = true + inputBoxKeyboardAdapter.backgroundColor = inputBox.backgroundView.backgroundColor + + view.addSubview(inputBox) + inputBox.translatesAutoresizingMaskIntoConstraints = false + [ + inputBox.leadingAnchor.constraint(equalTo: view.leadingAnchor), + inputBox.trailingAnchor.constraint(equalTo: view.trailingAnchor), + inputBox.bottomAnchor.constraint(equalTo: inputBoxKeyboardAdapter.topAnchor), + ].forEach { $0.isActive = true } + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + [ + tableView.topAnchor.constraint(equalTo: header.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor, constant: 16), + ].forEach { $0.isActive = true } + + view.addSubview(progressView) + progressView.hidesWhenStopped = true + progressView.stopAnimating() + progressView.translatesAutoresizingMaskIntoConstraints = false + [ + progressView.centerXAnchor.constraint(equalTo: inputBox.centerXAnchor), + progressView.centerYAnchor.constraint(equalTo: inputBox.centerYAnchor), + ].forEach { $0.isActive = true } + progressView.style = .large + + view.bringSubviewToFront(inputBox) + inputBox.editor.controlBanner.sendButton.addTarget( + self, + action: #selector(send), + for: .touchUpInside + ) + } + + @objc func send() { + assert(Thread.isMainThread) + inputBox.isUserInteractionEnabled = false + progressView.startAnimating() + progressView.isHidden = false + progressView.alpha = 0 + UIView.animate(withDuration: 0.3) { + self.inputBox.editor.alpha = 0 + self.progressView.alpha = 1 + } completion: { _ in + let viewModel = self.inputBox.editor.viewModel.duplicate() + self.inputBox.editor.viewModel.reset() + DispatchQueue.global().async { + self.sendSyncEx(viewModel: viewModel) + DispatchQueue.main.async { + UIView.animate(withDuration: 0.3) { + self.inputBox.editor.alpha = 1 + self.progressView.alpha = 0 + } completion: { _ in + self.inputBox.isUserInteractionEnabled = true + self.progressView.stopAnimating() + } + } + } + } + } + + private func sendSyncEx(viewModel: InputEditView.ViewModel) { + let text = viewModel.text + let images = viewModel.attachments + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+ActionButton.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+ActionButton.swift new file mode 100644 index 0000000000000..03a39267eca3f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+ActionButton.swift @@ -0,0 +1,76 @@ +// +// IntelligentsFocusApertureView+ActionButton.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import UIKit + +extension IntelligentsFocusApertureView.ControlButtonsPanel { + class DarkActionButton: UIView { + var iconSystemName: String { + set { iconView.image = UIImage(systemName: newValue) } + get { fatalError() } + } + + var title: String { + set { titleLabel.text = newValue } + get { titleLabel.text ?? "" } + } + + let titleLabel = UILabel() + let iconView = UIImageView() + var action: (() -> Void)? = nil + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white.withAlphaComponent(0.25) + layer.cornerRadius = 12 + + let layoutGuide = UILayoutGuide() + addLayoutGuide(layoutGuide) + + titleLabel.textAlignment = .center + titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .semibold) + titleLabel.textColor = .white + addSubview(titleLabel) + + iconView.contentMode = .scaleAspectFit + iconView.tintColor = .white + addSubview(iconView) + + [ + layoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor), + layoutGuide.centerYAnchor.constraint(equalTo: centerYAnchor), + + iconView.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor), + iconView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor), + iconView.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor), + iconView.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor), + + titleLabel.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor), + titleLabel.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor), + titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor), + titleLabel.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), + ].forEach { $0.isActive = true } + + isUserInteractionEnabled = true + addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(onTapped) + )) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + @objc func onTapped() { + action?() + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Capture.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Capture.swift new file mode 100644 index 0000000000000..f820b9bcc6abd --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Capture.swift @@ -0,0 +1,22 @@ +// +// IntelligentsFocusApertureView+Capture.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import UIKit + +extension IntelligentsFocusApertureView { + func captureImageBuffer(_ targetContentView: UIView) { + let imageSize = targetContentView.frame.size + let renderer = UIGraphicsImageRenderer(size: imageSize) + let image = renderer.image { _ in + targetContentView.drawHierarchy( + in: targetContentView.bounds, + afterScreenUpdates: false + ) + } + capturedImage = image + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Delegate.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Delegate.swift new file mode 100644 index 0000000000000..bbd7d14e49f8e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Delegate.swift @@ -0,0 +1,19 @@ +// +// IntelligentsFocusApertureView+Delegate.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import Foundation + +public enum IntelligentsFocusApertureViewActionType: String { + case translateTo + case summary + case chatWithAI + case dismiss +} + +public protocol IntelligentsFocusApertureViewDelegate: AnyObject { + func focusApertureRequestAction(actionType: IntelligentsFocusApertureViewActionType) +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Layout.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Layout.swift new file mode 100644 index 0000000000000..ab247ceaa7b7f --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Layout.swift @@ -0,0 +1,89 @@ +// +// IntelligentsFocusApertureView+Layout.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import UIKit + +extension IntelligentsFocusApertureView { + func prepareFrameLayout() { + guard let viewController = targetViewController, + let view = viewController.view + else { + assertionFailure() + return + } + let safeLayout = viewController.view.safeAreaLayoutGuide + + frameConstraints = [ + // use safe area to layout content views + leadingAnchor.constraint(equalTo: safeLayout.leadingAnchor), + trailingAnchor.constraint(equalTo: safeLayout.trailingAnchor), + topAnchor.constraint(equalTo: safeLayout.topAnchor), + bottomAnchor.constraint(equalTo: safeLayout.bottomAnchor), + // cover all safe area so use constraints over view + backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backgroundView.topAnchor.constraint(equalTo: view.topAnchor), + backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + } + + func prepareContentLayouts() { + guard let targetView else { + assertionFailure() + return + } + + contentBeginConstraints = [ + snapshotView.leftAnchor.constraint(equalTo: targetView.leftAnchor), + snapshotView.rightAnchor.constraint(equalTo: targetView.rightAnchor), + snapshotView.topAnchor.constraint(equalTo: targetView.topAnchor), + snapshotView.bottomAnchor.constraint(equalTo: targetView.bottomAnchor), + + controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor), + controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor), + controlButtonsPanel.topAnchor.constraint(equalTo: bottomAnchor), + ] + + let sharedInset: CGFloat = 32 + contentFinalConstraints = [ + snapshotView.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset), + snapshotView.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset), + snapshotView.topAnchor.constraint(equalTo: topAnchor), + snapshotView.bottomAnchor.constraint(equalTo: controlButtonsPanel.topAnchor, constant: -sharedInset / 2), + + controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset), + controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset), + controlButtonsPanel.bottomAnchor.constraint(equalTo: bottomAnchor), + ] + } + + enum LayoutType { + case begin + case complete + } + + func activateLayoutForAnimation(_ type: LayoutType) { + NSLayoutConstraint.activate(frameConstraints) + switch type { + case .begin: + NSLayoutConstraint.deactivate(contentFinalConstraints) + NSLayoutConstraint.activate(contentBeginConstraints) + + snapshotView.layer.cornerRadius = 0 + case .complete: + NSLayoutConstraint.deactivate(contentBeginConstraints) + NSLayoutConstraint.activate(contentFinalConstraints) + + snapshotView.layer.cornerRadius = 32 + } + let effectiveView = superview ?? self + effectiveView.setNeedsUpdateConstraints() + effectiveView.setNeedsLayout() + updateConstraints() + layoutIfNeeded() + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Panel.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Panel.swift new file mode 100644 index 0000000000000..53f745414c0e6 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView+Panel.swift @@ -0,0 +1,115 @@ +// +// IntelligentsFocusApertureView+Panel.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import UIKit + +extension IntelligentsFocusApertureView { + class ControlButtonsPanel: UIView { + let headerLabel = UILabel() + let headerIcon = UIImageView() + + let translateButton = DarkActionButton() + let summaryButton = DarkActionButton() + let chatWithAIButton = DarkActionButton() + + init() { + super.init(frame: .zero) + defer { removeEveryAutoResizingMasks() } + + let contentSpacing: CGFloat = 16 + let buttonGroupHeight: CGFloat = 55 + + let headerGroup = UIView() + addSubview(headerGroup) + [ + headerGroup.topAnchor.constraint(equalTo: topAnchor), + headerGroup.leadingAnchor.constraint(equalTo: leadingAnchor), + headerGroup.trailingAnchor.constraint(equalTo: trailingAnchor), + ].forEach { $0.isActive = true } + + headerLabel.text = NSLocalizedString("AFFiNE AI", comment: "") // TODO: FREE TRAIL??? + // title 3 with bold + headerLabel.font = .preferredFont(for: .title3, weight: .bold) + headerLabel.textColor = .white + headerLabel.textAlignment = .left + headerIcon.image = .init(named: "spark", in: .module, with: nil) + headerIcon.contentMode = .scaleAspectFit + headerIcon.tintColor = Constant.affineTintColor + headerGroup.addSubview(headerLabel) + headerGroup.addSubview(headerIcon) + [ + headerLabel.topAnchor.constraint(equalTo: headerGroup.topAnchor), + headerLabel.leadingAnchor.constraint(equalTo: headerGroup.leadingAnchor), + headerLabel.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor), + + headerIcon.topAnchor.constraint(equalTo: headerGroup.topAnchor), + headerIcon.trailingAnchor.constraint(equalTo: headerGroup.trailingAnchor), + headerIcon.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor), + + headerIcon.widthAnchor.constraint(equalToConstant: 32), + headerIcon.trailingAnchor.constraint(equalTo: headerGroup.trailingAnchor), + headerIcon.leadingAnchor.constraint(equalTo: headerLabel.trailingAnchor, constant: contentSpacing), + ].forEach { $0.isActive = true } + + let firstButtonSectionGroup = UIView() + addSubview(firstButtonSectionGroup) + [ + firstButtonSectionGroup.topAnchor.constraint(equalTo: headerGroup.bottomAnchor, constant: contentSpacing), + firstButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor), + firstButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor), + firstButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight), + ].forEach { $0.isActive = true } + + translateButton.title = NSLocalizedString("Translate", comment: "") + translateButton.iconSystemName = "textformat" + summaryButton.title = NSLocalizedString("Summary", comment: "") + summaryButton.iconSystemName = "doc.text" + firstButtonSectionGroup.addSubview(translateButton) + firstButtonSectionGroup.addSubview(summaryButton) + [ + translateButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor), + translateButton.leadingAnchor.constraint(equalTo: firstButtonSectionGroup.leadingAnchor), + translateButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor), + + summaryButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor), + summaryButton.trailingAnchor.constraint(equalTo: firstButtonSectionGroup.trailingAnchor), + summaryButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor), + + translateButton.widthAnchor.constraint(equalTo: summaryButton.widthAnchor), + translateButton.trailingAnchor.constraint(equalTo: summaryButton.leadingAnchor, constant: -contentSpacing), + ].forEach { $0.isActive = true } + + let secondButtonSectionGroup = UIView() + addSubview(secondButtonSectionGroup) + [ + secondButtonSectionGroup.topAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor, constant: contentSpacing), + secondButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor), + secondButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor), + secondButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight), + ].forEach { $0.isActive = true } + + secondButtonSectionGroup.addSubview(chatWithAIButton) + chatWithAIButton.title = NSLocalizedString("Chat with AI", comment: "") + chatWithAIButton.iconSystemName = "paperplane" + [ + chatWithAIButton.topAnchor.constraint(equalTo: secondButtonSectionGroup.topAnchor), + chatWithAIButton.leadingAnchor.constraint(equalTo: secondButtonSectionGroup.leadingAnchor), + chatWithAIButton.bottomAnchor.constraint(equalTo: secondButtonSectionGroup.bottomAnchor), + chatWithAIButton.trailingAnchor.constraint(equalTo: secondButtonSectionGroup.trailingAnchor), + ].forEach { $0.isActive = true } + + [ + secondButtonSectionGroup.bottomAnchor.constraint(equalTo: bottomAnchor), + ].forEach { $0.isActive = true } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView.swift new file mode 100644 index 0000000000000..e79fb3faeb99d --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/IntelligentsFocusApertureView/IntelligentsFocusApertureView.swift @@ -0,0 +1,128 @@ +// +// IntelligentsFocusApertureView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/21. +// + +import UIKit + +public class IntelligentsFocusApertureView: UIView { + let backgroundView = UIView() + let snapshotView = UIImageView() + let controlButtonsPanel = ControlButtonsPanel() + + public var animationDuration: TimeInterval = 0.75 + + public internal(set) weak var targetView: UIView? + public internal(set) weak var targetViewController: UIViewController? + public internal(set) weak var capturedImage: UIImage? { + get { snapshotView.image } + set { snapshotView.image = newValue } + } + + var frameConstraints: [NSLayoutConstraint] = [] + var contentBeginConstraints: [NSLayoutConstraint] = [] + var contentFinalConstraints: [NSLayoutConstraint] = [] + + public weak var delegate: (any IntelligentsFocusApertureViewDelegate)? + + public init() { + super.init(frame: .zero) + + backgroundView.backgroundColor = .black + backgroundView.isUserInteractionEnabled = true + backgroundView.addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(dismissFocus) + )) + + snapshotView.setContentHuggingPriority(.defaultLow, for: .vertical) + snapshotView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + snapshotView.layer.contentsGravity = .top + snapshotView.layer.masksToBounds = true + snapshotView.contentMode = .scaleAspectFill + snapshotView.isUserInteractionEnabled = true + snapshotView.addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(dismissFocus) + )) + + addSubview(backgroundView) + addSubview(controlButtonsPanel) + addSubview(snapshotView) + bringSubviewToFront(snapshotView) + + controlButtonsPanel.translateButton.action = { [weak self] in + self?.delegate?.focusApertureRequestAction(actionType: .translateTo) + } + controlButtonsPanel.summaryButton.action = { [weak self] in + self?.delegate?.focusApertureRequestAction(actionType: .summary) + } + controlButtonsPanel.chatWithAIButton.action = { [weak self] in + self?.delegate?.focusApertureRequestAction(actionType: .chatWithAI) + } + removeEveryAutoResizingMasks() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + public func prepareAnimationWith( + capturingTargetContentView targetContentView: UIView, + coveringRootViewController viewController: UIViewController + ) { + captureImageBuffer(targetContentView) + + targetView = targetContentView + targetViewController = viewController + + viewController.view.addSubview(self) + + prepareFrameLayout() + prepareContentLayouts() + activateLayoutForAnimation(.begin) + } + + public func executeAnimationKickIn(_ completion: @escaping () -> Void = {}) { + activateLayoutForAnimation(.begin) + isUserInteractionEnabled = false + UIView.animate( + withDuration: animationDuration, + delay: 0, + usingSpringWithDamping: 1.0, + initialSpringVelocity: 0.8 + ) { + self.activateLayoutForAnimation(.complete) + } completion: { _ in + self.isUserInteractionEnabled = true + completion() + } + } + + public func executeAnimationDismiss(_ completion: @escaping () -> Void = {}) { + activateLayoutForAnimation(.complete) + isUserInteractionEnabled = false + UIView.animate( + withDuration: animationDuration, + delay: 0, + usingSpringWithDamping: 1.0, + initialSpringVelocity: 0.8 + ) { + self.activateLayoutForAnimation(.begin) + } completion: { _ in + self.isUserInteractionEnabled = true + completion() + } + } + + @objc func dismissFocus() { + isUserInteractionEnabled = false + executeAnimationDismiss { + self.removeFromSuperview() + self.delegate?.focusApertureRequestAction(actionType: .dismiss) + } + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/Chat.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/Chat.swift new file mode 100644 index 0000000000000..85fba639b6e25 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Model/Chat.swift @@ -0,0 +1,27 @@ +// +// Chat.swift +// Intelligents +// +// Created by 秋星桥 on 2024/11/18. +// + +import Foundation + +struct Chat: Codable { + enum ParticipantType: String, Codable, Equatable { + case user + case bot + } + + var participant: ParticipantType + + typealias MarkdownDocument = String + var content: MarkdownDocument + var date: Date + + init(participant: ParticipantType, content: MarkdownDocument, date: Date = .init()) { + self.participant = participant + self.content = content + self.date = date + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/Contents.json new file mode 100644 index 0000000000000..74d6a722cf39b --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/accent.colorset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/accent.colorset/Contents.json new file mode 100644 index 0000000000000..e968c86373ac4 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/accent.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "228", + "green": "148", + "red": "72" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/close.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/close.imageset/Contents.json new file mode 100644 index 0000000000000..18fdc0dd3115c --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/close.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "close.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/close.imageset/close.svg b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/close.imageset/close.svg new file mode 100644 index 0000000000000..2681b0ecf9a69 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/close.imageset/close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/spark.imageset/Contents.json b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/spark.imageset/Contents.json new file mode 100644 index 0000000000000..e5c2a0117e54e --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/spark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "spark.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/spark.imageset/spark.svg b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/spark.imageset/spark.svg new file mode 100644 index 0000000000000..9be7fe670c627 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/Media.xcassets/spark.imageset/spark.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/en.lproj/Localizable.strings b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000000000..159c0684fcf2c --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/en.lproj/Localizable.strings @@ -0,0 +1,31 @@ +/* + Localizable.strings + Intelligents + + Created by 秋星桥 on 2024/11/18. + +*/ + +/* No comment provided by engineer. */ +"Chat with AI" = "Chat with AI"; + +/* No comment provided by engineer. */ +"AFFiNE AI" = "AFFiNE AI"; + +/* No comment provided by engineer. */ +"Translate" = "Translate"; + +/* No comment provided by engineer. */ +"Summary" = "Summary"; + +/* No comment provided by engineer. */ +"Summarize this article for me..." = "Summarize this article for me..."; + +/* No comment provided by engineer. */ +"System" = "System"; + +/* No comment provided by engineer. */ +"AFFiNE AI" = "AFFiNE AI"; + +/* No comment provided by engineer. */ +"You" = "You"; diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/zh-Hans.lproj/Localizable.strings b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000000000..3e65ad82656af --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,31 @@ +/* + Localizable.strings + Intelligents + + Created by 秋星桥 on 2024/11/18. + +*/ + +/* No comment provided by engineer. */ +"Chat with AI" = "与 AI 聊天"; + +/* No comment provided by engineer. */ +"AFFiNE AI" = "AFFiNE 人工智能"; + +/* No comment provided by engineer. */ +"Translate" = "翻译"; + +/* No comment provided by engineer. */ +"Summary" = "总结"; + +/* No comment provided by engineer. */ +"Summarize this article for me..." = "请为我总结这份文档..."; + +/* No comment provided by engineer. */ +"System" = "系统"; + +/* No comment provided by engineer. */ +"AFFiNE AI" = "AFFiNE AI"; + +/* No comment provided by engineer. */ +"You" = "你"; diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/SupplementView/CircleImageView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/SupplementView/CircleImageView.swift new file mode 100644 index 0000000000000..1c14bf2c79725 --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/SupplementView/CircleImageView.swift @@ -0,0 +1,29 @@ +// +// CircleImageView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/13. +// + +import UIKit + +class CircleImageView: UIImageView { + init() { + super.init(frame: .zero) + + contentMode = .scaleAspectFill + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError() + } + + override func layoutSubviews() { + super.layoutSubviews() + + clipsToBounds = true + layer.cornerRadius = (bounds.width + bounds.height) / 2 / 2 + layer.masksToBounds = true + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/SupplementView/UIHostingView.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/SupplementView/UIHostingView.swift new file mode 100644 index 0000000000000..f643c43a69aee --- /dev/null +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Sources/Intelligents/SupplementView/UIHostingView.swift @@ -0,0 +1,47 @@ +// +// UIHostingView.swift +// Intelligents +// +// Created by 秋星桥 on 2024/12/13. +// + +import SwiftUI +import UIKit + +class UIHostingView: UIView { + private let hostingViewController: UIHostingController + + var rootView: Content { + get { hostingViewController.rootView } + set { hostingViewController.rootView = newValue } + } + + init(rootView: Content) { + hostingViewController = UIHostingController(rootView: rootView) + super.init(frame: .zero) + + hostingViewController.view?.translatesAutoresizingMaskIntoConstraints = false + addSubview(hostingViewController.view) + if let view = hostingViewController.view { + view.backgroundColor = .clear + view.isOpaque = false + addSubview(view) + let constraints = [ + view.topAnchor.constraint(equalTo: topAnchor), + view.bottomAnchor.constraint(equalTo: bottomAnchor), + view.leftAnchor.constraint(equalTo: leftAnchor), + view.rightAnchor.constraint(equalTo: rightAnchor), + ] + NSLayoutConstraint.activate(constraints) + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + hostingViewController.sizeThatFits(in: size) + } +} diff --git a/packages/frontend/apps/ios/setup.sh b/packages/frontend/apps/ios/setup.sh new file mode 100755 index 0000000000000..7c50923e90248 --- /dev/null +++ b/packages/frontend/apps/ios/setup.sh @@ -0,0 +1,29 @@ +#!/bin/zsh + + +set -e +set -o pipefail + +# packages/frontend/apps/ios/ + +cd "$(dirname "$0")" +cd ../../../../ + +if [ ! -d .git ]; then + echo "[-] .git directory not found at project root" + exit 1 +fi + +echo "[+] setting up the project" + +yarn install +BUILD_TYPE=canary PUBLIC_PATH="/" yarn workspace @affine/ios build +yarn workspace @affine/ios cap sync + +rustup target add aarch64-apple-ios +rustup target add aarch64-apple-ios-sim +rustup target add aarch64-apple-darwin + +echo "[+] setup complete" + +yarn workspace @affine/ios cap open ios diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index 887fe9b5c7c45..d55672618a565 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -6,6 +6,7 @@ import { NavigationGestureProvider } from '@affine/core/mobile/modules/navigatio import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; +import { AIButtonProvider } from '@affine/core/modules/ai-button'; import { AuthService, DefaultServerService, @@ -13,6 +14,7 @@ import { ValidatorProvider, WebSocketAuthProvider, } from '@affine/core/modules/cloud'; +import { DocsService } from '@affine/core/modules/doc'; import { GlobalContextService } from '@affine/core/modules/global-context'; import { I18nProvider } from '@affine/core/modules/i18n'; import { LifecycleService } from '@affine/core/modules/lifecycle'; @@ -21,11 +23,18 @@ import { PopupWindowProvider } from '@affine/core/modules/url'; import { ClientSchemeProvider } from '@affine/core/modules/url/providers/client-schema'; import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace'; import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench'; +import { WorkspacesService } from '@affine/core/modules/workspace'; import { configureBrowserWorkspaceFlavours, configureIndexedDBWorkspaceEngineStorageProvider, } from '@affine/core/modules/workspace-engine'; import { I18n } from '@affine/i18n'; +import { + docLinkBaseURLMiddleware, + MarkdownAdapter, + titleMiddleware, +} from '@blocksuite/affine/blocks'; +import { Job } from '@blocksuite/affine/store'; import { App as CapacitorApp } from '@capacitor/app'; import { Browser } from '@capacitor/browser'; import { Haptics } from '@capacitor/haptics'; @@ -39,6 +48,7 @@ import { configureFetchProvider } from './fetch'; import { ModalConfigProvider } from './modal-config'; import { Cookie } from './plugins/cookie'; import { Hashcash } from './plugins/hashcash'; +import { Intelligents } from './plugins/intelligents'; import { NavigationGesture } from './plugins/navigation-gesture'; const future = { @@ -109,6 +119,15 @@ framework.impl(HapticProvider, { selectionChanged: () => Haptics.selectionChanged(), selectionEnd: () => Haptics.selectionEnd(), }); +framework.impl(AIButtonProvider, { + presentAIButton: () => { + return Intelligents.presentIntelligentsButton(); + }, + dismissAIButton: () => { + return Intelligents.dismissIntelligentsButton(); + }, +}); + const frameworkProvider = framework.provider(); // ------ some apis for native ------ @@ -125,6 +144,51 @@ const frameworkProvider = framework.provider(); (window as any).getCurrentI18nLocale = () => { return I18n.language; }; +(window as any).getCurrentDocContentInMarkdown = async () => { + const globalContextService = frameworkProvider.get(GlobalContextService); + const currentWorkspaceId = + globalContextService.globalContext.workspaceId.get(); + const currentDocId = globalContextService.globalContext.docId.get(); + const workspacesService = frameworkProvider.get(WorkspacesService); + const workspaceRef = currentWorkspaceId + ? workspacesService.openByWorkspaceId(currentWorkspaceId) + : null; + if (!workspaceRef) { + return; + } + const { workspace, dispose: disposeWorkspace } = workspaceRef; + + const docsService = workspace.scope.get(DocsService); + const docRef = currentDocId ? docsService.open(currentDocId) : null; + if (!docRef) { + return; + } + const { doc, release: disposeDoc } = docRef; + + try { + const blockSuiteDoc = doc.blockSuiteDoc; + + const job = new Job({ + collection: blockSuiteDoc.collection, + middlewares: [docLinkBaseURLMiddleware, titleMiddleware], + }); + const snapshot = job.docToSnapshot(blockSuiteDoc); + + const adapter = new MarkdownAdapter(job); + if (!snapshot) { + return; + } + + const markdownResult = await adapter.fromDocSnapshot({ + snapshot, + assets: job.assetsManager, + }); + return markdownResult.file; + } finally { + disposeDoc(); + disposeWorkspace(); + } +}; // setup application lifecycle events, and emit application start event window.addEventListener('focus', () => { diff --git a/packages/frontend/apps/ios/src/plugins/intelligents/definitions.ts b/packages/frontend/apps/ios/src/plugins/intelligents/definitions.ts new file mode 100644 index 0000000000000..b53876c7d754f --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/intelligents/definitions.ts @@ -0,0 +1,4 @@ +export interface IntelligentsPlugin { + presentIntelligentsButton(): Promise; + dismissIntelligentsButton(): Promise; +} diff --git a/packages/frontend/apps/ios/src/plugins/intelligents/index.ts b/packages/frontend/apps/ios/src/plugins/intelligents/index.ts new file mode 100644 index 0000000000000..7acb52298f23f --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/intelligents/index.ts @@ -0,0 +1,8 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { IntelligentsPlugin } from './definitions'; + +const Intelligents = registerPlugin('Intelligents'); + +export * from './definitions'; +export { Intelligents }; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx index 342233b3b87c6..c53680ecbea98 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx @@ -10,6 +10,7 @@ import { PageDetailEditor } from '@affine/core/components/page-detail-editor'; import { DetailPageWrapper } from '@affine/core/desktop/pages/workspace/detail-page/detail-page-wrapper'; import { PageHeader } from '@affine/core/mobile/components'; import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events'; +import { AIButtonService } from '@affine/core/modules/ai-button'; import { DocService } from '@affine/core/modules/doc'; import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; import { EditorService } from '@affine/core/modules/editor'; @@ -57,6 +58,7 @@ const DetailPageImpl = () => { workspaceService, globalContextService, featureFlagService, + aIButtonService, } = useServices({ WorkbenchService, ViewService, @@ -65,6 +67,7 @@ const DetailPageImpl = () => { WorkspaceService, GlobalContextService, FeatureFlagService, + AIButtonService, }); const editor = editorService.editor; const workspace = workspaceService.workspace; @@ -124,6 +127,14 @@ const DetailPageImpl = () => { enableEdgelessEditing, ]); + useEffect(() => { + aIButtonService.presentAIButton(true); + + return () => { + aIButtonService.presentAIButton(false); + }; + }, [aIButtonService]); + useEffect(() => { globalContext.isTrashDoc.set(!!isInTrash); diff --git a/packages/frontend/core/src/modules/ai-button/index.ts b/packages/frontend/core/src/modules/ai-button/index.ts new file mode 100644 index 0000000000000..f01dd64af156d --- /dev/null +++ b/packages/frontend/core/src/modules/ai-button/index.ts @@ -0,0 +1,13 @@ +export { AIButtonProvider } from './provider/ai-button'; +export { AIButtonService } from './services/ai-button'; + +import type { Framework } from '@toeverything/infra'; + +import { AIButtonProvider } from './provider/ai-button'; +import { AIButtonService } from './services/ai-button'; + +export const configureAIButtonModule = (framework: Framework) => { + framework.service(AIButtonService, container => { + return new AIButtonService(container.getOptional(AIButtonProvider)); + }); +}; diff --git a/packages/frontend/core/src/modules/ai-button/provider/ai-button.ts b/packages/frontend/core/src/modules/ai-button/provider/ai-button.ts new file mode 100644 index 0000000000000..3cee232d59c6e --- /dev/null +++ b/packages/frontend/core/src/modules/ai-button/provider/ai-button.ts @@ -0,0 +1,9 @@ +import { createIdentifier } from '@toeverything/infra'; + +export interface AIButtonProvider { + presentAIButton: () => Promise; + dismissAIButton: () => Promise; +} + +export const AIButtonProvider = + createIdentifier('AIButtonProvider'); diff --git a/packages/frontend/core/src/modules/ai-button/services/ai-button.ts b/packages/frontend/core/src/modules/ai-button/services/ai-button.ts new file mode 100644 index 0000000000000..807afe4563ae7 --- /dev/null +++ b/packages/frontend/core/src/modules/ai-button/services/ai-button.ts @@ -0,0 +1,48 @@ +import { DebugLogger } from '@affine/debug'; +import { + effect, + exhaustMapWithTrailing, + fromPromise, + Service, +} from '@toeverything/infra'; +import { + catchError, + distinctUntilChanged, + EMPTY, + mergeMap, + throttleTime, +} from 'rxjs'; + +import type { AIButtonProvider } from '../provider/ai-button'; + +const logger = new DebugLogger('AIButtonService'); + +export class AIButtonService extends Service { + constructor(private readonly aiButtonProvider?: AIButtonProvider) { + super(); + } + + presentAIButton = effect( + distinctUntilChanged(), + throttleTime(1000), // throttle time to avoid frequent calls + exhaustMapWithTrailing((present: boolean) => { + return fromPromise(async () => { + if (!this.aiButtonProvider) { + return; + } + if (present) { + await this.aiButtonProvider.presentAIButton(); + } else { + await this.aiButtonProvider.dismissAIButton(); + } + return; + }).pipe( + mergeMap(() => EMPTY), + catchError(err => { + logger.error('presentAIButton error', err); + return EMPTY; + }) + ); + }) + ); +} diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index ad4dffb68d802..f31a1099fe47b 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -1,6 +1,7 @@ import { configureQuotaModule } from '@affine/core/modules/quota'; import { type Framework } from '@toeverything/infra'; +import { configureAIButtonModule } from './ai-button'; import { configureAppSidebarModule } from './app-sidebar'; import { configAtMenuConfigModule } from './at-menu-config'; import { configureCloudModule } from './cloud'; @@ -88,4 +89,5 @@ export function configureCommonModules(framework: Framework) { configAtMenuConfigModule(framework); configureDndModule(framework); configureCommonGlobalStorageImpls(framework); + configureAIButtonModule(framework); } diff --git a/packages/frontend/core/src/modules/workspace/index.ts b/packages/frontend/core/src/modules/workspace/index.ts index 130d043d7665d..b0730d141f191 100644 --- a/packages/frontend/core/src/modules/workspace/index.ts +++ b/packages/frontend/core/src/modules/workspace/index.ts @@ -67,6 +67,7 @@ export function configureWorkspaceModule(framework: Framework) { .service(WorkspaceRepositoryService, [ WorkspaceFlavoursService, WorkspaceProfileService, + WorkspaceListService, ]) .scope(WorkspaceScope) .service(WorkspaceService) diff --git a/packages/frontend/core/src/modules/workspace/services/repo.ts b/packages/frontend/core/src/modules/workspace/services/repo.ts index 57fe2cf4075ee..7f803e0d8e707 100644 --- a/packages/frontend/core/src/modules/workspace/services/repo.ts +++ b/packages/frontend/core/src/modules/workspace/services/repo.ts @@ -7,6 +7,7 @@ import type { WorkspaceOpenOptions } from '../open-options'; import type { WorkspaceEngineProvider } from '../providers/flavour'; import { WorkspaceScope } from '../scopes/workspace'; import type { WorkspaceFlavoursService } from './flavours'; +import type { WorkspaceListService } from './list'; import type { WorkspaceProfileService } from './profile'; import { WorkspaceService } from './workspace'; @@ -15,7 +16,8 @@ const logger = new DebugLogger('affine:workspace-repository'); export class WorkspaceRepositoryService extends Service { constructor( private readonly flavoursService: WorkspaceFlavoursService, - private readonly profileRepo: WorkspaceProfileService + private readonly profileRepo: WorkspaceProfileService, + private readonly workspacesListService: WorkspaceListService ) { super(); } @@ -73,6 +75,12 @@ export class WorkspaceRepositoryService extends Service { }; }; + openByWorkspaceId = (workspaceId: string) => { + const workspaceMetadata = + this.workspacesListService.list.workspace$(workspaceId).value; + return workspaceMetadata && this.open({ metadata: workspaceMetadata }); + }; + instantiate( openOptions: WorkspaceOpenOptions, customProvider?: WorkspaceEngineProvider diff --git a/packages/frontend/core/src/modules/workspace/services/workspaces.ts b/packages/frontend/core/src/modules/workspace/services/workspaces.ts index 4d08882421dad..274c6f30b4725 100644 --- a/packages/frontend/core/src/modules/workspace/services/workspaces.ts +++ b/packages/frontend/core/src/modules/workspace/services/workspaces.ts @@ -42,6 +42,10 @@ export class WorkspacesService extends Service { return this.workspaceRepo.open; } + get openByWorkspaceId() { + return this.workspaceRepo.openByWorkspaceId; + } + get create() { return this.workspaceFactory.create; }