Merge branch 'main' into feat-blobbi
This commit is contained in:
+7
-1
@@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
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 = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CAPACITOR_DEBUG</key>
|
||||
<string>$(CAPACITOR_DEBUG)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ditto</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
public let isCapacitorApp = true
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
Generated
+14
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/badges" element={<BadgesPage />} />
|
||||
<Route path="/books" element={<BooksPage />} />
|
||||
<Route path="/archive" element={<ArchivePage />} />
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/help" element={<HelpPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
|
||||
@@ -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 <svg> 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 (
|
||||
<svg
|
||||
className={cn(positionClass, 'w-full pointer-events-none', className)}
|
||||
viewBox="0 0 100 64"
|
||||
preserveAspectRatio="none"
|
||||
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
|
||||
>
|
||||
<path d={path} className="fill-background/85" />
|
||||
{variant === 'down' && <path d="M0,44 Q50,64 100,44" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
|
||||
{variant === 'up' && <path d="M0,30 Q50,0 100,30" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -35,9 +35,9 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
|
||||
/>
|
||||
)}
|
||||
{summary ? (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3 mt-2">{summary}</p>
|
||||
<p className="text-[15px] leading-relaxed line-clamp-3 mt-2">{summary}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3 mt-2">
|
||||
<p className="text-[15px] leading-relaxed line-clamp-3 mt-2">
|
||||
{event.content.slice(0, 280)}{event.content.length > 280 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
@@ -58,7 +58,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
|
||||
className="w-full rounded-xl object-cover max-h-96 mb-6"
|
||||
/>
|
||||
)}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
|
||||
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
|
||||
<Markdown rehypePlugins={[rehypeSanitize]}>
|
||||
{event.content}
|
||||
</Markdown>
|
||||
|
||||
@@ -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 (
|
||||
<ExternalPostCard
|
||||
post={{ ...post, brandIcon: <BlueskyLogo className="size-3.5" /> }}
|
||||
hideImage={hideImage}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1030,7 +1030,7 @@ export function ComposeBox({
|
||||
if (!user && compact) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-3", !forceExpanded && "border-b border-border")}>
|
||||
<div className={cn("px-4 py-3 bg-background/50")}>
|
||||
{/* Preview toggle at top when not controlled and has previewable content */}
|
||||
{hasPreviewableContent && controlledPreviewMode === undefined && (
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
|
||||
@@ -136,7 +136,7 @@ export function CustomNipCard({ event, preview = true }: CustomNipCardProps) {
|
||||
{/* Full markdown content -- detail view only, outside card */}
|
||||
{!preview && event.content && (
|
||||
<div className="rounded-2xl border border-border overflow-hidden px-3.5 py-3">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
|
||||
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
|
||||
<Markdown rehypePlugins={[rehypeSanitize]}>
|
||||
{event.content}
|
||||
</Markdown>
|
||||
|
||||
@@ -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 <WikipediaArticleHeader title={wikiTitle} url={url} />;
|
||||
}
|
||||
|
||||
if (blueskyPost) {
|
||||
return <BlueskyPostHeader author={blueskyPost.author} rkey={blueskyPost.rkey} url={url} />;
|
||||
}
|
||||
|
||||
return <LinkEmbed url={url} showActions={false} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<div className="flex gap-6 pt-1">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !post) {
|
||||
return <LinkEmbed url={url} showActions={false} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="py-1">
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar */}
|
||||
<Link to={profileUrl} className="shrink-0">
|
||||
{post.avatar ? (
|
||||
<img
|
||||
src={post.avatar}
|
||||
alt=""
|
||||
className="size-11 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-11 rounded-full bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center text-white text-sm font-bold">
|
||||
{(post.displayName ?? post.handle).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Author info */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Link to={profileUrl} className="font-semibold text-[15px] truncate leading-tight hover:underline">
|
||||
{post.displayName ?? post.handle}
|
||||
</Link>
|
||||
<Link to={profileUrl} className="text-muted-foreground text-sm truncate leading-tight hover:underline">
|
||||
@{post.handle}
|
||||
</Link>
|
||||
<span className="text-muted-foreground text-sm shrink-0">·</span>
|
||||
<span className="text-muted-foreground text-sm shrink-0">
|
||||
{blueskyTimeAgo(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Post text */}
|
||||
{post.text && (
|
||||
<p className="mt-1 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
|
||||
{post.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Image embeds */}
|
||||
{post.images && post.images.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 rounded-xl overflow-hidden border border-border',
|
||||
post.images.length === 1 && 'grid grid-cols-1',
|
||||
post.images.length === 2 && 'grid grid-cols-2 gap-0.5',
|
||||
post.images.length === 3 && 'grid grid-cols-2 gap-0.5',
|
||||
post.images.length >= 4 && 'grid grid-cols-2 gap-0.5',
|
||||
)}
|
||||
>
|
||||
{post.images.slice(0, 4).map((img, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-secondary',
|
||||
post.images!.length === 1 ? 'aspect-video' : 'aspect-square',
|
||||
post.images!.length === 3 && i === 0 && 'row-span-2 aspect-auto',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={img.thumb}
|
||||
alt={img.alt || ''}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External link embed */}
|
||||
{post.external && post.external.thumb && (
|
||||
<div className="mt-3 rounded-xl border border-border overflow-hidden bg-secondary/30">
|
||||
<div className="aspect-[2/1] overflow-hidden bg-secondary">
|
||||
<img
|
||||
src={post.external.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
{post.external.title && (
|
||||
<div className="px-3 py-2.5">
|
||||
<p className="text-sm font-semibold leading-tight line-clamp-2">{post.external.title}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-5 mt-3 -ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleComment}
|
||||
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
|
||||
title="Comment"
|
||||
>
|
||||
<MessageCircle className="size-[18px]" />
|
||||
{post.replyCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.replyCount)}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRepost}
|
||||
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
|
||||
title="Share to feed"
|
||||
>
|
||||
<Repeat2 className="size-[18px]" />
|
||||
{post.repostCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.repostCount)}</span>}
|
||||
</button>
|
||||
<ExternalReactionButton content={externalContent} iconSize="size-[18px]" count={post.likeCount} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
className="inline-flex items-center p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Share link"
|
||||
>
|
||||
<Share2 className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bluesky source link */}
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<BlueskyIcon className="size-3.5 text-sky-500" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors hover:underline"
|
||||
>
|
||||
View on Bluesky
|
||||
</a>
|
||||
<ExternalLink className="size-3" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Comment compose modal */}
|
||||
{commentOpen && (
|
||||
<ReplyComposeModal
|
||||
open={commentOpen}
|
||||
onOpenChange={setCommentOpen}
|
||||
event={new URL(url)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share compose modal */}
|
||||
{shareOpen && (
|
||||
<ReplyComposeModal
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
initialContent={url}
|
||||
title="Share to feed"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wikipedia article header (rich display for Wikipedia URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 && (
|
||||
<div className={cn(
|
||||
'w-full overflow-hidden',
|
||||
post.images.length > 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 && (
|
||||
<div className="w-full overflow-hidden">
|
||||
<img
|
||||
src={post.external.thumb}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { QuickReactMenu } from '@/components/QuickReactMenu';
|
||||
import { ReactionEmoji } from '@/components/CustomEmoji';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
useExternalUserReaction,
|
||||
useExternalReactionCount,
|
||||
} from '@/hooks/useExternalReactions';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExternalContent } from '@/lib/externalContent';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: NIP-73 k tag value
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getExternalKTag(content: ExternalContent): string {
|
||||
switch (content.type) {
|
||||
case 'url': return 'web';
|
||||
case 'isbn': return 'isbn';
|
||||
case 'iso3166': return 'iso3166';
|
||||
default: return 'web';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExternalReactionButton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ExternalReactionButtonProps {
|
||||
/** Parsed NIP-73 external content. */
|
||||
content: ExternalContent;
|
||||
/** Icon size class (default "size-5"). */
|
||||
iconSize?: string;
|
||||
/** Display count from an external source (e.g. Bluesky like count). Falls back to the Nostr reaction count. */
|
||||
count?: number;
|
||||
/** Extra class names on the trigger button. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully-featured reaction button for NIP-73 external content.
|
||||
*
|
||||
* Includes hover-to-open emoji picker via `QuickReactMenu`, optimistic UI,
|
||||
* and displays the user's existing reaction & total count.
|
||||
*/
|
||||
export function ExternalReactionButton({ content, iconSize = 'size-5', count, className }: ExternalReactionButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const identifier = content.value;
|
||||
|
||||
const userReactionData = useExternalUserReaction(content);
|
||||
const reactionCount = useExternalReactionCount(content);
|
||||
|
||||
const hasReacted = !!userReactionData;
|
||||
const userEmoji = userReactionData?.emoji;
|
||||
const userReactionTags = userReactionData?.tags;
|
||||
|
||||
// Popover state
|
||||
const [reactOpen, setReactOpen] = useState(false);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<Popover open={reactOpen} onOpenChange={(open) => {
|
||||
if (open && justClosedRef.current) return;
|
||||
if (!open) pickerExpandedRef.current = false;
|
||||
setReactOpen(open);
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
|
||||
hasReacted
|
||||
? 'text-pink-500'
|
||||
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
|
||||
className,
|
||||
)}
|
||||
title="React"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!user) return;
|
||||
if (justClosedRef.current) return;
|
||||
setReactOpen((prev) => !prev);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hasReacted && userEmoji ? (
|
||||
<span className={cn(iconSize, 'flex items-center justify-center text-base leading-none')}>
|
||||
<ReactionEmoji content={userEmoji} tags={userReactionTags} className={iconSize} />
|
||||
</span>
|
||||
) : (
|
||||
<Heart className={iconSize} />
|
||||
)}
|
||||
{(count ?? reactionCount) > 0 && (
|
||||
<span className="text-sm tabular-nums">{formatNumber(count ?? reactionCount)}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0 border-0 bg-transparent shadow-none"
|
||||
side="top"
|
||||
align="start"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<QuickReactMenu
|
||||
eventId={identifier}
|
||||
eventPubkey=""
|
||||
eventKind={17}
|
||||
onExpandChange={(expanded) => { pickerExpandedRef.current = expanded; }}
|
||||
onClose={() => {
|
||||
pickerExpandedRef.current = false;
|
||||
justClosedRef.current = true;
|
||||
setReactOpen(false);
|
||||
setTimeout(() => { justClosedRef.current = false; }, 300);
|
||||
}}
|
||||
onReact={handleReact}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
+15
-9
@@ -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 (
|
||||
<main className="flex-1 min-w-0">
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero
|
||||
onLoginClick={() => setLoginDialogOpen(true)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideCompose && <ComposeBox compact />}
|
||||
|
||||
{header}
|
||||
|
||||
{/* Tabs (logged in) or CTA (logged out, main feed only) */}
|
||||
{user ? (
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 overflow-x-auto scrollbar-none">
|
||||
{/* Tabs (logged in) */}
|
||||
{user && (
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
|
||||
{!isKindSpecificPage && showDittoFeed && (
|
||||
<TabButton label="Ditto" active={activeTab === 'ditto'} onClick={() => handleSetActiveTab('ditto')} />
|
||||
@@ -275,15 +285,11 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
</span>
|
||||
</TabButton>
|
||||
))}
|
||||
</div>
|
||||
) : !kinds && (
|
||||
<LandingHero
|
||||
onLoginClick={() => setLoginDialogOpen(true)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
{/* Feed content — saved feed tab gets its own stream */}
|
||||
{user && <div style={{ height: ARC_OVERHANG_PX }} />}
|
||||
{activeHashtag ? (
|
||||
<HashtagFeedContent tag={activeHashtag} />
|
||||
) : activeGeotag ? (
|
||||
|
||||
@@ -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<React.CSSProperties | undefined>(() => {
|
||||
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 (
|
||||
<>
|
||||
<Button
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="size-14 rounded-full shadow-lg bg-accent hover:bg-accent/90 text-accent-foreground transition-transform hover:scale-105 active:scale-95"
|
||||
className="relative size-16 transition-transform hover:scale-105 active:scale-95"
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
|
||||
>
|
||||
{icon ?? <Plus strokeWidth={4} />}
|
||||
</Button>
|
||||
{/* FAB background: user's avatar shape (emoji mask) or circle (default) */}
|
||||
<div
|
||||
className="absolute inset-0 bg-primary rounded-full"
|
||||
style={shapeMaskStyle}
|
||||
/>
|
||||
{/* Plus icon centered on the button */}
|
||||
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
|
||||
{icon ?? <Plus strokeWidth={4} size={16} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Kind 1: Compose modal */}
|
||||
{kind === 1 && (
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex border-t border-b border-border bg-background/80 backdrop-blur-md z-10')}>
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
|
||||
<TabButton label="Members" active={activeTab === 'members'} onClick={() => setActiveTab('members')} />
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'feed' ? (
|
||||
|
||||
@@ -63,7 +63,7 @@ export function LinkEmbed({ url, className, navigateToComments, showActions = tr
|
||||
embed = <TweetEmbed tweetId={tweetId} />;
|
||||
} else if (blueskyPost) {
|
||||
// BlueskyEmbed has built-in /i/ navigation, no DiscussBar needed
|
||||
return <BlueskyEmbed author={blueskyPost.author} rkey={blueskyPost.rkey} className={className} />;
|
||||
return <BlueskyEmbed author={blueskyPost.author} rkey={blueskyPost.rkey} hideImage={hideImage} className={className} />;
|
||||
} else if (mastodonUrl) {
|
||||
// MastodonEmbed has built-in /i/ navigation, no DiscussBar needed
|
||||
return <MastodonEmbed url={mastodonUrl} className={className} />;
|
||||
|
||||
@@ -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) {
|
||||
<>
|
||||
<main className="xl:max-sidebar:flex max-sidebar:flex max-sidebar:flex-col max-sidebar:livestream-height max-sidebar:overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 sidebar:sticky sidebar:top-0 z-10 flex items-center gap-4 px-4 mt-2 mb-2 sidebar:mt-4 sidebar:mb-4 bg-background/80 backdrop-blur-md">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold truncate">Live Stream</h1>
|
||||
<PageHeader
|
||||
title="Live Stream"
|
||||
onBack={() => 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"
|
||||
>
|
||||
<Badge variant="outline" className={cn('ml-auto shrink-0', statusConfig.className)}>
|
||||
{status === 'live' && <Radio className="size-3 mr-1" />}
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Video Player */}
|
||||
<div className="xl:px-4 shrink-0">
|
||||
|
||||
@@ -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 && <CursorFireEffect />}
|
||||
|
||||
{/* Mobile top bar - only on small screens */}
|
||||
<MobileTopBar onAvatarClick={() => setDrawerOpen(true)} />
|
||||
<MobileTopBar onAvatarClick={() => setDrawerOpen(true)} hasSubHeader={hasSubHeader} />
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<MobileDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
|
||||
@@ -88,15 +91,14 @@ function MainLayoutInner() {
|
||||
|
||||
{/* Main content + right sidebar: inside Suspense so the left sidebar persists while lazy pages load */}
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<div className={cn("relative flex-1 min-w-0 sidebar:border-l border-r border-border bg-background/85", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}>
|
||||
{/* -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. */}
|
||||
<div className={cn("relative flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85 -mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}>
|
||||
<Outlet />
|
||||
{showFAB && (
|
||||
<div className="sticky bottom-fab sidebar:bottom-6 z-30 pointer-events-none flex justify-end pr-6">
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
|
||||
</Suspense>
|
||||
@@ -104,6 +106,18 @@ function MainLayoutInner() {
|
||||
|
||||
{/* Mobile bottom nav - only on small screens, slides out on scroll */}
|
||||
<MobileBottomNav />
|
||||
|
||||
{/* FAB - fixed, mirrors bottom nav hide/show transition on mobile */}
|
||||
{showFAB && (
|
||||
<div
|
||||
className="fixed bottom-fab sidebar:bottom-6 right-6 z-30 pointer-events-none sidebar:transition-none transition-transform duration-300 ease-in-out"
|
||||
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)))` } : undefined}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'fixed bottom-0 left-0 right-0 z-40 bg-background/80 backdrop-blur-md border-t border-border sidebar:hidden safe-area-bottom',
|
||||
'fixed bottom-0 left-0 right-0 z-40 sidebar:hidden will-change-transform',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isHidden && 'translate-y-full',
|
||||
)}
|
||||
style={isHidden ? hiddenStyle : undefined}
|
||||
>
|
||||
<div className="h-14 flex items-center">
|
||||
{/* Arc + items wrapper */}
|
||||
<div className="relative">
|
||||
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
|
||||
<div className="h-11 flex items-center relative">
|
||||
|
||||
{/* Feed */}
|
||||
<Link
|
||||
@@ -112,16 +121,17 @@ export function MobileBottomNav() {
|
||||
) : (
|
||||
<Link
|
||||
to="/profile"
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors text-muted-foreground',
|
||||
)}
|
||||
className="flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors text-muted-foreground"
|
||||
>
|
||||
<User className="size-5" />
|
||||
<span className="text-[10px] font-medium">Profile</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Safe area spacer — fully opaque so any subpixel gap is invisible */}
|
||||
<div className="safe-area-bottom bg-background" />
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) setMoreMenuOpen(false); onOpenChange(v); }}>
|
||||
<SheetContent side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col" style={bgStyle}>
|
||||
{hasBgImage && <div className="absolute inset-0 bg-background/70 pointer-events-none" />}
|
||||
<SheetContent side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col overflow-visible">
|
||||
{/* 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. */}
|
||||
<svg className="absolute" width="0" height="0" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
|
||||
<path d="M0,0 L0.893,0 Q1,0.5 0.893,1 L0,1 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className="absolute top-0 left-0 bottom-0 pointer-events-none bg-background"
|
||||
style={{ ...bgStyle, ...clipStyle }}
|
||||
/>
|
||||
{hasBgImage && (
|
||||
<div
|
||||
className="absolute top-0 left-0 bottom-0 bg-background/70 pointer-events-none"
|
||||
style={clipStyle}
|
||||
/>
|
||||
)}
|
||||
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
|
||||
|
||||
{user ? (
|
||||
|
||||
@@ -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 (
|
||||
<header className="sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-border sidebar:hidden safe-area-top">
|
||||
<div className="flex items-center px-3 h-12">
|
||||
<header className="sticky top-0 z-20 sidebar:hidden safe-area-top">
|
||||
<ArcBackground variant={hasSubHeader ? 'rect' : 'down'} />
|
||||
<div className="relative flex items-center px-3 h-10">
|
||||
{/* Left: hamburger menu icon */}
|
||||
<div className="flex items-center justify-center w-7 shrink-0">
|
||||
<button onClick={onAvatarClick} className="rounded-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:ring-offset-background text-muted-foreground hover:text-foreground transition-colors">
|
||||
@@ -27,12 +31,12 @@ export function MobileTopBar({ onAvatarClick }: MobileTopBarProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center: Ditto logo */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Link to="/" onClick={handleLogoClick}>
|
||||
<DittoLogo size={28} />
|
||||
</Link>
|
||||
</div>
|
||||
{/* Center: Ditto logo */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Link to="/" onClick={handleLogoClick}>
|
||||
<DittoLogo size={28} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: spacer for symmetry */}
|
||||
<div className="w-7 shrink-0" />
|
||||
|
||||
@@ -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 `<button>` instead of a `<Link>`. */
|
||||
onBack?: () => void;
|
||||
/** Always show the back button, even on desktop (default: mobile only). */
|
||||
alwaysShowBack?: boolean;
|
||||
/** Extra elements placed after the title (e.g. KindInfoButton). */
|
||||
children?: React.ReactNode;
|
||||
/** Override the outer wrapper classes. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared page header with back button, icon, and title.
|
||||
*
|
||||
* Used by kind-feed pages, bookmarks, help, trends, and other sub-pages
|
||||
* to provide a consistent header layout.
|
||||
*/
|
||||
export function PageHeader({ title, icon, titleContent, backTo = '/', onBack, alwaysShowBack, children, className }: PageHeaderProps) {
|
||||
const backButtonClass = cn('p-2 -ml-2 rounded-full hover:bg-secondary transition-colors', !alwaysShowBack && 'sidebar:hidden');
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-4 px-4 mt-4 mb-1', className)}>
|
||||
{onBack ? (
|
||||
<button onClick={onBack} className={backButtonClass} aria-label="Go back">
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<Link to={backTo} className={backButtonClass}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
)}
|
||||
{titleContent ?? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{icon}
|
||||
<h1 className="text-xl font-bold truncate">{title}</h1>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export function PullRequestCard({
|
||||
{/* PR description */}
|
||||
{!preview && hasDescription && (
|
||||
<div className="rounded-2xl border border-border overflow-hidden px-3.5 py-3">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
|
||||
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-headings:font-bold prose-a:text-primary prose-img:rounded-lg">
|
||||
<Markdown rehypePlugins={[rehypeSanitize]}>
|
||||
{event.content}
|
||||
</Markdown>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs flex-1 truncate" title={relay.url}>
|
||||
<Link to={`/r/${encodeURIComponent(relay.url)}`} className="font-mono text-xs flex-1 truncate hover:underline" title={relay.url}>
|
||||
{renderRelayUrl(relay.url)}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 text-[10px]">
|
||||
{relay.read && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400 font-medium">Read</span>
|
||||
@@ -277,9 +278,9 @@ export function RelayListManager() {
|
||||
className="flex items-center gap-3 py-2.5 px-3 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<Wifi className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs flex-1 truncate" title={relay.url}>
|
||||
<Link to={`/r/${encodeURIComponent(relay.url)}`} className="font-mono text-xs flex-1 truncate hover:underline" title={relay.url}>
|
||||
{renderRelayUrl(relay.url)}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Settings Popover */}
|
||||
<Popover>
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embedded original post (reply only, not for URL roots or quotes) */}
|
||||
{event && !isUrl && !isQuote && (
|
||||
{/* Embedded original post (reply only, not for quotes) */}
|
||||
{event && !isQuote && (
|
||||
<div className="overflow-y-auto min-h-0 shrink">
|
||||
<EmbeddedPost event={event} />
|
||||
{isUrl ? (
|
||||
<div className="mx-4 mb-2">
|
||||
<LinkEmbed url={event.href} showActions={false} hideImage />
|
||||
</div>
|
||||
) : (
|
||||
<EmbeddedPost event={event} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bluesky disclaimer */}
|
||||
{isUrl && /bsky\.(app|social)/.test(event.href) && (
|
||||
<div className="mx-4 mb-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 flex items-start gap-2">
|
||||
<span className="text-sm leading-relaxed shrink-0" aria-hidden>⚠️</span>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||
People on Bluesky can't see you because they're not actually decentralized.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<SubHeaderBarContextValue>({ 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<HoverSlice | null>(null);
|
||||
const [active, setActive] = useState<HoverSlice | null>(null);
|
||||
|
||||
return (
|
||||
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive }}>
|
||||
<div className={cn('relative sticky top-mobile-bar sidebar:top-0 sidebar:py-2 z-10', className)}>
|
||||
<ArcBackground variant={noArc ? 'rect' : 'down'} />
|
||||
{/* Per-tab arc hover highlight: full-width arc, clipped to the hovered tab's x-slice */}
|
||||
{hover && !noArc && (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="absolute top-0 left-0 w-full pointer-events-none"
|
||||
style={{
|
||||
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
|
||||
clipPath: `inset(0 calc(100% - ${hover.left + hover.width}px) 0 ${hover.left}px)`,
|
||||
}}
|
||||
viewBox="0 0 100 64"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d="M0,0 L100,0 L100,44 Q50,64 0,44 Z" className="fill-secondary/40" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Active tab indicator: the arc's bottom edge as a stroke, clipped to the active tab's x-slice */}
|
||||
{active && !noArc && (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="absolute top-0 left-0 w-full pointer-events-none"
|
||||
style={{
|
||||
height: `calc(100% + ${ARC_OVERHANG_PX}px)`,
|
||||
clipPath: `inset(0 calc(100% - ${active.left + active.width}px) 0 ${active.left}px)`,
|
||||
}}
|
||||
viewBox="0 0 100 64"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d="M100,44 Q50,64 0,44" fill="none" className="stroke-primary" strokeWidth="3" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Tab content sits above the SVG background */}
|
||||
<div className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SubHeaderBarContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(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 (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40 px-4 whitespace-nowrap',
|
||||
'flex-1 py-1.5 text-center text-sm font-medium transition-colors relative px-4 whitespace-nowrap',
|
||||
active ? 'text-foreground' : 'text-muted-foreground',
|
||||
disabled && 'opacity-50 cursor-not-allowed hover:bg-transparent',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children ?? label}
|
||||
{active && (
|
||||
<div className={cn('absolute bottom-0 left-1/2 -translate-x-1/2 h-1 bg-primary rounded-full w-3/4 max-w-16', indicatorClassName)} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main className="">
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-4 px-4 pt-4 pb-5 bg-background/80 backdrop-blur-md z-10')}>
|
||||
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
{icon && <span className="text-muted-foreground shrink-0">{icon}</span>}
|
||||
<h1 className="text-xl font-bold flex-1 truncate min-w-0">{title}</h1>
|
||||
<PageHeader
|
||||
title={title}
|
||||
icon={icon ? <span className="text-muted-foreground shrink-0">{icon}</span> : undefined}
|
||||
>
|
||||
{followable && user && tag && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -119,7 +116,7 @@ export function TagFeedPage({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<FeedSkeleton />
|
||||
|
||||
@@ -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<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026" />
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
|
||||
BlueskyIcon.displayName = 'BlueskyIcon';
|
||||
@@ -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 (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('absolute inset-0 w-full h-full', className)}
|
||||
>
|
||||
<defs>
|
||||
{/* 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. */}
|
||||
<mask id={maskId}>
|
||||
<circle cx="12" cy="12" r="8" fill="white" />
|
||||
<path
|
||||
d="M7.06 18.24 C9.1 17.82 11.57 16.88 14.05 15.5 C16.51 14.14 18.57 12.54 19.98 11.03"
|
||||
fill="none"
|
||||
stroke="black"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
{/* Planet body with solid fill, front-arc gap cut out */}
|
||||
<circle cx="12" cy="12" r="8" fill="hsl(var(--primary))" mask={`url(#${maskId})`} />
|
||||
{/* Full ring as one continuous path */}
|
||||
<path
|
||||
d="M4.05 13 C2.35 14.8 1.55 16.5 2.25 17.5 C2.84 18.53 4.66 18.74 7.06 18.24 C9.1 17.82 11.57 16.88 14.05 15.5 C16.51 14.14 18.57 12.54 19.98 11.03 C21.66 9.22 22.4 7.54 21.75 6.5 C21.15 5.5 19.35 5.3 17.05 5.8"
|
||||
fill="none"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BlueskyActorResult[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<BlueskySearchResult[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<BlueskyTrendingPage> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
+25
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
<main className="flex flex-col ai-chat-height sidebar:h-dvh">
|
||||
<main className="flex flex-col ai-chat-height sidebar:h-dvh bg-secondary/50">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 border-b border-border bg-background/80 backdrop-blur-md px-4 py-3 flex flex-col sidebar:flex-row sidebar:items-center sidebar:justify-between gap-2 sidebar:gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
</div>
|
||||
<h1 className="font-semibold text-lg">AI Chat</h1>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-3 flex flex-col sidebar:flex-row sidebar:items-center sidebar:justify-between gap-2 sidebar:gap-3">
|
||||
<PageHeader title="AI Chat" icon={<Bot className="size-5" />} className="px-0 mt-0 mb-0" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model selector */}
|
||||
@@ -490,7 +486,7 @@ export function AIChatPage() {
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="shrink-0 border-t border-border bg-background p-4">
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="max-w-2xl mx-auto flex items-end gap-2">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
@@ -551,12 +547,7 @@ function EmptyState() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-8 text-center select-none animate-in fade-in duration-500">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-6 rounded-full bg-primary/[0.06] blur-2xl" />
|
||||
<div className="relative size-24 rounded-2xl bg-gradient-to-b from-primary/15 to-primary/5 border border-primary/10 flex items-center justify-center shadow-sm">
|
||||
<pre className="text-2xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-4xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-base font-semibold tracking-tight text-foreground">Dork AI</h2>
|
||||
<p className="text-sm text-muted-foreground">{greeting}</p>
|
||||
@@ -584,7 +575,7 @@ function MessageBubble({ message }: { message: DisplayMessage }) {
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-a:text-primary">
|
||||
<div className="prose prose-sm max-w-none text-foreground prose-headings:text-foreground prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-pre:my-2 prose-code:text-xs prose-a:text-primary">
|
||||
<Markdown rehypePlugins={[rehypeSanitize]}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { AdvancedSettings } from '@/components/AdvancedSettings';
|
||||
import { WalletSettings } from '@/components/WalletSettings';
|
||||
@@ -24,19 +23,18 @@ export function AdvancedSettingsPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold">Advanced</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Wallet connections, system configuration, and other advanced options for power users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro */}
|
||||
|
||||
+21
-25
@@ -6,7 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import {
|
||||
Award, Loader2, ArrowLeft, ChevronUp, ChevronDown, X, Check, Clock,
|
||||
Award, Loader2, ChevronUp, ChevronDown, X, Check, Clock,
|
||||
Pencil, Trash2, Upload, Users, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
|
||||
@@ -102,6 +104,7 @@ export function BadgesPage() {
|
||||
showFAB: true,
|
||||
onFabClick: () => setCreateDialogOpen(true),
|
||||
fabIcon: <Award className="size-5" />,
|
||||
hasSubHeader: !!user,
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<BadgesTab>(() => {
|
||||
@@ -124,31 +127,24 @@ export function BadgesPage() {
|
||||
|
||||
return (
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Award className="size-5" />
|
||||
<h1 className="text-xl font-bold">Badges</h1>
|
||||
</div>
|
||||
</div>
|
||||
{/* Follows / My Badges tabs */}
|
||||
{user && (
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => handleSetTab('follows')} />
|
||||
<TabButton label="My Badges" active={activeTab === 'mine'} onClick={() => handleSetTab('mine')}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
My Badges
|
||||
{pendingCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full bg-primary text-primary-foreground text-[11px] font-bold leading-none">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TabButton>
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => handleSetTab('follows')} disabled={!user} />
|
||||
<TabButton label="My Badges" active={activeTab === 'mine'} onClick={() => handleSetTab('mine')} disabled={!user}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
My Badges
|
||||
{pendingCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full bg-primary text-primary-foreground text-[11px] font-bold leading-none">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TabButton>
|
||||
</div>
|
||||
<PageHeader title="Badges" icon={<Award className="size-5" />} />
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'mine' ? (
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
FlameKindling,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Repeat2,
|
||||
Search,
|
||||
Share2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBlueskyTrending, type BlueskyPost } from '@/hooks/useBlueskyTrending';
|
||||
import { useBlueskyActorSearch, type BlueskyActorResult } from '@/hooks/useBlueskyActorSearch';
|
||||
import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
import { parseExternalUri } from '@/lib/externalContent';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert an AT URI (at://did/collection/rkey) into a bsky.app web URL. */
|
||||
function postWebUrl(uri: string, handle: string): string {
|
||||
const parts = uri.split('/');
|
||||
const rkey = parts[parts.length - 1];
|
||||
return `https://bsky.app/profile/${handle}/post/${rkey}`;
|
||||
}
|
||||
|
||||
/** Convert a bsky.app post URL into our /i/ route. */
|
||||
function dittoUrl(url: string): string {
|
||||
return `/i/${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
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 timeAgo(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`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post card — feed-style (vertical, like NoteCard)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BlueskyFeedPost({ post }: { post: BlueskyPost }) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const webUrl = postWebUrl(post.uri, post.author.handle);
|
||||
const internalUrl = dittoUrl(webUrl);
|
||||
const profileUrl = dittoUrl(`https://bsky.app/profile/${post.author.handle}`);
|
||||
const images = post.embed?.$type === 'app.bsky.embed.images#view' ? (post.embed.images ?? []) : [];
|
||||
const externalEmbed = post.embed?.$type === 'app.bsky.embed.external#view' ? post.embed.external : undefined;
|
||||
|
||||
// NIP-73 external content for the reaction button
|
||||
const externalContent = useMemo(() => parseExternalUri(webUrl), [webUrl]);
|
||||
|
||||
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}${internalUrl}`;
|
||||
const result = await shareOrCopy(fullUrl);
|
||||
if (result === 'copied') {
|
||||
toast({ title: 'Link copied' });
|
||||
}
|
||||
}, [internalUrl, toast]);
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
navigate(internalUrl);
|
||||
}, [navigate, internalUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
onClick={handleCardClick}
|
||||
className="px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar */}
|
||||
<Link to={profileUrl} onClick={(e) => e.stopPropagation()} className="shrink-0">
|
||||
{post.author.avatar ? (
|
||||
<img
|
||||
src={post.author.avatar}
|
||||
alt=""
|
||||
className="size-11 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-11 rounded-full bg-gradient-to-br from-sky-400 to-blue-500 flex items-center justify-center text-white text-sm font-bold">
|
||||
{(post.author.displayName ?? post.author.handle).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Author info */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Link to={profileUrl} onClick={(e) => e.stopPropagation()} className="font-semibold text-[15px] truncate leading-tight hover:underline">
|
||||
{post.author.displayName ?? post.author.handle}
|
||||
</Link>
|
||||
<Link to={profileUrl} onClick={(e) => e.stopPropagation()} className="text-muted-foreground text-sm truncate leading-tight hover:underline">
|
||||
@{post.author.handle}
|
||||
</Link>
|
||||
<span className="text-muted-foreground text-sm shrink-0">·</span>
|
||||
<span className="text-muted-foreground text-sm shrink-0">
|
||||
{timeAgo(post.record.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Post text */}
|
||||
<p className="mt-1 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
|
||||
{post.record.text}
|
||||
</p>
|
||||
|
||||
{/* Image embeds */}
|
||||
{images.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 rounded-xl overflow-hidden border border-border',
|
||||
images.length === 1 && 'grid grid-cols-1',
|
||||
images.length === 2 && 'grid grid-cols-2 gap-0.5',
|
||||
images.length === 3 && 'grid grid-cols-2 gap-0.5',
|
||||
images.length >= 4 && 'grid grid-cols-2 gap-0.5',
|
||||
)}
|
||||
>
|
||||
{images.slice(0, 4).map((img, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-secondary',
|
||||
images.length === 1 ? 'aspect-video' : 'aspect-square',
|
||||
images.length === 3 && i === 0 && 'row-span-2 aspect-auto',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={img.thumb}
|
||||
alt={img.alt || ''}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External link embed */}
|
||||
{externalEmbed && (
|
||||
<div className="mt-3 rounded-xl border border-border overflow-hidden bg-secondary/30">
|
||||
{externalEmbed.thumb && (
|
||||
<div className="aspect-[2/1] overflow-hidden bg-secondary">
|
||||
<img
|
||||
src={externalEmbed.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-2.5 space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{(() => { try { return new URL(externalEmbed.uri).hostname; } catch { return externalEmbed.uri; } })()}
|
||||
</p>
|
||||
{externalEmbed.title && (
|
||||
<p className="text-sm font-semibold leading-tight line-clamp-2">{externalEmbed.title}</p>
|
||||
)}
|
||||
{externalEmbed.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{externalEmbed.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-5 mt-3 -ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleComment}
|
||||
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-sky-500 hover:bg-sky-500/10 transition-colors"
|
||||
title="Comment"
|
||||
>
|
||||
<MessageCircle className="size-[18px]" />
|
||||
{post.replyCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.replyCount)}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRepost}
|
||||
className="inline-flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
|
||||
title="Share to feed"
|
||||
>
|
||||
<Repeat2 className="size-[18px]" />
|
||||
{post.repostCount > 0 && <span className="text-sm tabular-nums">{formatCount(post.repostCount)}</span>}
|
||||
</button>
|
||||
<ExternalReactionButton content={externalContent} iconSize="size-[18px]" count={post.likeCount} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
className="inline-flex items-center p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Share link"
|
||||
>
|
||||
<Share2 className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Comment compose modal */}
|
||||
{commentOpen && (
|
||||
<ReplyComposeModal
|
||||
open={commentOpen}
|
||||
onOpenChange={setCommentOpen}
|
||||
event={new URL(webUrl)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share compose modal */}
|
||||
{shareOpen && (
|
||||
<ReplyComposeModal
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
initialContent={webUrl}
|
||||
title="Share to feed"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BlueskySearchBar() {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const { data: results, isFetching } = useBlueskyActorSearch(debouncedQuery);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setQuery(value);
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setDebouncedQuery(value.trim());
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length >= 1 && results && results.length > 0) {
|
||||
setDropdownOpen(true);
|
||||
} else if (debouncedQuery.length >= 1 && results && results.length === 0 && !isFetching) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
}, [debouncedQuery, results, isFetching]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((result: BlueskyActorResult) => {
|
||||
setQuery('');
|
||||
setDebouncedQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.blur();
|
||||
navigate(dittoUrl(result.url));
|
||||
}, [navigate]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setQuery('');
|
||||
setDebouncedQuery('');
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
if (e.key === 'Enter' && results && results.length > 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(results[0]);
|
||||
}
|
||||
}, [results, handleSelect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative px-4 pb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search Bluesky users..."
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (debouncedQuery.length >= 1) setDropdownOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pl-9 pr-9 h-9 text-base md:text-sm"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Search results dropdown */}
|
||||
{dropdownOpen && debouncedQuery.length >= 1 && (
|
||||
<div className="absolute left-4 right-4 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg overflow-hidden">
|
||||
{isFetching && (!results || results.length === 0) ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : results && results.length > 0 ? (
|
||||
<div className="divide-y divide-border max-h-80 overflow-y-auto">
|
||||
{results.map((result) => (
|
||||
<button
|
||||
key={result.did}
|
||||
type="button"
|
||||
className="flex items-center gap-3 px-3 py-2.5 w-full text-left hover:bg-secondary/60 transition-colors"
|
||||
onClick={() => handleSelect(result)}
|
||||
>
|
||||
{result.avatar ? (
|
||||
<img
|
||||
src={result.avatar}
|
||||
alt=""
|
||||
className="size-10 rounded-full object-cover bg-secondary shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-10 rounded-full bg-gradient-to-br from-sky-500/20 to-blue-500/20 flex items-center justify-center shrink-0">
|
||||
<BlueskyIcon className="size-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{result.displayName || result.handle}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
@{result.handle}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No users found for “{debouncedQuery}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator when results exist but we're refetching */}
|
||||
{isFetching && results && results.length > 0 && (
|
||||
<div className="flex justify-center py-2 border-t border-border">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading skeleton (feed-style)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BlueskyLoadingSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<div className="flex gap-6 pt-1">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<Skeleton className="h-4 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BlueskyPage() {
|
||||
const { config } = useAppContext();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useBlueskyTrending();
|
||||
const { ref: loadMoreRef, inView } = useInView();
|
||||
|
||||
useSeoMeta({
|
||||
title: `Bluesky | ${config.appName}`,
|
||||
description: 'Explore popular posts from Bluesky \u2014 trending discussions, images, and links.',
|
||||
});
|
||||
|
||||
// Flatten pages, deduplicate by URI
|
||||
const allPosts = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return data.pages.flatMap((page) => page.posts).filter((post) => {
|
||||
if (seen.has(post.uri)) return false;
|
||||
seen.add(post.uri);
|
||||
return true;
|
||||
});
|
||||
}, [data?.pages]);
|
||||
|
||||
// Trigger next page fetch when sentinel is in view
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-2">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<div className="size-8 rounded-lg bg-gradient-to-br from-sky-500/20 to-blue-500/10 flex items-center justify-center">
|
||||
<BlueskyIcon className="size-4 text-sky-500 dark:text-sky-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold leading-tight">Bluesky</h1>
|
||||
<p className="text-xs text-muted-foreground">Popular posts from the ATmosphere</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded-full hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="About"
|
||||
>
|
||||
<Info className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-72 text-xs text-muted-foreground">
|
||||
Content provided by{' '}
|
||||
<a
|
||||
href="https://bsky.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
Bluesky
|
||||
</a>
|
||||
, a decentralized social network built on the AT Protocol. All posts are public and belong to their respective authors.
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<a
|
||||
href="https://bsky.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-full hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Visit Bluesky"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<BlueskySearchBar />
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<BlueskyLoadingSkeleton />
|
||||
) : isError ? (
|
||||
<div className="px-4 pt-8 pb-16 text-center">
|
||||
<FlameKindling className="size-10 mx-auto mb-3 text-muted-foreground/30" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Couldn't load Bluesky posts. Try again later.
|
||||
</p>
|
||||
</div>
|
||||
) : allPosts.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<BlueskyIcon className="size-10 mx-auto mb-3 text-muted-foreground/20" />
|
||||
<p className="text-muted-foreground text-sm">No posts found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{allPosts.map((post) => (
|
||||
<BlueskyFeedPost key={post.uri} post={post} />
|
||||
))}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{hasNextPage && (
|
||||
<div ref={loadMoreRef} className="py-6">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, Bookmark } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBookmarks } from '@/hooks/useBookmarks';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function BookmarksPage() {
|
||||
const { config } = useAppContext();
|
||||
@@ -22,16 +21,7 @@ export function BookmarksPage() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Sticky header */}
|
||||
<div className={cn('sidebar:sticky sidebar:top-0', 'flex items-center gap-4 px-4 pt-4 pb-5 bg-background/80 backdrop-blur-md z-10')}>
|
||||
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bookmark className="size-5" />
|
||||
<h1 className="text-xl font-bold">Bookmarks</h1>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Bookmarks" icon={<Bookmark className="size-5" />} />
|
||||
|
||||
{/* Content */}
|
||||
{!user ? (
|
||||
|
||||
+17
-20
@@ -1,16 +1,18 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, BookMarked, Loader2, Search, X } from 'lucide-react';
|
||||
import { BookMarked, Loader2, Search, X } from 'lucide-react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { BookFeedItem, BookFeedItemSkeleton } from '@/components/BookFeedItem';
|
||||
import { useBookFeed } from '@/hooks/useBookFeed';
|
||||
import { useBookSearch, type BookSearchResult } from '@/hooks/useBookSearch';
|
||||
@@ -18,6 +20,7 @@ import { usePrefetchBookSummaries } from '@/hooks/useBookSummary';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import type { ExtraKindDef } from '@/lib/extraKinds';
|
||||
|
||||
type FeedTab = 'follows' | 'global';
|
||||
@@ -45,6 +48,8 @@ export function BooksPage() {
|
||||
description: 'Book reviews, ratings, and discussions from the Nostr community',
|
||||
});
|
||||
|
||||
useLayoutOptions({ hasSubHeader: !!user });
|
||||
|
||||
const feedQuery = useBookFeed(activeTab);
|
||||
|
||||
const {
|
||||
@@ -93,29 +98,21 @@ export function BooksPage() {
|
||||
|
||||
return (
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<BookMarked className="size-5" />
|
||||
<h1 className="text-xl font-bold">Books</h1>
|
||||
</div>
|
||||
{/* Follows / Global tabs */}
|
||||
{user && (
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => setActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => setActiveTab('global')} />
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
<PageHeader title="Books" icon={<BookMarked className="size-5" />}>
|
||||
<KindInfoButton kindDef={booksDef} icon={<BookMarked className="size-10" />} />
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Book search bar */}
|
||||
<BookSearchBar />
|
||||
|
||||
{/* Follows / Global tabs */}
|
||||
{user && (
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => setActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => setActiveTab('global')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{showSkeleton ? (
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, ShieldAlert } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
|
||||
@@ -17,17 +17,7 @@ export function CSAEPolicyPage() {
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<div className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-5" />
|
||||
<h1 className="text-xl font-bold">Child Safety Policy</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Child Safety Policy" icon={<ShieldAlert className="size-5" />} backTo="/" />
|
||||
|
||||
<article className="px-4 pb-8 space-y-6 text-sm text-foreground/90 leading-relaxed">
|
||||
<p className="text-xs text-muted-foreground">Last updated: March 19, 2026</p>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, RotateCcw } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { MuteSettingsInternals, SensitiveContentSection, ThemePreferencesSection } from '@/components/ContentSettings';
|
||||
import { MuteListRecoveryDialog } from '@/components/MuteListRecoveryDialog';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -23,19 +23,18 @@ export function ContentPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold">Content</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Control what you see. Mute users, hashtags, or words, and choose how content warnings are handled. Mutes are encrypted and private.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Lead image — Muted Content */}
|
||||
<div className="flex items-center gap-4 px-7 py-5">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ContentSettings } from '@/components/ContentSettings';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
@@ -16,19 +15,18 @@ export function ContentSettingsPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold flex items-center gap-1.5">Home Feed <HelpTip faqId="fyp" /></h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
Nostr supports many content types beyond text posts. Customize which appear in your home feed. <HelpTip faqId="what-is-nostr" iconSize="size-3.5" />
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Nostr supports many content types beyond text posts. Customize which appear in your home feed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<ContentSettings />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -13,7 +12,7 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { isRepostKind } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
@@ -92,26 +91,22 @@ export function DomainFeedPage() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-3 px-4 py-4 border-b border-border bg-background/80 backdrop-blur-md z-10')}>
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ExternalFavicon url={domain ? `https://${domain}` : undefined} size={20} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold truncate leading-tight">{domain}</h1>
|
||||
{pubkeys && pubkeys.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground leading-tight">
|
||||
{pubkeys.length} user{pubkeys.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
<PageHeader
|
||||
onBack={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
titleContent={
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ExternalFavicon url={domain ? `https://${domain}` : undefined} size={20} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold truncate leading-tight">{domain}</h1>
|
||||
{pubkeys && pubkeys.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground leading-tight">
|
||||
{pubkeys.length} user{pubkeys.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{pubkeysError ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, CalendarDays, Loader2 } from 'lucide-react';
|
||||
import { CalendarDays, Loader2 } from 'lucide-react';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -11,6 +10,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -42,7 +43,7 @@ export function EventsFeedPage() {
|
||||
const [activeTab, setActiveTab] = useFeedTab<FeedTab>('events', ['follows', 'global']);
|
||||
|
||||
useSeoMeta({ title: `Events | ${config.appName}` });
|
||||
useLayoutOptions({ showFAB: true, fabKind: 31923 });
|
||||
useLayoutOptions({ showFAB: true, fabKind: 31923, hasSubHeader: !!user });
|
||||
|
||||
// Calendar events feed
|
||||
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
|
||||
@@ -105,26 +106,18 @@ export function EventsFeedPage() {
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<CalendarDays className="size-5" />
|
||||
<h1 className="text-xl font-bold">Events</h1>
|
||||
</div>
|
||||
<KindInfoButton kindDef={eventsDef} icon={sidebarItemIcon('events', 'size-5')} />
|
||||
</div>
|
||||
|
||||
{/* Follows / Global tabs */}
|
||||
{user && (
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => setActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => setActiveTab('global')} />
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
<PageHeader title="Events" icon={<CalendarDays className="size-5" />}>
|
||||
<KindInfoButton kindDef={eventsDef} icon={sidebarItemIcon('events', 'size-5')} />
|
||||
</PageHeader>
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{showSkeleton ? (
|
||||
<div className="divide-y divide-border">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, Globe, Heart, MessageSquare, MoreHorizontal, Repeat2, Star, AlertTriangle, PanelLeft, Trash2 } from 'lucide-react';
|
||||
import { ArrowLeft, Globe, MessageSquare, MoreHorizontal, Repeat2, Star, AlertTriangle, PanelLeft, Trash2 } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -8,15 +8,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ThreadedReplyList } from '@/components/ThreadedReplyList';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { QuickReactMenu } from '@/components/QuickReactMenu';
|
||||
import { ReactionEmoji } from '@/components/CustomEmoji';
|
||||
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
|
||||
import { BookReviewFormDialog } from '@/components/BookReviewForm';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import {
|
||||
@@ -38,45 +36,23 @@ import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { extractWikipediaTitle } from '@/lib/linkEmbed';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useExternalUserReaction,
|
||||
useExternalReactionCount,
|
||||
} from '@/hooks/useExternalReactions';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { BookReview } from '@/lib/bookstr';
|
||||
import NotFound from './NotFound';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: NIP-73 k tag value
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getExternalKTag(content: ExternalContent): string {
|
||||
switch (content.type) {
|
||||
case 'url': return 'web';
|
||||
case 'isbn': return 'isbn';
|
||||
case 'iso3166': return 'iso3166';
|
||||
default: return 'web';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action bar component for external content (react + share)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ExternalActionBar({ content }: { content: ExternalContent }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const identifier = content.value;
|
||||
const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings();
|
||||
@@ -93,134 +69,13 @@ function ExternalActionBar({ content }: { content: ExternalContent }) {
|
||||
toast({ title: 'Removed from sidebar' });
|
||||
}, [identifier, removeFromSidebar, toast]);
|
||||
|
||||
const userReactionData = useExternalUserReaction(content);
|
||||
const reactionCount = useExternalReactionCount(content);
|
||||
|
||||
const hasReacted = !!userReactionData;
|
||||
const userEmoji = userReactionData?.emoji;
|
||||
const userReactionTags = userReactionData?.tags;
|
||||
|
||||
// Reaction popover state
|
||||
const [reactOpen, setReactOpen] = useState(false);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const justClosedRef = useRef(false);
|
||||
const pickerExpandedRef = useRef(false);
|
||||
|
||||
// Share compose modal state
|
||||
const [shareOpen, setShareOpen] = useState(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 (
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-border">
|
||||
{/* Reaction button */}
|
||||
<Popover open={reactOpen} onOpenChange={(open) => {
|
||||
if (open && justClosedRef.current) return;
|
||||
if (!open) pickerExpandedRef.current = false;
|
||||
setReactOpen(open);
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${
|
||||
hasReacted
|
||||
? 'text-pink-500'
|
||||
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10'
|
||||
}`}
|
||||
title="React"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!user) return;
|
||||
if (justClosedRef.current) return;
|
||||
setReactOpen((prev) => !prev);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hasReacted && userEmoji ? (
|
||||
<span className="size-5 flex items-center justify-center text-base leading-none">
|
||||
<ReactionEmoji content={userEmoji} tags={userReactionTags} className="size-5" />
|
||||
</span>
|
||||
) : (
|
||||
<Heart className="size-5" />
|
||||
)}
|
||||
{reactionCount > 0 && (
|
||||
<span className="text-sm tabular-nums">{formatNumber(reactionCount)}</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-0 border-0 bg-transparent shadow-none"
|
||||
side="top"
|
||||
align="start"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<QuickReactMenu
|
||||
eventId={identifier}
|
||||
eventPubkey=""
|
||||
eventKind={17}
|
||||
onExpandChange={(expanded) => { pickerExpandedRef.current = expanded; }}
|
||||
onClose={() => {
|
||||
pickerExpandedRef.current = false;
|
||||
justClosedRef.current = true;
|
||||
setReactOpen(false);
|
||||
setTimeout(() => { justClosedRef.current = false; }, 300);
|
||||
}}
|
||||
onReact={handleReact}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ExternalReactionButton content={content} />
|
||||
|
||||
{/* Share button — opens compose modal pre-filled with the URL */}
|
||||
<button
|
||||
|
||||
+3
-13
@@ -1,9 +1,10 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, HelpCircle, Shield } from 'lucide-react';
|
||||
import { HelpCircle, Shield } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { TeamSoapboxCard } from '@/components/TeamSoapboxCard';
|
||||
import { HelpFAQSection } from '@/components/HelpFAQSection';
|
||||
|
||||
@@ -18,18 +19,7 @@ export function HelpPage() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<div className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpCircle className="size-5" />
|
||||
<h1 className="text-xl font-bold">Help</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Help" icon={<HelpCircle className="size-5" />} />
|
||||
|
||||
{/* Team Soapbox follow pack */}
|
||||
<TeamSoapboxCard className="px-4 pt-2 pb-4" />
|
||||
|
||||
+3
-1
@@ -1,17 +1,19 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Feed } from '@/components/Feed';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
|
||||
const Index = () => {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
useSeoMeta({
|
||||
title: config.appName,
|
||||
description: 'Your content. Your vibe. Your rules.',
|
||||
});
|
||||
|
||||
useLayoutOptions({ showFAB: true, fabKind: 1 });
|
||||
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
|
||||
|
||||
return <Feed />;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Feed } from '@/components/Feed';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { EXTRA_KINDS, type ExtraKindDef } from '@/lib/extraKinds';
|
||||
|
||||
@@ -35,6 +35,7 @@ interface KindFeedPageProps {
|
||||
|
||||
export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo = '/', alwaysShowBack, fabHref, tagFilters, extra, onFabClick, showFAB = true, feedId }: KindFeedPageProps) {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const primaryKind = Array.isArray(kind) ? kind[0] : kind;
|
||||
|
||||
const resolvedDef = useMemo(
|
||||
@@ -50,7 +51,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
|
||||
});
|
||||
|
||||
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
|
||||
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick });
|
||||
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
|
||||
|
||||
const kinds = Array.isArray(kind) ? kind : [kind];
|
||||
|
||||
@@ -63,16 +64,9 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
|
||||
feedId={feedId ?? title.toLowerCase()}
|
||||
emptyMessage={emptyMessage ?? `No ${title.toLowerCase()} yet. Check back soon!`}
|
||||
header={
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<Link to={backTo} className={`p-2 -ml-2 rounded-full hover:bg-secondary transition-colors ${alwaysShowBack ? '' : 'sidebar:hidden'}`}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{icon}
|
||||
<h1 className="text-xl font-bold">{title}</h1>
|
||||
</div>
|
||||
<PageHeader title={title} icon={icon} backTo={backTo} alwaysShowBack={alwaysShowBack}>
|
||||
{resolvedDef && <KindInfoButton kindDef={resolvedDef} icon={icon} open={infoOpen} onOpenChange={setInfoOpen} />}
|
||||
</div>
|
||||
</PageHeader>
|
||||
}
|
||||
/>
|
||||
{extra}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ArrowLeft, Users, UserPlus, Loader2, X, Share2, Check, Copy, Quote, PanelLeft, Trash2,
|
||||
Users, UserPlus, Loader2, X, Rss, Share2, Check, Copy, Quote, PanelLeft, Trash2,
|
||||
} from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { AddMembersDialog } from '@/components/AddMembersDialog';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -41,8 +42,9 @@ import { getRepostKind } from '@/lib/feedUtils';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { UserList } from '@/hooks/useUserLists';
|
||||
import NotFound from './NotFound';
|
||||
@@ -296,6 +298,8 @@ export function ListDetailPage() {
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings();
|
||||
|
||||
useLayoutOptions({ hasSubHeader: true });
|
||||
|
||||
// Decode the naddr to get the d-tag identifier and author
|
||||
const decoded = useMemo(() => {
|
||||
if (!naddr) return null;
|
||||
@@ -447,15 +451,7 @@ export function ListDetailPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main>
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-4 px-4 pt-4 pb-3 bg-background/80 backdrop-blur-md z-10')}>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</div>
|
||||
<PageHeader onBack={() => navigate(-1)} titleContent={<Skeleton className="h-6 w-32" />} />
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
@@ -481,30 +477,27 @@ export function ListDetailPage() {
|
||||
return (
|
||||
<main>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/lists')}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-bold truncate">{list.title}</h1>
|
||||
{decoded && (
|
||||
<Link to={listAuthorProfileUrl} className="flex items-center gap-1.5 mt-0.5 group">
|
||||
<Avatar shape={listAuthorAvatarShape} className="size-4">
|
||||
<AvatarImage src={listAuthorMetadata?.picture} alt={listAuthorName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{listAuthorName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground group-hover:underline truncate">
|
||||
{listAuthorName}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<PageHeader
|
||||
onBack={() => window.history.length > 1 ? navigate(-1) : navigate('/lists')}
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-bold truncate">{list.title}</h1>
|
||||
{decoded && (
|
||||
<Link to={listAuthorProfileUrl} className="flex items-center gap-1.5 mt-0.5 group">
|
||||
<Avatar shape={listAuthorAvatarShape} className="size-4">
|
||||
<AvatarImage src={listAuthorMetadata?.picture} alt={listAuthorName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{listAuthorName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground group-hover:underline truncate">
|
||||
{listAuthorName}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{user && !isOwnList && (
|
||||
<Button
|
||||
@@ -561,7 +554,7 @@ export function ListDetailPage() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Description and image */}
|
||||
{(list.description || list.image) && (
|
||||
@@ -614,13 +607,32 @@ export function ListDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex border-b border-border bg-background/80 backdrop-blur-md z-10')}>
|
||||
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
|
||||
<TabButton label="Members" active={activeTab === 'members'} onClick={() => setActiveTab('members')} />
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<SubHeaderBar>
|
||||
<TabButton
|
||||
label="Feed"
|
||||
active={activeTab === 'feed'}
|
||||
onClick={() => setActiveTab('feed')}
|
||||
indicatorClassName="left-1/4 right-1/4 w-auto h-0.5"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Rss className="size-4" />
|
||||
Feed
|
||||
</span>
|
||||
</TabButton>
|
||||
<TabButton
|
||||
label="Members"
|
||||
active={activeTab === 'members'}
|
||||
onClick={() => setActiveTab('members')}
|
||||
indicatorClassName="left-1/4 right-1/4 w-auto h-0.5"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Users className="size-4" />
|
||||
Members
|
||||
<span className="text-xs text-muted-foreground">({list.pubkeys.length})</span>
|
||||
</span>
|
||||
</TabButton>
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'feed' ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -21,19 +20,18 @@ export function MagicSettingsPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold">Magic</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Harness the mystical energies of your device. Imbue your cursor with elemental fire.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { RelayListManager } from '@/components/RelayListManager';
|
||||
import { BlossomSettings } from '@/components/BlossomSettings';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
@@ -26,19 +26,18 @@ export function NetworkSettingsPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Network</h1>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold flex items-center gap-1.5">Network <HelpTip faqId="what-is-nostr" /></h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Relays are servers that store and distribute content across the Nostr network. Blossom servers handle file uploads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro */}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, Bell, BellOff, AlertTriangle, Heart, Repeat2, Zap, AtSign, MessageSquare, Users, Award } from 'lucide-react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Bell, BellOff, AlertTriangle, Heart, Repeat2, Zap, AtSign, MessageSquare, Users, Award } from 'lucide-react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -227,19 +228,18 @@ export function NotificationSettings() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold">Notifications</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Customize which notifications you receive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Push Notifications */}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
@@ -31,6 +32,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
|
||||
type NotificationTab = 'all' | 'mentions';
|
||||
|
||||
@@ -42,6 +44,8 @@ export function NotificationsPage() {
|
||||
description: 'Your Nostr notifications',
|
||||
});
|
||||
|
||||
useLayoutOptions({ hasSubHeader: true });
|
||||
|
||||
const [activeTab, setActiveTab] = useState<NotificationTab>('all');
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -113,7 +117,7 @@ export function NotificationsPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<SubHeaderBar>
|
||||
{tabs.map(({ key, label }) => (
|
||||
<TabButton
|
||||
key={key}
|
||||
@@ -124,7 +128,7 @@ export function NotificationsPage() {
|
||||
indicatorClassName="sidebar:h-[3px]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Content */}
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Camera } from 'lucide-react';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
@@ -22,7 +21,9 @@ import { useInfiniteHotFeed } from '@/hooks/useTrending';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { sidebarItemIcon } from '@/lib/sidebarItems';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { getExtraKindDef } from '@/lib/extraKinds';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
@@ -43,7 +44,7 @@ export function PhotosFeedPage() {
|
||||
const [activeTab, setActiveTab] = useFeedTab<FeedTab>('photos', ['follows', 'global']);
|
||||
|
||||
useSeoMeta({ title: `Photos | ${config.appName}`, description: 'Photo posts on Nostr' });
|
||||
useLayoutOptions({ showFAB: false });
|
||||
useLayoutOptions({ showFAB: false, hasSubHeader: true });
|
||||
|
||||
// ── Follows feed (chronological) ──
|
||||
const followsQuery = useFeed('follows', { kinds: [PHOTO_KIND] });
|
||||
@@ -90,23 +91,15 @@ export function PhotosFeedPage() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Camera className="size-5" />
|
||||
<h1 className="text-xl font-bold">Photos</h1>
|
||||
</div>
|
||||
<KindInfoButton kindDef={photosDef} icon={sidebarItemIcon('photos', 'size-5')} />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => setActiveTab('follows')} disabled={!user} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => setActiveTab('global')} />
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
|
||||
<PageHeader title="Photos" icon={<Camera className="size-5" />}>
|
||||
<KindInfoButton kindDef={photosDef} icon={sidebarItemIcon('photos', 'size-5')} />
|
||||
</PageHeader>
|
||||
|
||||
{/* Grid */}
|
||||
{showSkeleton ? (
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
@@ -20,15 +18,7 @@ export function PlaceholderPage({ title, icon, description }: PlaceholderPagePro
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
<div className={cn('sidebar:sticky sidebar:top-0', 'flex items-center gap-4 px-4 pt-4 pb-5 bg-background/80 backdrop-blur-md z-10')}>
|
||||
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h1 className="text-xl font-bold">{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title={title} icon={icon} backTo="/" />
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-muted-foreground text-lg">Coming soon</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, Shield } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Shield } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
|
||||
@@ -17,17 +17,7 @@ export function PrivacyPolicyPage() {
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<div className="sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-3">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-5" />
|
||||
<h1 className="text-xl font-bold">Privacy Policy</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Privacy Policy" icon={<Shield className="size-5" />} backTo="/" />
|
||||
|
||||
<article className="px-4 pb-8 space-y-6 text-sm text-foreground/90 leading-relaxed">
|
||||
<p className="text-xs text-muted-foreground">Last updated: March 18, 2026</p>
|
||||
|
||||
@@ -88,8 +88,9 @@ import { FontPicker } from '@/components/FontPicker';
|
||||
import { BackgroundPicker } from '@/components/BackgroundPicker';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -1719,6 +1720,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
rightSidebar: <ProfileRightSidebar fields={fields} mediaEvents={mediaEvents} mediaLoading={mediaPending} onMediaClick={handleSidebarMediaClick} />,
|
||||
showFAB: !(activeTab === 'wall' && !profileFollowsMe),
|
||||
onFabClick: activeTab === 'wall' ? openWallCompose : undefined,
|
||||
hasSubHeader: true,
|
||||
} : {});
|
||||
|
||||
if (!pubkey) {
|
||||
@@ -2142,7 +2144,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex border-b border-border backdrop-blur-md z-10 overflow-x-auto scrollbar-none')}>
|
||||
<SubHeaderBar>
|
||||
{/* Skeleton while kind 16769 is loading */}
|
||||
{!profileTabsQuery.isFetched && (
|
||||
<div className="flex gap-1 px-2 py-2">
|
||||
@@ -2281,7 +2283,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Add/edit single tab modal */}
|
||||
{pubkey && (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical,
|
||||
Loader2, Plus, Trash2, ChevronDown, GripVertical,
|
||||
Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, AlertTriangle, CloudSun,
|
||||
} from 'lucide-react';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -23,6 +23,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { ProfileCard } from '@/components/ProfileCard';
|
||||
import { ProfileRightSidebar } from '@/components/ProfileRightSidebar';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
@@ -741,20 +742,21 @@ export function ProfileSettings() {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-mobile-bar sidebar:top-0 z-10 bg-background/80 backdrop-blur-md px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors shrink-0">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<PageHeader
|
||||
title="Profile"
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold leading-tight">Profile</h1>
|
||||
<p className="text-sm text-muted-foreground truncate">Your Nostr identity is portable — it goes wherever you go.</p>
|
||||
<p className="text-sm text-muted-foreground">Your Nostr identity is portable — it goes wherever you go.</p>
|
||||
</div>
|
||||
<Button type="submit" form="profile-settings-form" size="sm" className="shrink-0 rounded-full font-bold px-5" disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button type="submit" form="profile-settings-form" size="sm" className="shrink-0 rounded-full font-bold px-5" disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form id="profile-settings-form" onSubmit={form.handleSubmit(onSubmit)} className="max-w-xl mx-auto px-4 pb-10 space-y-6">
|
||||
|
||||
+29
-38
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, Globe, Mail, Shield, Zap, Server, Hash } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Globe, Mail, Shield, Zap, Server, Hash } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
@@ -13,7 +13,7 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { isRepostKind } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import NotFound from './NotFound';
|
||||
|
||||
@@ -85,6 +85,7 @@ function useRelayFeed(relayUrl: string | undefined, kinds: number[]) {
|
||||
|
||||
export function RelayPage() {
|
||||
const { config } = useAppContext();
|
||||
const navigate = useNavigate();
|
||||
const { '*': rawParam } = useParams();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
const { muteItems } = useMuteList();
|
||||
@@ -135,43 +136,33 @@ export function RelayPage() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Sticky header */}
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-3 px-4 py-4 border-b border-border bg-background/80 backdrop-blur-md z-10')}>
|
||||
<Link
|
||||
to="/"
|
||||
onClick={(e) => {
|
||||
if (window.history.length > 1) {
|
||||
e.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
}}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{info?.icon ? (
|
||||
<img
|
||||
src={info.icon}
|
||||
alt=""
|
||||
className="size-8 rounded-full object-cover ring-1 ring-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center ring-1 ring-border">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
onBack={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
titleContent={
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{info?.icon ? (
|
||||
<img
|
||||
src={info.icon}
|
||||
alt=""
|
||||
className="size-8 rounded-full object-cover ring-1 ring-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center ring-1 ring-border">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold truncate leading-tight">
|
||||
{info?.name ?? hostname}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground leading-tight truncate">
|
||||
{hostname}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold truncate leading-tight">
|
||||
{info?.name ?? hostname}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground leading-tight truncate">
|
||||
{hostname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* NIP-11 Info Section */}
|
||||
{infoLoading ? (
|
||||
|
||||
+328
-343
@@ -45,10 +45,11 @@ import { ListPackPicker } from '@/components/SavedFeedFiltersEditor';
|
||||
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
// getNostrIdentifierPath removed — identifiers are now handled as autocomplete suggestions
|
||||
import { cn, STICKY_HEADER_CLASS, parseKindFilter } from '@/lib/utils';
|
||||
import { cn, parseKindFilter } from '@/lib/utils';
|
||||
import type { TabFilter } from '@/contexts/AppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
@@ -91,6 +92,8 @@ export function SearchPage() {
|
||||
description: 'Search Nostr',
|
||||
});
|
||||
|
||||
useLayoutOptions({ hasSubHeader: true });
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Derive tab directly from URL — single source of truth
|
||||
@@ -406,337 +409,335 @@ export function SearchPage() {
|
||||
const { data: profiles, isLoading: profilesLoading, followedPubkeys } = useSearchProfiles(activeTab === 'accounts' ? debouncedSearchQuery : '');
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Tabs — sticky at top */}
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'bg-background/80 backdrop-blur-md z-10 border-b border-border')}>
|
||||
<div className="flex">
|
||||
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} className="sidebar:py-5" />
|
||||
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} className="sidebar:py-5" />
|
||||
<main className="flex-1 min-w-0">
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
|
||||
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Search input bar — always rendered right after tabs, like ComposeBox on Feed */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput
|
||||
initialValue={debouncedSearchQuery}
|
||||
onDebouncedChange={setDebouncedSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Add to feed button (posts tab only) */}
|
||||
{activeTab === 'posts' && user && (
|
||||
<div className={cn(!debouncedSearchQuery.trim() && !hasActiveFilters ? 'hidden' : undefined)}>
|
||||
<Popover open={savePopoverOpen} onOpenChange={(o) => {
|
||||
setSavePopoverOpen(o);
|
||||
if (o && !saveFeedLabel) {
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
setSaveFeedLabel(debouncedSearchQuery.trim());
|
||||
} else if (listPickerValue) {
|
||||
const matched =
|
||||
listPickerValue.startsWith('set:')
|
||||
? lists.find((l) => l.id === listPickerValue.slice(4))?.title
|
||||
: followPacks.find((p) => p.id === listPickerValue.slice(5))?.title;
|
||||
if (matched) setSaveFeedLabel(matched);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'shrink-0 h-10 w-10 rounded-lg border flex items-center justify-center transition-colors',
|
||||
alreadySaved || savedJustNow
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
style={{ outline: 'none' }}
|
||||
aria-label="Add to feed"
|
||||
>
|
||||
{savedJustNow ? <Check className="size-4" /> : <BookmarkPlus className="size-4" />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-3 space-y-3">
|
||||
<p className="font-semibold text-sm">Save as tab</p>
|
||||
|
||||
{alreadySaved ? (
|
||||
<p className="text-sm text-muted-foreground">Already saved.</p>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Tab name…"
|
||||
value={saveFeedLabel}
|
||||
onChange={(e) => setSaveFeedLabel(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveFeed(); }}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 text-base md:text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<SaveDestinationRow
|
||||
icon={<BookmarkPlus className="size-4 text-muted-foreground" />}
|
||||
label="Home feed"
|
||||
description="Tab on your home page"
|
||||
onClick={() => handleSaveFeed()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
|
||||
loading={isSavingFeed}
|
||||
/>
|
||||
{!isAuthorSpecific && (
|
||||
<SaveDestinationRow
|
||||
icon={<User className="size-4 text-muted-foreground" />}
|
||||
label="Profile tab"
|
||||
description="Your posts matching this search"
|
||||
onClick={() => handleSaveProfileTab()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
|
||||
loading={isPublishingTabs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter popover (posts tab only) */}
|
||||
{activeTab === 'posts' && (
|
||||
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'shrink-0 h-10 w-10 rounded-lg border bg-secondary/50 hover:bg-secondary flex items-center justify-center transition-colors',
|
||||
filtersOpen
|
||||
? 'border-2 border-primary bg-secondary text-primary'
|
||||
: hasActiveFilters
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border',
|
||||
)}
|
||||
style={{ outline: 'none' }}
|
||||
aria-label="Search filters"
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-sm">Filters</span>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Author scope */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">From</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['anyone', 'Anyone', Globe],
|
||||
['follows', 'Follows', Users],
|
||||
['people', 'People', UserSearch],
|
||||
] as const).map(([scope, label, Icon]) => (
|
||||
<button
|
||||
key={scope}
|
||||
onClick={() => setAuthorScope(scope as AuthorScope)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
authorScope === scope
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{authorScope === 'people' && (
|
||||
<div className="space-y-1.5">
|
||||
{authorPubkeys.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{authorPubkeys.map((pk) => (
|
||||
<AuthorChip key={pk} pubkey={pk} onRemove={() => {
|
||||
const next = authorPubkeys.filter((p) => p !== pk);
|
||||
setSearchParams((prev) => {
|
||||
const n = new URLSearchParams(prev);
|
||||
n.delete('author');
|
||||
next.forEach((p) => n.append('author', p));
|
||||
if (next.length === 0) n.delete('authorScope');
|
||||
return n;
|
||||
}, { replace: true });
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AuthorFilterDropdown onCommit={(pubkey) => {
|
||||
if (!authorPubkeys.includes(pubkey)) {
|
||||
setSearchParams((prev) => {
|
||||
const n = new URLSearchParams(prev);
|
||||
n.append('author', pubkey);
|
||||
n.set('authorScope', 'people');
|
||||
return n;
|
||||
}, { replace: true });
|
||||
}
|
||||
}} />
|
||||
<ListPackPicker
|
||||
lists={lists}
|
||||
followPacks={followPacks}
|
||||
value={listPickerValue}
|
||||
onSelectPubkeys={setAuthorsFromList}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Sort */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sort</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['recent', 'Recent', Clock],
|
||||
['hot', 'Hot', Flame],
|
||||
['trending', 'Trending', TrendingUp],
|
||||
] as const).map(([s, label, Icon]) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSort(s)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
sort === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Media + Protocol */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Media</span>
|
||||
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="images">Images</SelectItem>
|
||||
<SelectItem value="videos">Videos</SelectItem>
|
||||
<SelectItem value="vines">Shorts</SelectItem>
|
||||
<SelectItem value="none">No media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
|
||||
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language + Kind */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Language</span>
|
||||
<Select value={language} onValueChange={(v) => setLanguage(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
<SelectItem value="de">German</SelectItem>
|
||||
<SelectItem value="ja">Japanese</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kindFilter === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 1, 30023"
|
||||
value={customKindText}
|
||||
onChange={(e) => setCustomKindText(e.target.value)}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Include replies toggle */}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Include replies</span>
|
||||
<Switch checked={includeReplies} onCheckedChange={setIncludeReplies} className="scale-90" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filter summary chips (posts tab only) */}
|
||||
{activeTab === 'posts' && activeFilterLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{activeFilterLabels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="text-xs font-normal">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NIP-50 search query debug block (posts tab only) */}
|
||||
{activeTab === 'posts' && debouncedSearchQuery.trim() && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mt-2 px-3 py-2 rounded-md bg-secondary/40 border border-border cursor-default">
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
<span className="text-muted-foreground/60 mr-1">search:</span>
|
||||
{nip50SearchString}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs font-mono break-all">
|
||||
{nip50SearchString}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Posts Tab ─── */}
|
||||
{activeTab === 'posts' && (
|
||||
<>
|
||||
{/* Search input + filter icon */}
|
||||
<div className="px-4 pt-5 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput
|
||||
initialValue={debouncedSearchQuery}
|
||||
onDebouncedChange={setDebouncedSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Add to feed button */}
|
||||
{user && (
|
||||
<div className={cn(!debouncedSearchQuery.trim() && !hasActiveFilters ? 'hidden' : undefined)}>
|
||||
<Popover open={savePopoverOpen} onOpenChange={(o) => {
|
||||
setSavePopoverOpen(o);
|
||||
if (o && !saveFeedLabel) {
|
||||
// Pre-fill with the search query, or a label derived from active filters
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
setSaveFeedLabel(debouncedSearchQuery.trim());
|
||||
} else if (listPickerValue) {
|
||||
const matched =
|
||||
listPickerValue.startsWith('set:')
|
||||
? lists.find((l) => l.id === listPickerValue.slice(4))?.title
|
||||
: followPacks.find((p) => p.id === listPickerValue.slice(5))?.title;
|
||||
if (matched) setSaveFeedLabel(matched);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'shrink-0 h-10 w-10 rounded-lg border flex items-center justify-center transition-colors',
|
||||
alreadySaved || savedJustNow
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
style={{ outline: 'none' }}
|
||||
aria-label="Add to feed"
|
||||
>
|
||||
{savedJustNow ? <Check className="size-4" /> : <BookmarkPlus className="size-4" />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-3 space-y-3">
|
||||
<p className="font-semibold text-sm">Save as tab</p>
|
||||
|
||||
{alreadySaved ? (
|
||||
<p className="text-sm text-muted-foreground">Already saved.</p>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Tab name…"
|
||||
value={saveFeedLabel}
|
||||
onChange={(e) => setSaveFeedLabel(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveFeed(); }}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 text-base md:text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<SaveDestinationRow
|
||||
icon={<BookmarkPlus className="size-4 text-muted-foreground" />}
|
||||
label="Home feed"
|
||||
description="Tab on your home page"
|
||||
onClick={() => handleSaveFeed()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
|
||||
loading={isSavingFeed}
|
||||
/>
|
||||
{!isAuthorSpecific && (
|
||||
<SaveDestinationRow
|
||||
icon={<User className="size-4 text-muted-foreground" />}
|
||||
label="Profile tab"
|
||||
description="Your posts matching this search"
|
||||
onClick={() => handleSaveProfileTab()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
|
||||
loading={isPublishingTabs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter popover */}
|
||||
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'shrink-0 h-10 w-10 rounded-lg border bg-secondary/50 hover:bg-secondary flex items-center justify-center transition-colors',
|
||||
filtersOpen
|
||||
? 'border-2 border-primary bg-secondary text-primary'
|
||||
: hasActiveFilters
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border',
|
||||
)}
|
||||
style={{ outline: 'none' }}
|
||||
aria-label="Search filters"
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-sm">Filters</span>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Author scope */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">From</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['anyone', 'Anyone', Globe],
|
||||
['follows', 'Follows', Users],
|
||||
['people', 'People', UserSearch],
|
||||
] as const).map(([scope, label, Icon]) => (
|
||||
<button
|
||||
key={scope}
|
||||
onClick={() => setAuthorScope(scope as AuthorScope)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
authorScope === scope
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{authorScope === 'people' && (
|
||||
<div className="space-y-1.5">
|
||||
{authorPubkeys.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{authorPubkeys.map((pk) => (
|
||||
<AuthorChip key={pk} pubkey={pk} onRemove={() => {
|
||||
const next = authorPubkeys.filter((p) => p !== pk);
|
||||
setSearchParams((prev) => {
|
||||
const n = new URLSearchParams(prev);
|
||||
n.delete('author');
|
||||
next.forEach((p) => n.append('author', p));
|
||||
if (next.length === 0) n.delete('authorScope');
|
||||
return n;
|
||||
}, { replace: true });
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AuthorFilterDropdown onCommit={(pubkey) => {
|
||||
if (!authorPubkeys.includes(pubkey)) {
|
||||
setSearchParams((prev) => {
|
||||
const n = new URLSearchParams(prev);
|
||||
n.append('author', pubkey);
|
||||
n.set('authorScope', 'people');
|
||||
return n;
|
||||
}, { replace: true });
|
||||
}
|
||||
}} />
|
||||
<ListPackPicker
|
||||
lists={lists}
|
||||
followPacks={followPacks}
|
||||
value={listPickerValue}
|
||||
onSelectPubkeys={setAuthorsFromList}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Sort */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sort</span>
|
||||
<div className="flex rounded-lg border border-border overflow-hidden">
|
||||
{([
|
||||
['recent', 'Recent', Clock],
|
||||
['hot', 'Hot', Flame],
|
||||
['trending', 'Trending', TrendingUp],
|
||||
] as const).map(([s, label, Icon]) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSort(s)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 flex items-center justify-center gap-1 text-xs font-medium transition-colors',
|
||||
sort === s
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/40 text-muted-foreground hover:bg-secondary hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Media + Protocol */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Media</span>
|
||||
<Select value={mediaType} onValueChange={(v) => setMediaType(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="images">Images</SelectItem>
|
||||
<SelectItem value="videos">Videos</SelectItem>
|
||||
<SelectItem value="vines">Shorts</SelectItem>
|
||||
<SelectItem value="none">No media</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Protocol <HelpTip faqId="vs-mastodon-bluesky" iconSize="size-3" /></span>
|
||||
<Select value={platform} onValueChange={(v) => setPlatform(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nostr">Nostr</SelectItem>
|
||||
<SelectItem value="activitypub">Mastodon</SelectItem>
|
||||
<SelectItem value="atproto">Bluesky</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language + Kind */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Language</span>
|
||||
<Select value={language} onValueChange={(v) => setLanguage(v)}>
|
||||
<SelectTrigger className="w-full bg-secondary/50 h-8 text-base md:text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">Global</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
<SelectItem value="de">German</SelectItem>
|
||||
<SelectItem value="ja">Japanese</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">Kind</span>
|
||||
<KindPicker value={kindFilter} options={kindOptions} onChange={(v) => setKindFilter(v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kindFilter === 'custom' && (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 1, 30023"
|
||||
value={customKindText}
|
||||
onChange={(e) => setCustomKindText(e.target.value)}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 rounded-lg text-base md:text-xs h-8"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Include replies toggle */}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Include replies</span>
|
||||
<Switch checked={includeReplies} onCheckedChange={setIncludeReplies} className="scale-90" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Active filter summary chips */}
|
||||
{activeFilterLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{activeFilterLabels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="text-xs font-normal">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NIP-50 search query debug block */}
|
||||
{debouncedSearchQuery.trim() && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mt-2 px-3 py-2 rounded-md bg-secondary/40 border border-border cursor-default">
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
<span className="text-muted-foreground/60 mr-1">search:</span>
|
||||
{nip50SearchString}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs font-mono break-all">
|
||||
{nip50SearchString}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Post results — stream */}
|
||||
{postsLoading && posts.length === 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
@@ -772,14 +773,6 @@ export function SearchPage() {
|
||||
{/* ─── Accounts Tab ─── */}
|
||||
{activeTab === 'accounts' && (
|
||||
<>
|
||||
{/* Search input for accounts */}
|
||||
<div className="px-4 pt-5 pb-2">
|
||||
<SearchInput
|
||||
initialValue={debouncedSearchQuery}
|
||||
onDebouncedChange={setDebouncedSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{debouncedSearchQuery.trim() ? (
|
||||
profilesLoading ? (
|
||||
@@ -1019,14 +1012,6 @@ function AccountSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Author filter input that uses the profile search dropdown.
|
||||
* Commits to the parent only when:
|
||||
* - A profile is selected from the dropdown (fires onCommit immediately with npub + display name)
|
||||
* - The user manually types a full npub1… / hex pubkey / NIP-05 and presses Enter or blurs
|
||||
*/
|
||||
/** Small removable chip showing a single selected author. */
|
||||
|
||||
/**
|
||||
* Owns the raw keystroke state for the search box so that typing only
|
||||
* re-renders this small component, not the entire SearchPage.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ArrowLeft, ChevronRight, Settings } from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Settings } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
@@ -114,20 +115,9 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="relative isolate min-h-screen pb-16 sidebar:pb-0"
|
||||
style={{ background: 'radial-gradient(ellipse 100% 300px at 50% 0%, hsl(var(--primary) / 0.06), transparent), radial-gradient(ellipse 100% 300px at 50% 100%, hsl(var(--primary) / 0.06), transparent)' }}
|
||||
>
|
||||
<main className="relative min-h-screen pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-5" />
|
||||
<h1 className="text-xl font-bold">Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Settings" icon={<Settings className="size-5" />} backTo="/" />
|
||||
|
||||
{/* Codex heading + exposition */}
|
||||
<div className="px-7 pb-4 pt-4 text-center space-y-2.5">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Radio, Users, Clock } from 'lucide-react';
|
||||
import { Radio, Users, Clock } from 'lucide-react';
|
||||
import { sidebarItemIcon } from '@/lib/sidebarItems';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
@@ -13,6 +13,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useStreamKind } from '@/hooks/useStreamKind';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
@@ -70,17 +71,9 @@ export function StreamsFeedPage() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Radio className="size-5" />
|
||||
<h1 className="text-xl font-bold">Streams</h1>
|
||||
</div>
|
||||
<PageHeader title="Streams" icon={<Radio className="size-5" />}>
|
||||
<KindInfoButton kindDef={streamsDef} icon={sidebarItemIcon('streams', 'size-5')} />
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Feed */}
|
||||
{isLoading && events.length === 0 ? (
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Sparkles, ArrowLeft, Pencil } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Loader2, Sparkles, Pencil } from 'lucide-react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ThemeSelector } from '@/components/ThemeSelector';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { useThemeFeed } from '@/hooks/useThemeFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -46,7 +47,8 @@ export function ThemesPage() {
|
||||
useLayoutOptions({
|
||||
showFAB: activeTab === 'my-themes',
|
||||
onFabClick: handleFabClick,
|
||||
fabIcon: <Pencil strokeWidth={3} />,
|
||||
fabIcon: <Pencil strokeWidth={3} size={16} />,
|
||||
hasSubHeader: true,
|
||||
});
|
||||
|
||||
// Feed queries for follows/global tabs
|
||||
@@ -102,23 +104,14 @@ export function ThemesPage() {
|
||||
|
||||
return (
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Sparkles className="size-5" />
|
||||
<h1 className="text-xl font-bold">Themes</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<SubHeaderBar>
|
||||
<TabButton label="My Themes" active={activeTab === 'my-themes'} onClick={() => setActiveTab('my-themes')} />
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => setActiveTab('follows')} disabled={!user} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => setActiveTab('global')} />
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
|
||||
<PageHeader title="Themes" icon={<Sparkles className="size-5" />} />
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'my-themes' ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { Link } from "react-router-dom";
|
||||
import { NoteCard } from "@/components/NoteCard";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useMuteList } from "@/hooks/useMuteList";
|
||||
@@ -64,13 +65,7 @@ export function TrendsPage() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Page header */}
|
||||
<div className="px-4 py-3.5 sidebar:py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="size-5" />
|
||||
<h1 className="font-bold text-xl">Trends</h1>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Trends" icon={<TrendingUp className="size-5" />} />
|
||||
|
||||
{/* Trending Hashtags */}
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
|
||||
+26
-15
@@ -4,11 +4,11 @@
|
||||
* Settings sub-page for managing NIP-51 Follow Sets (kind 30000).
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, Users, Pencil,
|
||||
Info, Plus, Trash2, Scroll, Users, Pencil,
|
||||
Check, X,
|
||||
} from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserLists } from '@/hooks/useUserLists';
|
||||
@@ -209,19 +213,26 @@ export function UserListsPage() {
|
||||
return (
|
||||
<main>
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Lists</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Organize people into lists. Lists are stored on Nostr so they follow you across clients.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Lists" icon={<Scroll className="size-5" />}>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8 rounded-full text-muted-foreground hover:text-foreground">
|
||||
<Info className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-xs p-6">
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="text-primary [&>svg]:size-10">
|
||||
<Scroll className="size-10" />
|
||||
</div>
|
||||
<DialogTitle className="text-lg">Lists</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed">
|
||||
Organize people into lists. Lists are stored on Nostr so they follow you across clients.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageHeader>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro block */}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Film, Radio, Play, Eye } from 'lucide-react';
|
||||
import { Film, Radio, Play, Eye } from 'lucide-react';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
@@ -41,10 +41,12 @@ import { getAvatarShape } from '@/lib/avatarShape';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useVideoThumbnail } from '@/components/VideoPlayer';
|
||||
import { sidebarItemIcon } from '@/lib/sidebarItems';
|
||||
import { getExtraKindDef } from '@/lib/extraKinds';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getEffectiveStreamStatus } from '@/lib/streamStatus';
|
||||
@@ -643,19 +645,10 @@ function ShortsPlayer({
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose, activeIndex, events.length]);
|
||||
|
||||
// Same structure as VinesFeedPage: tab bar + snap container, filling the feed column
|
||||
// Same structure as VinesFeedPage: PageHeader + snap container, filling the feed column
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* Tab bar — same chrome as VinesTabBar, back button replaces tabs */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-1.5 px-4 py-3.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Videos
|
||||
</button>
|
||||
</div>
|
||||
<PageHeader title="Videos" icon={<Film className="size-5" />} onBack={onClose} alwaysShowBack />
|
||||
|
||||
{/* Snap-scroll VineCard column — identical sizing to VinesFeedPage */}
|
||||
<div
|
||||
@@ -719,7 +712,10 @@ export function VideosFeedPage() {
|
||||
const [feedTab, setFeedTab] = useFeedTab<FeedTab>('videos', ['follows', 'global']);
|
||||
|
||||
useSeoMeta({ title: `Videos | ${config.appName}`, description: 'Videos and live streams on Nostr' });
|
||||
useLayoutOptions({ showFAB: false, noOverscroll: true });
|
||||
|
||||
const [shortsPlayerIndex, setShortsPlayerIndex] = useState<number | null>(null);
|
||||
const shortsOpen = shortsPlayerIndex !== null;
|
||||
useLayoutOptions({ showFAB: false, noOverscroll: true, hasSubHeader: !shortsOpen });
|
||||
useEffect(() => { setShowAllVideos(false); }, [feedTab]);
|
||||
|
||||
// ── Follows: chronological, small page ──
|
||||
@@ -753,7 +749,6 @@ export function VideosFeedPage() {
|
||||
const normalVideos = useMemo(() => videoEvents.filter((e) => e.kind === 21), [videoEvents]);
|
||||
const shorts = useMemo(() => videoEvents.filter((e) => e.kind === 22), [videoEvents]);
|
||||
|
||||
const [shortsPlayerIndex, setShortsPlayerIndex] = useState<number | null>(null);
|
||||
const [showAllVideos, setShowAllVideos] = useState(false);
|
||||
const { data: streams } = useClassifiedStreams(feedTab);
|
||||
const hasStreams = streams.live.length + streams.planned.length + streams.past.length > 0;
|
||||
@@ -776,23 +771,15 @@ export function VideosFeedPage() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Film className="size-5" />
|
||||
<h1 className="text-xl font-bold">Videos</h1>
|
||||
</div>
|
||||
<KindInfoButton kindDef={videosDef} icon={sidebarItemIcon('videos', 'size-5')} />
|
||||
</div>
|
||||
|
||||
{/* Follows / Global tabs */}
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Follows" active={feedTab === 'follows'} onClick={() => setFeedTab('follows')} disabled={!user} />
|
||||
<TabButton label="Global" active={feedTab === 'global'} onClick={() => setFeedTab('global')} />
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
|
||||
<PageHeader title="Videos" icon={<Film className="size-5" />}>
|
||||
<KindInfoButton kindDef={videosDef} icon={sidebarItemIcon('videos', 'size-5')} />
|
||||
</PageHeader>
|
||||
|
||||
{/* Live streams strip — follows tab filters by followed authors */}
|
||||
<LiveStreamsStrip tab={feedTab} />
|
||||
|
||||
@@ -50,6 +50,7 @@ import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -441,10 +442,10 @@ export function VineCard({ event, isActive, isNearActive, onCommentClick }: Vine
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mute toggle (top-right) — only shown once video is ready ──── */}
|
||||
{/* ── Mute toggle (bottom-right) — only shown once video is ready ──── */}
|
||||
{isVideoReady && (
|
||||
<button
|
||||
className="absolute top-4 right-4 z-10 size-9 rounded-full bg-black/40 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/60 transition-colors"
|
||||
className="absolute bottom-4 right-4 z-10 size-9 rounded-full bg-black/40 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/60 transition-colors"
|
||||
onClick={toggleMute}
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
@@ -633,6 +634,8 @@ export function VinesFeedPage() {
|
||||
showFAB: false,
|
||||
scrollContainer,
|
||||
noOverscroll: true,
|
||||
hasSubHeader: true,
|
||||
noArcs: true,
|
||||
});
|
||||
|
||||
// Track scroll direction to expand vines when bottom nav hides
|
||||
@@ -785,11 +788,11 @@ interface VinesTabBarProps {
|
||||
|
||||
function VinesTabBar({ tab, onTabChange, hasUser }: VinesTabBarProps) {
|
||||
return (
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10 shrink-0">
|
||||
<SubHeaderBar className="shrink-0" noArc>
|
||||
{hasUser && (
|
||||
<TabButton label="Follows" active={tab === 'follows'} onClick={() => onTabChange('follows')} className="sidebar:py-5 sidebar:font-semibold" indicatorClassName="sidebar:h-[3px]" />
|
||||
)}
|
||||
<TabButton label="Global" active={tab === 'global'} onClick={() => onTabChange('global')} className="sidebar:py-5 sidebar:font-semibold" indicatorClassName="sidebar:h-[3px]" />
|
||||
</div>
|
||||
</SubHeaderBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { WalletSettings } from '@/components/WalletSettings';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -22,19 +22,18 @@ export function WalletSettingsPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold flex items-center gap-1.5">Wallet <HelpTip faqId="connect-wallet" /></h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Manage wallet connections and payments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<WalletSettings />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Earth, Search } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { COUNTRIES } from '@/lib/countries';
|
||||
@@ -31,12 +32,7 @@ export function WorldPage() {
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3.5 sidebar:py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Earth className="size-5" />
|
||||
<h1 className="font-bold text-xl">World</h1>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="World" icon={<Earth className="size-5" />} backTo="/" />
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
Reference in New Issue
Block a user