diff --git a/capacitor.config.ts b/capacitor.config.ts index 6e708618..b678f974 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -7,12 +7,18 @@ const config: CapacitorConfig = { server: { // Handle deep links from your domain hostname: 'ditto.pub', - androidScheme: 'https' + androidScheme: 'https', + iosScheme: 'https' }, android: { // Enable safe area handling for notches and navigation bars allowMixedContent: false, backgroundColor: '#14161f' + }, + ios: { + backgroundColor: '#14161f', + contentInset: 'automatic', + scheme: 'Ditto' } }; diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..f4702997 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 00000000..9b0498cc --- /dev/null +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift new file mode 100644 index 00000000..c3cd83b5 --- /dev/null +++ b/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +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) + } + +} diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 00000000..adf6ba01 Binary files /dev/null and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9b7d382d --- /dev/null +++ b/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/App/Assets.xcassets/Contents.json b/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 00000000..d7d96a67 --- /dev/null +++ b/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 00000000..33ea6c97 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 00000000..33ea6c97 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ diff --git a/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 00000000..33ea6c97 Binary files /dev/null and b/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ diff --git a/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..e7ae5d78 --- /dev/null +++ b/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 00000000..b44df7be --- /dev/null +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist new file mode 100644 index 00000000..42962337 --- /dev/null +++ b/ios/App/App/Info.plist @@ -0,0 +1,51 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Ditto + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/App/CapApp-SPM/.gitignore b/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 00000000..29e020d1 --- /dev/null +++ b/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"), + .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") + ] + ) + ] +) diff --git a/ios/App/CapApp-SPM/README.md b/ios/App/CapApp-SPM/README.md new file mode 100644 index 00000000..03964db9 --- /dev/null +++ b/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 00000000..945afec8 --- /dev/null +++ b/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/ios/debug.xcconfig b/ios/debug.xcconfig new file mode 100644 index 00000000..53ce18de --- /dev/null +++ b/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/package-lock.json b/package-lock.json index 9c63563f..6445ba19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,6 +112,7 @@ "devDependencies": { "@capacitor/android": "^8.1.0", "@capacitor/cli": "^8.1.0", + "@capacitor/ios": "^8.2.0", "@eslint/js": "^9.9.0", "@html-eslint/eslint-plugin": "^0.41.0", "@html-eslint/parser": "^0.41.0", @@ -326,14 +327,24 @@ } }, "node_modules/@capacitor/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.1.0.tgz", - "integrity": "sha512-UfMBMWc1v7J+14AhH03QmeNwV3HZx3qnOWhpwnHfzALEwAwlV/itQOQqcasMQYhOHWL0tiymc5ByaLTn7KKQxw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.2.0.tgz", + "integrity": "sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==", "license": "MIT", "dependencies": { "tslib": "^2.1.0" } }, + "node_modules/@capacitor/ios": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.2.0.tgz", + "integrity": "sha512-X2/VtM4qP/R1SM0VQ5W/VotEc6PS/KTooD33EijsfAHWBdee+xmBapW8SeNLnu16wJ+tsfWlvtipaJEyfKbRKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.2.0" + } + }, "node_modules/@capacitor/local-notifications": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.0.1.tgz", diff --git a/package.json b/package.json index f30a5c8c..daf3c942 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "devDependencies": { "@capacitor/android": "^8.1.0", "@capacitor/cli": "^8.1.0", + "@capacitor/ios": "^8.2.0", "@eslint/js": "^9.9.0", "@html-eslint/eslint-plugin": "^0.41.0", "@html-eslint/parser": "^0.41.0", diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index bb728387..9f14beb8 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -55,6 +55,7 @@ import { WalletSettingsPage } from "./pages/WalletSettingsPage"; import { WebxdcFeedPage } from "./pages/WebxdcFeedPage"; import { WorldPage } from "./pages/WorldPage"; import { ArchivePage } from "./pages/ArchivePage"; +import { BlueskyPage } from "./pages/BlueskyPage"; import { WikipediaPage } from "./pages/WikipediaPage"; const pollsDef = getExtraKindDef("polls")!; @@ -213,6 +214,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/ArcBackground.tsx b/src/components/ArcBackground.tsx new file mode 100644 index 00000000..0e3054e9 --- /dev/null +++ b/src/components/ArcBackground.tsx @@ -0,0 +1,61 @@ +import { cn } from '@/lib/utils'; + +/** Arc overhang for the downward arc (top bar / sub-header). */ +export const ARC_OVERHANG_PX = 20; + +/** Larger overhang for the upward arc (bottom nav) so the harsher curve isn't clipped. */ +export const ARC_UP_OVERHANG_PX = 28; + +/** SVG path for a downward arc (used by top bar and sub-header bar). */ +const ARC_DOWN_PATH = 'M0,0 L100,0 L100,44 Q50,64 0,44 Z'; + +/** SVG path for an upward arc (used by bottom nav). */ +const ARC_UP_PATH = 'M0,30 Q50,0 100,30 L100,64 L0,64 Z'; + +/** SVG path for a plain rectangle with no arc. */ +const RECT_PATH = 'M0,0 L100,0 L100,64 L0,64 Z'; + +/** Pre-computed style for down-arc variant. */ +const arcDownHeightStyle: React.CSSProperties = { height: `calc(100% + ${ARC_OVERHANG_PX}px)` }; + +/** Pre-computed style for up-arc variant (bottom nav — larger overhang). */ +const arcUpHeightStyle: React.CSSProperties = { height: `calc(100% + ${ARC_UP_OVERHANG_PX}px)` }; + +/** Pre-computed style for non-arc (rect) variant. */ +const fullHeightStyle: React.CSSProperties = { height: '100%' }; + +interface ArcBackgroundProps { + /** Which arc shape to render. */ + variant: 'down' | 'up' | 'rect'; + /** Extra classes on the element. */ + className?: string; +} + +/** + * Shared SVG background shape used by MobileTopBar, SubHeaderBar, and + * MobileBottomNav. Draws a semi-transparent filled shape (rectangle + optional + * curved arc) as a single path so there are no sub-pixel seams between layers. + */ +export function ArcBackground({ variant, className }: ArcBackgroundProps) { + const path = variant === 'down' ? ARC_DOWN_PATH : variant === 'up' ? ARC_UP_PATH : RECT_PATH; + const hasArc = variant !== 'rect'; + + // "down" and "rect" anchor to the top (arc/content extends downward). + // "up" anchors to the bottom so the arc extends upward above the container. + const positionClass = variant === 'up' + ? 'absolute bottom-0 left-0 right-0' + : 'absolute inset-0'; + + return ( + + + {variant === 'down' && } + {variant === 'up' && } + + ); +} diff --git a/src/components/ArticleContent.tsx b/src/components/ArticleContent.tsx index 4a370959..b39e0ba4 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -35,9 +35,9 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp /> )} {summary ? ( -

{summary}

+

{summary}

) : ( -

+

{event.content.slice(0, 280)}{event.content.length > 280 ? '...' : ''}

)} @@ -58,7 +58,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp className="w-full rounded-xl object-cover max-h-96 mb-6" /> )} -
+
{event.content} diff --git a/src/components/BlueskyEmbed.tsx b/src/components/BlueskyEmbed.tsx index 4985c4d2..60987167 100644 --- a/src/components/BlueskyEmbed.tsx +++ b/src/components/BlueskyEmbed.tsx @@ -6,6 +6,8 @@ interface BlueskyEmbedProps { author: string; /** Record key (rkey) of the post. */ rkey: string; + /** When true, hides image and external link thumbnails. */ + hideImage?: boolean; className?: string; } @@ -28,7 +30,7 @@ function BlueskyLogo({ className }: { className?: string }) { * Renders a Bluesky post as a native quote-post card, fetching data from * the public Bluesky API. */ -export function BlueskyEmbed({ author, rkey, className }: BlueskyEmbedProps) { +export function BlueskyEmbed({ author, rkey, hideImage, className }: BlueskyEmbedProps) { const { data: post, isLoading, isError } = useBlueskyPost(author, rkey); if (isLoading) { @@ -42,6 +44,7 @@ export function BlueskyEmbed({ author, rkey, className }: BlueskyEmbedProps) { return ( }} + hideImage={hideImage} className={className} /> ); diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 81078b08..5bbad4c3 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -1030,7 +1030,7 @@ export function ComposeBox({ if (!user && compact) return null; return ( -
+
{/* Preview toggle at top when not controlled and has previewable content */} {hasPreviewableContent && controlledPreviewMode === undefined && (
diff --git a/src/components/CustomNipCard.tsx b/src/components/CustomNipCard.tsx index 17bb7ab6..7e091408 100644 --- a/src/components/CustomNipCard.tsx +++ b/src/components/CustomNipCard.tsx @@ -136,7 +136,7 @@ export function CustomNipCard({ event, preview = true }: CustomNipCardProps) { {/* Full markdown content -- detail view only, outside card */} {!preview && event.content && (
-
+
{event.content} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 41521e75..4de6b997 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1,26 +1,33 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { BookOpen, Droplets, ExternalLink, FileText, Globe, MapPin, Package, Play, User, Users, Wind } from 'lucide-react'; +import { BookOpen, Droplets, ExternalLink, FileText, Globe, MapPin, MessageCircle, Package, Play, Repeat2, Share2, User, Users, Wind } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; import { ExternalFavicon } from '@/components/ExternalFavicon'; +import { ExternalReactionButton } from '@/components/ExternalReactionButton'; import { LinkEmbed } from '@/components/LinkEmbed'; +import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; -import { extractYouTubeId, extractWikipediaTitle } from '@/lib/linkEmbed'; +import { BlueskyIcon } from '@/components/icons/BlueskyIcon'; +import { extractYouTubeId, extractWikipediaTitle, extractBlueskyPost } from '@/lib/linkEmbed'; +import { parseExternalUri, formatIsbn } from '@/lib/externalContent'; +import { shareOrCopy } from '@/lib/share'; import { useLinkPreview } from '@/hooks/useLinkPreview'; +import { useBlueskyPost } from '@/hooks/useBlueskyPost'; import { useBookInfo } from '@/hooks/useBookInfo'; import { useAddrEvent } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useWeather } from '@/hooks/useWeather'; +import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { getCountryInfo, getWikipediaTitle } from '@/lib/countries'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; -import { parseExternalUri, formatIsbn } from '@/lib/externalContent'; import { EXTRA_KINDS } from '@/lib/extraKinds'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; +import { cn } from '@/lib/utils'; // --------------------------------------------------------------------------- // Full-size content headers (used on /i/ page) @@ -28,14 +35,266 @@ import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; export function UrlContentHeader({ url }: { url: string }) { const wikiTitle = useMemo(() => extractWikipediaTitle(url), [url]); + const blueskyPost = useMemo(() => extractBlueskyPost(url), [url]); if (wikiTitle) { return ; } + if (blueskyPost) { + return ; + } + return ; } +// --------------------------------------------------------------------------- +// Bluesky post header (full feed-style, like a thread top post) +// --------------------------------------------------------------------------- + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +function blueskyTimeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = now - then; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + return `${months}mo`; +} + +function BlueskyPostHeader({ author, rkey, url }: { author: string; rkey: string; url: string }) { + const { data: post, isLoading, isError } = useBlueskyPost(author, rkey); + const { toast } = useToast(); + + const profileUrl = `/i/${encodeURIComponent(`https://bsky.app/profile/${post?.handle ?? author}`)}`; + const externalContent = useMemo(() => parseExternalUri(url), [url]); + + const [shareOpen, setShareOpen] = useState(false); + const [commentOpen, setCommentOpen] = useState(false); + + const handleComment = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setCommentOpen(true); + }, []); + + const handleRepost = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setShareOpen(true); + }, []); + + const handleShare = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + const fullUrl = `${window.location.origin}/i/${encodeURIComponent(url)}`; + const result = await shareOrCopy(fullUrl); + if (result === 'copied') { + toast({ title: 'Link copied' }); + } + }, [url, toast]); + + if (isLoading) { + return ( +
+
+ +
+
+ + +
+ + +
+ + + +
+
+
+
+ ); + } + + if (isError || !post) { + return ; + } + + return ( + <> +
+
+ {/* Avatar */} + + {post.avatar ? ( + { e.currentTarget.style.display = 'none'; }} + /> + ) : ( +
+ {(post.displayName ?? post.handle).charAt(0).toUpperCase()} +
+ )} + + + {/* Body */} +
+ {/* Author info */} +
+ + {post.displayName ?? post.handle} + + + @{post.handle} + + · + + {blueskyTimeAgo(post.createdAt)} + +
+ + {/* Post text */} + {post.text && ( +

+ {post.text} +

+ )} + + {/* Image embeds */} + {post.images && post.images.length > 0 && ( +
= 4 && 'grid grid-cols-2 gap-0.5', + )} + > + {post.images.slice(0, 4).map((img, i) => ( +
+ {img.alt { e.currentTarget.style.display = 'none'; }} + /> +
+ ))} +
+ )} + + {/* External link embed */} + {post.external && post.external.thumb && ( +
+
+ { e.currentTarget.style.display = 'none'; }} + /> +
+ {post.external.title && ( +
+

{post.external.title}

+
+ )} +
+ )} + + {/* Action buttons */} +
+ + + + +
+
+
+ + {/* Bluesky source link */} + +
+ + {/* Comment compose modal */} + {commentOpen && ( + + )} + + {/* Share compose modal */} + {shareOpen && ( + + )} + + ); +} + // --------------------------------------------------------------------------- // Wikipedia article header (rich display for Wikipedia URLs) // --------------------------------------------------------------------------- diff --git a/src/components/ExternalPostCard.tsx b/src/components/ExternalPostCard.tsx index 9be0cc6f..d5a6c669 100644 --- a/src/components/ExternalPostCard.tsx +++ b/src/components/ExternalPostCard.tsx @@ -69,6 +69,8 @@ function formatDate(iso: string): string { interface ExternalPostCardProps { post: ExternalPostData; + /** When true, hides image and external link thumbnails. */ + hideImage?: boolean; className?: string; } @@ -78,7 +80,7 @@ interface ExternalPostCardProps { * Clicking the card body navigates to `/i/{postUrl}`. * Clicking the avatar or display name navigates to `/i/{profileUrl}`. */ -export function ExternalPostCard({ post, className }: ExternalPostCardProps) { +export function ExternalPostCard({ post, hideImage, className }: ExternalPostCardProps) { const navigate = useNavigate(); return ( @@ -103,7 +105,7 @@ export function ExternalPostCard({ post, className }: ExternalPostCardProps) { }} > {/* Images */} - {post.images && post.images.length > 0 && ( + {!hideImage && post.images && post.images.length > 0 && (
1 ? 'grid grid-cols-2 gap-px' : '', @@ -127,7 +129,7 @@ export function ExternalPostCard({ post, className }: ExternalPostCardProps) { )} {/* External link card (if no images) */} - {!post.images && post.external?.thumb && ( + {!hideImage && !post.images && post.external?.thumb && (
(null); + const justClosedRef = useRef(false); + const pickerExpandedRef = useRef(false); + + const handleMouseEnter = useCallback(() => { + if (!user) return; + if (justClosedRef.current) return; + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + setReactOpen(true); + }, [user]); + + const handleMouseLeave = useCallback(() => { + if (pickerExpandedRef.current) return; + closeTimeoutRef.current = setTimeout(() => setReactOpen(false), 150); + }, []); + + // Publish kind 17 reaction + const handleReact = useCallback((emoji: string, emojiTag?: string[]) => { + if (!user) return; + + const tags: string[][] = [ + ['k', getExternalKTag(content)], + ['i', identifier], + ]; + if (emojiTag) tags.push(emojiTag); + + queryClient.setQueryData(['external-user-reaction', identifier], { emoji: emoji || '+', tags }); + queryClient.setQueryData(['external-reaction-count', identifier], (prev: number | undefined) => (prev ?? 0) + 1); + + publishEvent( + { + kind: 17, + content: emoji, + created_at: Math.floor(Date.now() / 1000), + tags, + }, + { + onSuccess: () => { + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['external-user-reaction', identifier] }); + queryClient.invalidateQueries({ queryKey: ['external-reaction-count', identifier] }); + }, 3000); + }, + onError: () => { + toast({ title: 'Failed to react', variant: 'destructive' }); + queryClient.setQueryData(['external-user-reaction', identifier], null); + queryClient.setQueryData(['external-reaction-count', identifier], (prev: number | undefined) => Math.max(0, (prev ?? 1) - 1)); + }, + }, + ); + }, [user, content, identifier, publishEvent, queryClient, toast]); + + return ( + { + if (open && justClosedRef.current) return; + if (!open) pickerExpandedRef.current = false; + setReactOpen(open); + }}> + + + + e.stopPropagation()} + onOpenAutoFocus={(e) => e.preventDefault()} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + > + { pickerExpandedRef.current = expanded; }} + onClose={() => { + pickerExpandedRef.current = false; + justClosedRef.current = true; + setReactOpen(false); + setTimeout(() => { justClosedRef.current = false; }, 300); + }} + onReact={handleReact} + /> + + + ); +} diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index b6d08340..72e49b9a 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -24,6 +24,8 @@ import { useResolveTabFilter } from '@/hooks/useResolveTabFilter'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils'; import { isEventMuted } from '@/lib/muteHelpers'; +import { SubHeaderBar } from '@/components/SubHeaderBar'; +import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; import { TabButton } from '@/components/TabButton'; import { DITTO_RELAYS } from '@/lib/appRelays'; import type { FeedItem } from '@/lib/feedUtils'; @@ -229,13 +231,21 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee return (
+ {/* CTA (logged out, main feed only) */} + {!user && !kinds && ( + setLoginDialogOpen(true)} + onSignupClick={startSignup} + /> + )} + {!hideCompose && } {header} - {/* Tabs (logged in) or CTA (logged out, main feed only) */} - {user ? ( -
+ {/* Tabs (logged in) */} + {user && ( + handleSetActiveTab('follows')} /> {!isKindSpecificPage && showDittoFeed && ( handleSetActiveTab('ditto')} /> @@ -275,15 +285,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee ))} -
- ) : !kinds && ( - setLoginDialogOpen(true)} - onSignupClick={startSignup} - /> + )} {/* Feed content — saved feed tab gets its own stream */} + {user &&
} {activeHashtag ? ( ) : activeGeotag ? ( diff --git a/src/components/FloatingComposeButton.tsx b/src/components/FloatingComposeButton.tsx index 96364e04..1d22ca2d 100644 --- a/src/components/FloatingComposeButton.tsx +++ b/src/components/FloatingComposeButton.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Plus, Construction } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; @@ -9,6 +9,9 @@ import { } from '@/components/ui/dialog'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { getAvatarShape, getEmojiMaskUrl } from '@/lib/avatarShape'; + + interface FloatingComposeButtonProps { /** The Nostr event kind this FAB creates. kind=1 opens compose; others show "Coming soon". */ @@ -22,11 +25,30 @@ interface FloatingComposeButtonProps { } export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: FloatingComposeButtonProps) { - const { user } = useCurrentUser(); + const { user, metadata } = useCurrentUser(); const navigate = useNavigate(); const [composeOpen, setComposeOpen] = useState(false); const [comingSoonOpen, setComingSoonOpen] = useState(false); + const avatarShape = getAvatarShape(metadata); + + /** When the user has a custom emoji shape, use it as the FAB mask instead of a circle. */ + const shapeMaskStyle = useMemo(() => { + if (!avatarShape) return undefined; + const maskUrl = getEmojiMaskUrl(avatarShape); + if (!maskUrl) return undefined; + return { + WebkitMaskImage: `url(${maskUrl})`, + maskImage: `url(${maskUrl})`, + WebkitMaskSize: 'contain', + maskSize: 'contain' as string, + WebkitMaskRepeat: 'no-repeat', + maskRepeat: 'no-repeat' as string, + WebkitMaskPosition: 'center', + maskPosition: 'center' as string, + }; + }, [avatarShape]); + if (!user) { return null; } @@ -45,12 +67,22 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon }: Floa return ( <> - + {/* FAB background: user's avatar shape (emoji mask) or circle (default) */} +
+ {/* Plus icon centered on the button */} + + {icon ?? } + + {/* Kind 1: Compose modal */} {kind === 1 && ( diff --git a/src/components/FollowPackDetailContent.tsx b/src/components/FollowPackDetailContent.tsx index 15fa950f..37e5fc97 100644 --- a/src/components/FollowPackDetailContent.tsx +++ b/src/components/FollowPackDetailContent.tsx @@ -23,7 +23,7 @@ import { isEventMuted } from '@/lib/muteHelpers'; import { useNostr } from '@nostrify/react'; import { genUserName } from '@/lib/genUserName'; import { VerifiedNip05Text } from '@/components/Nip05Badge'; -import { cn, STICKY_HEADER_CLASS } from '@/lib/utils'; +import { SubHeaderBar } from '@/components/SubHeaderBar'; /** Parse a follow pack / starter pack event into structured data. */ function parsePackEvent(event: NostrEvent) { @@ -335,10 +335,10 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
{/* Tab bar */} -
+ setActiveTab('feed')} /> setActiveTab('members')} /> -
+ {/* Tab content */} {activeTab === 'feed' ? ( diff --git a/src/components/LinkEmbed.tsx b/src/components/LinkEmbed.tsx index c4d9952c..f4eaf61b 100644 --- a/src/components/LinkEmbed.tsx +++ b/src/components/LinkEmbed.tsx @@ -63,7 +63,7 @@ export function LinkEmbed({ url, className, navigateToComments, showActions = tr embed = ; } else if (blueskyPost) { // BlueskyEmbed has built-in /i/ navigation, no DiscussBar needed - return ; + return ; } else if (mastodonUrl) { // MastodonEmbed has built-in /i/ navigation, no DiscussBar needed return ; diff --git a/src/components/LiveStreamPage.tsx b/src/components/LiveStreamPage.tsx index b70902ae..8ba8925a 100644 --- a/src/components/LiveStreamPage.tsx +++ b/src/components/LiveStreamPage.tsx @@ -1,10 +1,11 @@ import { useMemo, useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Users, Radio, Zap, Clock, ChevronDown, ChevronUp } from 'lucide-react'; +import { Users, Radio, Zap, Clock, ChevronDown, ChevronUp } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { useSeoMeta } from '@unhead/react'; import { useLayoutOptions } from '@/contexts/LayoutContext'; +import { PageHeader } from '@/components/PageHeader'; import { LiveStreamPlayer } from '@/components/LiveStreamPlayer'; import { LiveStreamChat } from '@/components/LiveStreamChat'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -181,20 +182,17 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) { <>
{/* Header */} -
- -

Live Stream

+ window.history.length > 1 ? navigate(-1) : navigate('/')} + alwaysShowBack + className="shrink-0 sidebar:sticky sidebar:top-0 z-10 mt-2 mb-2 sidebar:mt-4 sidebar:mb-4" + > {status === 'live' && } {statusConfig.label} -
+ {/* Video Player */}
diff --git a/src/components/MainLayout.tsx b/src/components/MainLayout.tsx index 8f5941c3..1f8b7360 100644 --- a/src/components/MainLayout.tsx +++ b/src/components/MainLayout.tsx @@ -10,6 +10,8 @@ import { CursorFireEffect } from '@/components/CursorFireEffect'; import { Skeleton } from '@/components/ui/skeleton'; import { LayoutStore, LayoutStoreContext, useLayoutSnapshot } from '@/contexts/LayoutContext'; import { useAppContext } from '@/hooks/useAppContext'; +import { useScrollDirection } from '@/hooks/useScrollDirection'; +import { ARC_UP_OVERHANG_PX } from '@/components/ArcBackground'; import { cn } from '@/lib/utils'; /** Skeleton shown in the content area while a lazy page chunk is loading. */ @@ -64,9 +66,10 @@ function PageSkeleton() { /** Inner component that reads layout options from the context store. */ function MainLayoutInner() { - const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth } = useLayoutSnapshot(); + const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader } = useLayoutSnapshot(); const [drawerOpen, setDrawerOpen] = useState(false); const { config } = useAppContext(); + const { hidden: navHidden } = useScrollDirection(scrollContainer); return ( <> @@ -74,7 +77,7 @@ function MainLayoutInner() { {config.magicMouse && } {/* Mobile top bar - only on small screens */} - setDrawerOpen(true)} /> + setDrawerOpen(true)} hasSubHeader={hasSubHeader} /> {/* Mobile drawer */} @@ -88,15 +91,14 @@ function MainLayoutInner() { {/* Main content + right sidebar: inside Suspense so the left sidebar persists while lazy pages load */} }> -
+ {/* -mt-mobile-bar pulls content up behind the mobile top bar so the + transparent SVG header arc and page content overlap seamlessly. + The corresponding padding-top (set in CSS) prevents content from + being hidden. This depends on MobileTopBar having a transparent / + semi-transparent background — a solid top bar would obscure the + content underneath. Only active below the sidebar breakpoint. */} +
- {showFAB && ( -
-
- -
-
- )}
{rightSidebar !== null && (rightSidebar ?? )} @@ -104,6 +106,18 @@ function MainLayoutInner() { {/* Mobile bottom nav - only on small screens, slides out on scroll */} + + {/* FAB - fixed, mirrors bottom nav hide/show transition on mobile */} + {showFAB && ( +
+
+ +
+
+ )} ); } diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 2ef7d02d..1be38720 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -10,13 +10,19 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useScrollDirection } from '@/hooks/useScrollDirection'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useLayoutSnapshot } from '@/contexts/LayoutContext'; +import { ArcBackground, ARC_UP_OVERHANG_PX } from '@/components/ArcBackground'; import { MobileSearchSheet } from '@/components/MobileSearchSheet'; +/** Transform style applied when the bottom nav is hidden (scrolled away). */ +const hiddenStyle: React.CSSProperties = { + transform: `translateY(calc(100% + ${ARC_UP_OVERHANG_PX}px))`, +}; + export function MobileBottomNav() { const location = useLocation(); const { user, metadata } = useCurrentUser(); const hasUnread = useHasUnreadNotifications(); - const { scrollContainer } = useLayoutSnapshot(); + const { scrollContainer, noArcs } = useLayoutSnapshot(); const { hidden } = useScrollDirection(scrollContainer); const profileUrl = useProfileUrl(user?.pubkey ?? '', metadata); @@ -39,12 +45,15 @@ export function MobileBottomNav() { ); diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx index 4d4501fd..d167439e 100644 --- a/src/components/MobileDrawer.tsx +++ b/src/components/MobileDrawer.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useId, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -29,12 +29,22 @@ import { useToast } from '@/hooks/useToast'; import { Input } from '@/components/ui/input'; import { resolveTheme, resolveThemeConfig } from '@/themes'; +/** Total width of the drawer background layer: 300px drawer + 36px arc overhang. */ +const DRAWER_BG_WIDTH = 336; + +/** Build the shared clip-path style for the drawer arc background layers. */ +function drawerClipStyle(clipId: string): React.CSSProperties { + return { width: DRAWER_BG_WIDTH, clipPath: `url(#${clipId})` }; +} + interface MobileDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; } export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) { + const clipId = `${useId()}-drawer-arc-clip`; + const clipStyle = drawerClipStyle(clipId); const location = useLocation(); const navigate = useNavigate(); const { user, metadata, event: currentUserEvent } = useCurrentUser(); @@ -98,8 +108,32 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) { return ( <> { if (!v) setMoreMenuOpen(false); onOpenChange(v); }}> - - {hasBgImage &&
} + + {/* SVG clip path definition for the drawer + arc shape. + The clip path uses objectBoundingBox units so the arc scales with the + background layer. The 0.893 ratio ≈ DRAWER_WIDTH / DRAWER_BG_WIDTH + (300 / 336), placing the arc's apex at the right edge of the visible + drawer while the extra 36px overflows for the curved bulge. */} + + {/* Background layer: 300px drawer + 36px arc overhang = 336px total. + Clipped to the drawer+arc shape so the background image (if any) flows + seamlessly through both regions. */} +
+ {hasBgImage && ( +
+ )} Navigation menu {user ? ( diff --git a/src/components/MobileTopBar.tsx b/src/components/MobileTopBar.tsx index 4f5be5c8..81d365e2 100644 --- a/src/components/MobileTopBar.tsx +++ b/src/components/MobileTopBar.tsx @@ -2,12 +2,15 @@ import { useCallback } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { DittoLogo } from '@/components/DittoLogo'; import { BarsStaggeredIcon } from '@/components/icons/BarsStaggeredIcon'; +import { ArcBackground } from '@/components/ArcBackground'; interface MobileTopBarProps { onAvatarClick: () => void; + /** When true, a SubHeaderBar with an arc follows immediately below — skip the arc here to avoid doubling up. */ + hasSubHeader?: boolean; } -export function MobileTopBar({ onAvatarClick }: MobileTopBarProps) { +export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps) { const location = useLocation(); const handleLogoClick = useCallback((e: React.MouseEvent) => { @@ -18,8 +21,9 @@ export function MobileTopBar({ onAvatarClick }: MobileTopBarProps) { }, [location.pathname]); return ( -
-
+
+ +
{/* Left: hamburger menu icon */}
- {/* Center: Ditto logo */} -
- - - -
+ {/* Center: Ditto logo */} +
+ + + +
{/* Right: spacer for symmetry */}
diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx new file mode 100644 index 00000000..9a76f474 --- /dev/null +++ b/src/components/PageHeader.tsx @@ -0,0 +1,53 @@ +import { ArrowLeft } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { cn } from '@/lib/utils'; + +interface PageHeaderProps { + /** Page title text (ignored when `titleContent` is provided). */ + title?: string; + /** Icon rendered before the title (ignored when `titleContent` is provided). */ + icon?: React.ReactNode; + /** Fully custom title area — replaces the default icon + h1 when set. */ + titleContent?: React.ReactNode; + /** Where the back arrow navigates to (default: "/"). Ignored when `onBack` is set. */ + backTo?: string; + /** Callback for the back button — when set, renders a ` + ) : ( + + + + )} + {titleContent ?? ( +
+ {icon} +

{title}

+
+ )} + {children} +
+ ); +} diff --git a/src/components/PullRequestCard.tsx b/src/components/PullRequestCard.tsx index c07f00ab..7444e60e 100644 --- a/src/components/PullRequestCard.tsx +++ b/src/components/PullRequestCard.tsx @@ -213,7 +213,7 @@ export function PullRequestCard({ {/* PR description */} {!preview && hasDescription && (
-
+
{event.content} diff --git a/src/components/RelayListManager.tsx b/src/components/RelayListManager.tsx index 08a4de47..3685b174 100644 --- a/src/components/RelayListManager.tsx +++ b/src/components/RelayListManager.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; import { Plus, X, Wifi, Settings } from 'lucide-react'; import { HelpTip } from '@/components/HelpTip'; import { Button } from '@/components/ui/button'; @@ -238,9 +239,9 @@ export function RelayListManager() { className="flex items-center gap-3 py-2.5 px-3 hover:bg-muted/20 transition-colors" > - + {renderRelayUrl(relay.url)} - +
{relay.read && ( Read @@ -277,9 +278,9 @@ export function RelayListManager() { className="flex items-center gap-3 py-2.5 px-3 hover:bg-muted/20 transition-colors" > - + {renderRelayUrl(relay.url)} - + {/* Settings Popover */} diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index 77d39809..c7f771fd 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -14,6 +14,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { NoteContent } from '@/components/NoteContent'; import { ComposeBox } from '@/components/ComposeBox'; +import { LinkEmbed } from '@/components/LinkEmbed'; import { VanishCardCompact } from '@/components/VanishEventContent'; import { ProfilePreview } from '@/components/ExternalContentHeader'; import { useAuthor } from '@/hooks/useAuthor'; @@ -120,10 +121,26 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
- {/* Embedded original post (reply only, not for URL roots or quotes) */} - {event && !isUrl && !isQuote && ( + {/* Embedded original post (reply only, not for quotes) */} + {event && !isQuote && (
- + {isUrl ? ( +
+ +
+ ) : ( + + )} +
+ )} + + {/* Bluesky disclaimer */} + {isUrl && /bsky\.(app|social)/.test(event.href) && ( +
+ ⚠️ +

+ People on Bluesky can't see you because they're not actually decentralized. +

)} diff --git a/src/components/SubHeaderBar.tsx b/src/components/SubHeaderBar.tsx new file mode 100644 index 00000000..1d3491ac --- /dev/null +++ b/src/components/SubHeaderBar.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { cn } from '@/lib/utils'; +import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground'; + +interface HoverSlice { + left: number; + width: number; +} + +interface SubHeaderBarProps { + children: React.ReactNode; + /** Extra classes on the outer wrapper (e.g. shrink-0). */ + className?: string; + /** Extra classes on the inner flex container holding the tabs. */ + innerClassName?: string; + /** Replace the decorative arc with a plain rectangle. */ + noArc?: boolean; +} + +interface SubHeaderBarContextValue { + onHover: (slice: HoverSlice | null) => void; + onActive: (slice: HoverSlice | null) => void; +} + +import { createContext, useContext } from 'react'; + +export const SubHeaderBarContext = createContext({ onHover: () => {}, onActive: () => {} }); + +export function useSubHeaderBarHover() { + return useContext(SubHeaderBarContext); +} + +/** + * Shared sticky sub-header bar with a unified arc+background drawn as a single + * SVG shape. Eliminates the sub-pixel seam between a bg-background/80 container + * and a separate SVG arc overlay that can appear during scroll/animation. + * + * Used by all tab bars (Feed, Search, Notifications, etc.) and the MobileTopBar + * fallback arc. + */ +export function SubHeaderBar({ children, className, innerClassName, noArc }: SubHeaderBarProps) { + const [hover, setHover] = useState(null); + const [active, setActive] = useState(null); + + return ( + +
+ + {/* Per-tab arc hover highlight: full-width arc, clipped to the hovered tab's x-slice */} + {hover && !noArc && ( + + + + )} + {/* Active tab indicator: the arc's bottom edge as a stroke, clipped to the active tab's x-slice */} + {active && !noArc && ( + + + + )} + {/* Tab content sits above the SVG background */} +
+ {children} +
+
+
+ ); +} diff --git a/src/components/TabButton.tsx b/src/components/TabButton.tsx index b9705f43..1babbe2e 100644 --- a/src/components/TabButton.tsx +++ b/src/components/TabButton.tsx @@ -1,4 +1,6 @@ +import { useRef, useLayoutEffect } from 'react'; import { cn } from '@/lib/utils'; +import { useSubHeaderBarHover } from '@/components/SubHeaderBar'; interface TabButtonProps { /** Tab display label. */ @@ -25,12 +27,29 @@ interface TabButtonProps { * - Switching to a **different** tab resets scroll position instantly. */ export function TabButton({ label, active, onClick, disabled, className, indicatorClassName, children }: TabButtonProps) { + const ref = useRef(null); + const { onHover, onActive } = useSubHeaderBarHover(); + + const reportSlice = () => { + const btn = ref.current; + if (!btn) return; + return { left: btn.offsetLeft, width: btn.offsetWidth }; + }; + + useLayoutEffect(() => { + if (!active) return; + const s = reportSlice(); + if (s) onActive(s); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); + + const handleMouseEnter = () => { const s = reportSlice(); if (s) onHover(s); }; + const handleMouseLeave = () => onHover(null); + const handleClick = () => { if (active) { - // Re-tapping the active tab -> smooth scroll to top window.scrollTo({ top: 0, behavior: 'smooth' }); } else { - // Switching tabs -> jump to top instantly, then switch window.scrollTo({ top: 0 }); onClick(); } @@ -38,19 +57,19 @@ export function TabButton({ label, active, onClick, disabled, className, indicat return ( ); } diff --git a/src/components/TagFeedPage.tsx b/src/components/TagFeedPage.tsx index e8874b2c..e0affe5c 100644 --- a/src/components/TagFeedPage.tsx +++ b/src/components/TagFeedPage.tsx @@ -1,6 +1,5 @@ import { useMemo, type ReactNode } from 'react'; -import { ArrowLeft, Plus, Check, Loader2 } from 'lucide-react'; -import { Link } from 'react-router-dom'; +import { Plus, Check, Loader2 } from 'lucide-react'; import { useNostr } from '@nostrify/react'; import { useQuery } from '@tanstack/react-query'; import { NoteCard } from '@/components/NoteCard'; @@ -13,8 +12,8 @@ import { useInterests } from '@/hooks/useInterests'; import { useMuteList } from '@/hooks/useMuteList'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isRepostKind } from '@/lib/feedUtils'; +import { PageHeader } from '@/components/PageHeader'; import { isEventMuted } from '@/lib/muteHelpers'; -import { cn, STICKY_HEADER_CLASS } from '@/lib/utils'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; interface TagFeedPageProps { @@ -96,12 +95,10 @@ export function TagFeedPage({ return (
-
- - - - {icon && {icon}} -

{title}

+ {icon} : undefined} + > {followable && user && tag && (
+ {isLoading ? ( diff --git a/src/components/icons/BlueskyIcon.tsx b/src/components/icons/BlueskyIcon.tsx new file mode 100644 index 00000000..e62952f0 --- /dev/null +++ b/src/components/icons/BlueskyIcon.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +/** + * Bluesky butterfly logo icon. + * Rendered as a filled SVG component using currentColor. + */ +export const BlueskyIcon = React.forwardRef>( + ({ className, ...props }, ref) => ( + + + + ), +); + +BlueskyIcon.displayName = 'BlueskyIcon'; diff --git a/src/components/icons/PlanetButton.tsx b/src/components/icons/PlanetButton.tsx new file mode 100644 index 00000000..c231384f --- /dev/null +++ b/src/components/icons/PlanetButton.tsx @@ -0,0 +1,52 @@ +import { useId } from 'react'; +import { cn } from '@/lib/utils'; + +interface PlanetButtonProps { + className?: string; +} + +/** + * Filled planet-with-ring SVG shape used as the FAB background. + * + * Uses `useId()` to scope mask IDs so multiple instances can coexist + * without ID collisions. + */ +export function PlanetButton({ className }: PlanetButtonProps) { + const uid = useId(); + const maskId = `${uid}-planet-body-mask`; + + return ( + + + {/* Mask: white = visible, black = cut out. + The middle arc (crossing through the circle) is stroked black + so the ring appears to pass in front there. */} + + + + + + {/* Planet body with solid fill, front-arc gap cut out */} + + {/* Full ring as one continuous path */} + + + ); +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index bf12daaa..ea34e817 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -36,7 +36,7 @@ const sheetVariants = cva( top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + left: "inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", }, diff --git a/src/contexts/LayoutContext.ts b/src/contexts/LayoutContext.ts index 236a67ec..6fe02df9 100644 --- a/src/contexts/LayoutContext.ts +++ b/src/contexts/LayoutContext.ts @@ -34,6 +34,18 @@ export interface LayoutOptions { * full-width page layouts (e.g. messaging). */ noMaxWidth?: boolean; + /** + * If true, indicates the page renders its own sub-header with a decorative + * arc (e.g. tab bars). The mobile top bar will skip its own arc to avoid + * doubling up. + */ + hasSubHeader?: boolean; + /** + * If true, all decorative arcs are replaced with plain rectangles on the + * mobile top bar, bottom nav, and sub-header. Use for immersive pages + * (e.g. vines) where curved chrome interferes with full-bleed content. + */ + noArcs?: boolean; } type Listener = () => void; @@ -102,7 +114,9 @@ export function useLayoutOptions(options: LayoutOptions): void { prev.current.rightSidebar !== options.rightSidebar || prev.current.scrollContainer !== options.scrollContainer || prev.current.noOverscroll !== options.noOverscroll || - prev.current.noMaxWidth !== options.noMaxWidth; + prev.current.noMaxWidth !== options.noMaxWidth || + prev.current.hasSubHeader !== options.hasSubHeader || + prev.current.noArcs !== options.noArcs; if (changed) { prev.current = options; diff --git a/src/hooks/useBlueskyActorSearch.ts b/src/hooks/useBlueskyActorSearch.ts new file mode 100644 index 00000000..3b52b318 --- /dev/null +++ b/src/hooks/useBlueskyActorSearch.ts @@ -0,0 +1,86 @@ +import { useQuery } from '@tanstack/react-query'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BSKY_PUBLIC_API = 'https://api.bsky.app/xrpc'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BlueskyActorResult { + /** DID of the actor */ + did: string; + /** Handle (e.g. jay.bsky.team) */ + handle: string; + /** Display name */ + displayName?: string; + /** Avatar URL */ + avatar?: string; + /** Bio / description */ + description?: string; + /** Bsky profile URL */ + url: string; +} + +interface SearchActorsTypeaheadResponse { + actors: Array<{ + did: string; + handle: string; + displayName?: string; + avatar?: string; + description?: string; + }>; +} + +// --------------------------------------------------------------------------- +// Fetcher +// --------------------------------------------------------------------------- + +async function searchActors( + query: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + q: query, + limit: '8', + }); + + const res = await fetch( + `${BSKY_PUBLIC_API}/app.bsky.actor.searchActorsTypeahead?${params}`, + { signal, headers: { Accept: 'application/json' } }, + ); + + if (!res.ok) return []; + + const data: SearchActorsTypeaheadResponse = await res.json(); + if (!data.actors) return []; + + return data.actors.map((actor) => ({ + did: actor.did, + handle: actor.handle, + displayName: actor.displayName, + avatar: actor.avatar, + description: actor.description, + url: `https://bsky.app/profile/${actor.handle}`, + })); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** Hook to search Bluesky users by name/handle (typeahead). */ +export function useBlueskyActorSearch(query: string) { + return useQuery({ + queryKey: ['bluesky-actor-search', query], + queryFn: ({ signal }) => searchActors(query, signal), + enabled: query.trim().length >= 1, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 30, + retry: 1, + placeholderData: (prev) => prev, + }); +} diff --git a/src/hooks/useBlueskySearch.ts b/src/hooks/useBlueskySearch.ts new file mode 100644 index 00000000..eb506002 --- /dev/null +++ b/src/hooks/useBlueskySearch.ts @@ -0,0 +1,113 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { BlueskyPost } from './useBlueskyTrending'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BSKY_PUBLIC_API = 'https://api.bsky.app/xrpc'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BlueskySearchResult { + /** The AT URI of the post */ + uri: string; + /** Author handle */ + handle: string; + /** Author display name */ + displayName: string; + /** Author avatar URL */ + avatar?: string; + /** Post text (truncated) */ + text: string; + /** Bsky post URL for opening in browser */ + url: string; + /** Like count */ + likes: number; + /** First image thumbnail if available */ + thumbnail?: string; +} + +interface SearchPostsResponse { + posts: BlueskyPost[]; + cursor?: string; + hitsTotal?: number; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Convert an AT URI (at://did/collection/rkey) into a bsky.app web URL. */ +function postUrl(uri: string, handle: string): string { + const parts = uri.split('/'); + const rkey = parts[parts.length - 1]; + return `https://bsky.app/profile/${handle}/post/${rkey}`; +} + +// --------------------------------------------------------------------------- +// Fetcher +// --------------------------------------------------------------------------- + +async function searchBluesky( + query: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + q: query, + sort: 'top', + limit: '12', + lang: 'en', + }); + + const res = await fetch(`${BSKY_PUBLIC_API}/app.bsky.feed.searchPosts?${params}`, { + signal, + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) return []; + + const data: SearchPostsResponse = await res.json(); + if (!data.posts) return []; + + return data.posts.map((post) => { + // Extract first image thumbnail from embed + let thumbnail: string | undefined; + if (post.embed?.$type === 'app.bsky.embed.images#view' && post.embed?.images?.[0]) { + thumbnail = post.embed.images[0].thumb; + } else if (post.embed?.$type === 'app.bsky.embed.external#view' && post.embed?.external?.thumb) { + thumbnail = post.embed.external.thumb; + } + + return { + uri: post.uri, + handle: post.author.handle, + displayName: post.author.displayName ?? post.author.handle, + avatar: post.author.avatar, + text: post.record.text.slice(0, 200), + url: postUrl(post.uri, post.author.handle), + likes: post.likeCount, + thumbnail, + }; + }); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** Hook to search Bluesky posts by keyword. */ +export function useBlueskySearch(query: string) { + return useQuery({ + queryKey: ['bluesky-search', query], + queryFn: ({ signal }) => searchBluesky(query, signal), + enabled: query.trim().length >= 2, + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 30, + retry: 1, + placeholderData: (prev) => prev, + }); +} diff --git a/src/hooks/useBlueskyTrending.ts b/src/hooks/useBlueskyTrending.ts new file mode 100644 index 00000000..cc58058c --- /dev/null +++ b/src/hooks/useBlueskyTrending.ts @@ -0,0 +1,130 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BlueskyAuthor { + did: string; + handle: string; + displayName?: string; + avatar?: string; +} + +export interface BlueskyImage { + thumb: string; + fullsize: string; + alt: string; + aspectRatio?: { width: number; height: number }; +} + +export interface BlueskyExternal { + uri: string; + title: string; + description: string; + thumb?: string; +} + +export interface BlueskyEmbed { + $type: string; + images?: BlueskyImage[]; + external?: BlueskyExternal; +} + +export interface BlueskyPost { + uri: string; + cid: string; + author: BlueskyAuthor; + record: { + $type: string; + text: string; + createdAt: string; + langs?: string[]; + embed?: BlueskyEmbed; + }; + embed?: BlueskyEmbed; + replyCount: number; + repostCount: number; + likeCount: number; + quoteCount: number; + indexedAt: string; +} + +interface GetFeedResponse { + feed: Array<{ + post: BlueskyPost; + feedContext?: string; + }>; + cursor?: string; +} + +/** A single page of results returned by the hook. */ +export interface BlueskyTrendingPage { + posts: BlueskyPost[]; + cursor?: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BSKY_PUBLIC_API = 'https://api.bsky.app/xrpc'; + +/** Bluesky's official Discover feed — curated trending content, no NSFW. */ +const DISCOVER_FEED_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'; + +const PAGE_SIZE = 25; + +// --------------------------------------------------------------------------- +// Fetcher +// --------------------------------------------------------------------------- + +async function fetchTrendingPage( + cursor: string | undefined, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + feed: DISCOVER_FEED_URI, + limit: String(PAGE_SIZE), + }); + + if (cursor) { + params.set('cursor', cursor); + } + + const res = await fetch(`${BSKY_PUBLIC_API}/app.bsky.feed.getFeed?${params}`, { + signal, + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) return { posts: [] }; + + const data: GetFeedResponse = await res.json(); + if (!data.feed) return { posts: [] }; + + return { + posts: data.feed.map((item) => item.post), + cursor: data.cursor, + }; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Fetches trending/popular posts from Bluesky's official Discover feed. + * Returns curated, high-engagement content without NSFW. + * Supports infinite scroll via cursor-based pagination. + */ +export function useBlueskyTrending() { + return useInfiniteQuery({ + queryKey: ['bluesky-trending'], + queryFn: ({ pageParam, signal }) => fetchTrendingPage(pageParam, signal), + getNextPageParam: (lastPage) => lastPage.cursor, + initialPageParam: undefined as string | undefined, + staleTime: 1000 * 60 * 15, // 15 minutes + gcTime: 1000 * 60 * 60, // 1 hour + retry: 2, + }); +} diff --git a/src/index.css b/src/index.css index 60a10387..af3a5608 100644 --- a/src/index.css +++ b/src/index.css @@ -5,6 +5,9 @@ @layer base { :root { --radius: 0.75rem; + /* Mobile chrome dimensions — referenced by layout utility classes below */ + --top-bar-height: 2.5rem; /* MobileTopBar h-10 = 40px */ + --bottom-nav-height: 2.75rem; /* MobileBottomNav h-11 = 44px */ } } @@ -42,19 +45,19 @@ /* FAB bottom offset: clears bottom nav + safe area inset on mobile */ .bottom-fab { - bottom: calc(1.5rem + 3.5rem + env(safe-area-inset-bottom, 0px)); + bottom: calc(1.5rem + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)); } - /* Position above mobile bottom nav (h-14 = 3.5rem) + safe area */ + /* Position above mobile bottom nav + safe area + arc overhang (28px) */ .bottom-mobile-nav { - bottom: calc(3.5rem + env(safe-area-inset-bottom, 0px)); + bottom: calc(var(--bottom-nav-height) + 28px + env(safe-area-inset-bottom, 0px)); } /* Bottom overscroll padding for the center column: clears the mobile bottom nav + safe area + generous extra space so content can be scrolled well past the bottom bar */ .pb-overscroll { - padding-bottom: calc(10vh + 3.5rem + env(safe-area-inset-bottom, 0px)); + padding-bottom: calc(10vh + var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)); } @media (min-width: 900px) { @@ -63,31 +66,41 @@ } } - /* Mobile top bar height (48px) + safe area inset for sticky elements */ + /* Mobile top bar height + safe area inset for sticky elements */ .top-mobile-bar { - top: calc(3rem + env(safe-area-inset-top, 0px)); + top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px)); } - /* AI chat height on mobile: full viewport minus top bar, bottom nav (3.5rem), and safe-area insets */ + /* Negative margin to pull content area up behind the mobile top bar (only when it's visible) */ + @media (max-width: 899px) { + .-mt-mobile-bar { + margin-top: calc(-1 * var(--top-bar-height) - env(safe-area-inset-top, 0px)); + padding-top: calc(var(--top-bar-height) + env(safe-area-inset-top, 0px)); + } + } + + /* AI chat height on mobile: full viewport minus top bar, extends behind bottom nav. + Padding-bottom keeps input above the nav. */ .ai-chat-height { - height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); + height: calc(100dvh - var(--top-bar-height) - env(safe-area-inset-top, 0px)); + padding-bottom: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)); } /* Live stream page height on mobile: full viewport minus top bar, bottom nav, and safe-area insets */ .livestream-height { - height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-bottom, 0px)); - max-height: calc(100dvh - 3rem - 3.5rem - env(safe-area-inset-bottom, 0px)); + height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - env(safe-area-inset-bottom, 0px)); + max-height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - env(safe-area-inset-bottom, 0px)); } /* Vine feed slide height: full viewport minus top bar, tab bar, bottom nav, and safe-area insets */ .vine-slide-height { - height: calc(100dvh - env(safe-area-inset-top, 0px) - 3rem - 2.5rem - 3.5rem - env(safe-area-inset-bottom, 0px)); + height: calc(100dvh - env(safe-area-inset-top, 0px) - var(--top-bar-height) - var(--top-bar-height) - var(--bottom-nav-height) - env(safe-area-inset-bottom, 0px)); transition: height 300ms ease-in-out; } /* Expanded vine height when bottom nav is hidden */ .vine-slide-height-expanded { - height: calc(100dvh - env(safe-area-inset-top, 0px) - 3rem - 2.5rem); + height: calc(100dvh - env(safe-area-inset-top, 0px) - var(--top-bar-height) - var(--top-bar-height)); transition: height 300ms ease-in-out; } } diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index d287aece..77201e94 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -38,6 +38,7 @@ import { CardsIcon } from "@/components/icons/CardsIcon"; import { ChestIcon } from "@/components/icons/ChestIcon"; import { PlanetIcon } from "@/components/icons/PlanetIcon"; import { WikipediaIcon } from "@/components/icons/WikipediaIcon"; +import { BlueskyIcon } from "@/components/icons/BlueskyIcon"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -170,6 +171,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ { id: "world", label: "World", path: "/world", icon: Earth }, { id: "archive", label: "Archive", path: "/archive", icon: Archive }, { id: "wikipedia", label: "Wikipedia", path: "/wikipedia", icon: WikipediaIcon }, + { id: "bluesky", label: "Bluesky", path: "/bluesky", icon: BlueskyIcon }, ]; /** Set of all known sidebar item IDs for quick lookup. */ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ef7346ff..854074ea 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,13 +5,6 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -/** - * Sticky header class for pages inside MainLayout. - * On mobile, sticks below the top bar (accounting for safe area). - * On desktop (sidebar+), sticks to the top of the viewport. - */ -export const STICKY_HEADER_CLASS = 'sticky top-mobile-bar sidebar:top-0'; - /** * Parse a kindFilter string into an array of kind numbers. * Supports: diff --git a/src/pages/AIChatPage.tsx b/src/pages/AIChatPage.tsx index 5c6f808a..620ae414 100644 --- a/src/pages/AIChatPage.tsx +++ b/src/pages/AIChatPage.tsx @@ -2,8 +2,9 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import Markdown from 'react-markdown'; import rehypeSanitize from 'rehype-sanitize'; -import { Bot, Send, Sparkles, Trash2, Palette, Type } from 'lucide-react'; +import { Bot, Send, Trash2, Palette, Type } from 'lucide-react'; +import { PageHeader } from '@/components/PageHeader'; import { useShakespeare, type ChatMessage, type Model } from '@/hooks/useShakespeare'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; @@ -413,15 +414,10 @@ export function AIChatPage() { } return ( -
+
{/* Header */} -
-
-
- -
-

AI Chat

-
+
+ } className="px-0 mt-0 mb-0" />
{/* Model selector */} @@ -490,7 +486,7 @@ export function AIChatPage() { {/* Input Area */} -
+