Add native sharing handler

This commit is contained in:
Jon Staab 2026-08-11 14:54:05 -07:00 committed by Jon Staab
parent a572918e6c
commit 9cd717d5b3
33 changed files with 976 additions and 72 deletions

View file

@ -27,6 +27,17 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.flotilla.social" /> <data android:scheme="https" android:host="app.flotilla.social" />
</intent-filter> </intent-filter>
<!-- Puts Flotilla in the system share sheet. These intents carry their payload in
extras rather than a data uri, so Capacitor never emits appUrlOpen for them;
ShareIntentPlugin picks them up instead. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity> </activity>
<provider <provider

View file

@ -8,11 +8,13 @@ import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import social.flotilla.notifications.AndroidPushFallbackPlugin; import social.flotilla.notifications.AndroidPushFallbackPlugin;
import social.flotilla.share.ShareIntentPlugin;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
@Override @Override
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
registerPlugin(AndroidPushFallbackPlugin.class); registerPlugin(AndroidPushFallbackPlugin.class);
registerPlugin(ShareIntentPlugin.class);
createPushNotificationChannel(); createPushNotificationChannel();
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
} }

View file

@ -0,0 +1,53 @@
package social.flotilla.share
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.IntentCompat
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "ShareIntent")
class ShareIntentPlugin : Plugin() {
// Capacitor runs the launch intent through here as well as intents delivered while the app is
// already running, so this is the only hook we need. Retaining the event covers a cold start,
// where the web view isn't listening yet.
override fun handleOnNewIntent(intent: Intent) {
if (intent.action == Intent.ACTION_SEND) {
val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
// Android hands the same intent back when the activity is recreated, so drop the payload
// once we've taken it to avoid re-opening the share dialog.
if (uri != null) {
intent.removeExtra(Intent.EXTRA_STREAM)
// Copying a shared video can take a while, and this runs on the main thread
bridge.execute { notifyListeners("shareReceived", copyToCache(uri), true) }
} else if (text != null) {
intent.removeExtra(Intent.EXTRA_TEXT)
notifyListeners("shareReceived", JSObject().put("text", text), true)
}
}
}
// A content uri is readable by us but meaningless to the web view, so hand over a copy that
// capacitor's file server can serve.
private fun copyToCache(uri: Uri): JSObject {
val resolver = context.contentResolver
val type = resolver.getType(uri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type)
val file = File.createTempFile("shared", extension?.let { ".$it" }, context.cacheDir)
resolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
return JSObject()
.put("path", file.absolutePath)
.put("name", file.name)
.put("type", type)
}
}

View file

@ -7,6 +7,8 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
052C05E5302BD03100DD0620 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052C05E2302BD03100DD0620 /* ShareViewController.swift */; };
052C05D0302BD03100DD0620 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 052C05C6302BD03100DD0620 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; }; 3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
@ -18,8 +20,36 @@
AC8D5382B9575A9124613C5D /* Pods_Flotilla_Chat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA42C7E3CF3FFF7A17A3A729 /* Pods_Flotilla_Chat.framework */; }; AC8D5382B9575A9124613C5D /* Pods_Flotilla_Chat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DA42C7E3CF3FFF7A17A3A729 /* Pods_Flotilla_Chat.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
052C05CE302BD03100DD0620 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 052C05C5302BD03100DD0620;
remoteInfo = ShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
052C05D1302BD03100DD0620 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
052C05D0302BD03100DD0620 /* ShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
052C05E2302BD03100DD0620 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
052C05E3302BD03100DD0620 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
052C05E4302BD03100DD0620 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; }; 051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = "<group>"; };
052C05C6302BD03100DD0620 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; }; 1F53EE54954731A2328CBC4B /* Pods-Flotilla Chat.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Flotilla Chat.release.xcconfig"; path = "Pods/Target Support Files/Pods-Flotilla Chat/Pods-Flotilla Chat.release.xcconfig"; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
@ -38,6 +68,13 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
052C05C3302BD03100DD0620 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3011FED79650016851F /* Frameworks */ = { 504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -49,6 +86,16 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
052C05E1302BD03100DD0620 /* ShareExtension */ = {
isa = PBXGroup;
children = (
052C05E2302BD03100DD0620 /* ShareViewController.swift */,
052C05E3302BD03100DD0620 /* Info.plist */,
052C05E4302BD03100DD0620 /* ShareExtension.entitlements */,
);
path = ShareExtension;
sourceTree = "<group>";
};
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -63,6 +110,7 @@
3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */, 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */,
051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */, 051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */,
504EC3061FED79650016851F /* App */, 504EC3061FED79650016851F /* App */,
052C05E1302BD03100DD0620 /* ShareExtension */,
504EC3051FED79650016851F /* Products */, 504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */, 7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
@ -73,6 +121,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
504EC3041FED79650016851F /* Flotilla Chat.app */, 504EC3041FED79650016851F /* Flotilla Chat.app */,
052C05C6302BD03100DD0620 /* ShareExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -106,6 +155,25 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
052C05C5302BD03100DD0620 /* ShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 052C05D5302BD03100DD0620 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
buildPhases = (
052C05C2302BD03100DD0620 /* Sources */,
052C05C3302BD03100DD0620 /* Frameworks */,
052C05C4302BD03100DD0620 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = ShareExtension;
packageProductDependencies = (
);
productName = ShareExtension;
productReference = 052C05C6302BD03100DD0620 /* ShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
504EC3031FED79650016851F /* Flotilla Chat */ = { 504EC3031FED79650016851F /* Flotilla Chat */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Flotilla Chat" */; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Flotilla Chat" */;
@ -115,10 +183,12 @@
504EC3011FED79650016851F /* Frameworks */, 504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */, 504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
052C05D1302BD03100DD0620 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
052C05CF302BD03100DD0620 /* PBXTargetDependency */,
); );
name = "Flotilla Chat"; name = "Flotilla Chat";
productName = App; productName = App;
@ -132,9 +202,12 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920; LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 2630; LastUpgradeCheck = 2630;
TargetAttributes = { TargetAttributes = {
052C05C5302BD03100DD0620 = {
CreatedOnToolsVersion = 26.3;
};
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100; LastSwiftMigration = 1100;
@ -156,11 +229,19 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
504EC3031FED79650016851F /* Flotilla Chat */, 504EC3031FED79650016851F /* Flotilla Chat */,
052C05C5302BD03100DD0620 /* ShareExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
052C05C4302BD03100DD0620 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = { 504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -214,6 +295,14 @@
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
052C05C2302BD03100DD0620 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
052C05E5302BD03100DD0620 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = { 504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -224,6 +313,14 @@
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
052C05CF302BD03100DD0620 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 052C05C5302BD03100DD0620 /* ShareExtension */;
targetProxy = 052C05CE302BD03100DD0620 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */ /* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = { 504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup; isa = PBXVariantGroup;
@ -244,6 +341,78 @@
/* End PBXVariantGroup section */ /* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
052C05D2302BD03100DD0620 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.9.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
052C05D3302BD03100DD0620 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.9.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = social.flotilla.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
504EC3141FED79650016851F /* Debug */ = { 504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@ -424,6 +593,15 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
052C05D5302BD03100DD0620 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
052C05D2302BD03100DD0620 /* Debug */,
052C05D3302BD03100DD0620 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "Flotilla Chat.app"
BlueprintName = "Flotilla Chat"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "Flotilla Chat.app"
BlueprintName = "Flotilla Chat"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "Flotilla Chat.app"
BlueprintName = "Flotilla Chat"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -16,6 +16,17 @@
<string>$(PRODUCT_NAME)</string> <string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>social.flotilla</string>
<key>CFBundleURLSchemes</key>
<array>
<string>flotilla</string>
</array>
</dict>
</array>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>

View file

@ -8,5 +8,9 @@
<array> <array>
<string>applinks:app.flotilla.social</string> <string>applinks:app.flotilla.social</string>
</array> </array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.social.flotilla</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Flotilla</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>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,10 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>group.social.flotilla</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,159 @@
import ObjectiveC
import UIKit
import UniformTypeIdentifiers
// A share extension runs in its own process and can't reach the app or its web view, so the
// payload is handed over as a flotilla://share url, which the root layout picks up via
// appUrlOpen. Media is far too big for a url, so it gets copied into the shared app group
// container — the one directory both processes can read — and passed along by path.
class ShareViewController: UIViewController {
private let appGroup = "group.social.flotilla"
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let attachments = (extensionContext?.inputItems.first as? NSExtensionItem)?.attachments ?? []
// Browsers attach a page's title alongside its url, so take these in order of specificity.
// Photos offers a jpeg representation of a heic asset, which saves us re-encoding it later.
for type in [UTType.movie, UTType.jpeg, UTType.image, UTType.url, UTType.plainText] {
for provider in attachments where provider.hasItemConformingToTypeIdentifier(type.identifier) {
provider.loadItem(forTypeIdentifier: type.identifier) { value, _ in
if type == .url {
self.shareText((value as? URL)?.absoluteString)
} else if type == .plainText {
self.shareText(value as? String)
} else {
self.shareFile(value)
}
}
return
}
}
finish()
}
private func shareText(_ text: String?) {
guard let text = text else {
return finish()
}
open([URLQueryItem(name: "text", value: text)])
}
private func shareFile(_ value: NSSecureCoding?) {
let manager = FileManager.default
guard let container = manager.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
return finish()
}
// Nothing prunes the group container for us, and the app is done with the last share by the
// time a new one arrives. Emptying a directory of our own leaves the rest of the container,
// Library/ included, alone.
let directory = container.appendingPathComponent("shares")
try? manager.removeItem(at: directory)
do {
try manager.createDirectory(at: directory, withIntermediateDirectories: true)
if let source = value as? URL {
let destination = directory.appendingPathComponent(source.lastPathComponent)
try manager.copyItem(at: source, to: destination)
open(file: destination)
} else if let image = value as? UIImage, let data = image.jpegData(compressionQuality: 0.9) {
let destination = directory.appendingPathComponent("shared.jpg")
try data.write(to: destination)
open(file: destination)
} else {
finish()
}
} catch {
finish()
}
}
private func open(file: URL) {
open([
URLQueryItem(name: "path", value: file.path),
URLQueryItem(name: "name", value: file.lastPathComponent),
URLQueryItem(
name: "type",
value: UTType(filenameExtension: file.pathExtension)?.preferredMIMEType
),
])
}
private func open(_ queryItems: [URLQueryItem]) {
guard var components = URLComponents(string: "flotilla://share") else {
return finish()
}
components.queryItems = queryItems
guard let url = components.url else {
return finish()
}
DispatchQueue.main.async {
NSLog("Flotilla: opening %@", url.absoluteString)
// Which of these works varies by ios version and neither reports whether the app actually
// came forward, so try both. Opening the same url twice is harmless.
let foundApplication = self.openViaResponderChain(url)
self.extensionContext?.open(url) { opened in
NSLog(
"Flotilla: responder chain %@, extensionContext.open %@",
foundApplication ? "found UIApplication" : "found nothing",
opened ? "succeeded" : "failed"
)
// Completing the request tears down the extension, which can cancel an open springboard
// hasn't picked up yet
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.finish()
}
}
}
}
// UIApplication isn't reachable from an extension, so walk the responder chain to find it. Its
// open(_:options:completionHandler:) isn't visible to us at compile time either, and the
// openURL: that extensions used for years now force-returns false, so call it via the runtime.
private func openViaResponderChain(_ url: URL) -> Bool {
let selector = NSSelectorFromString("openURL:options:completionHandler:")
var responder: UIResponder? = self
while let current = responder {
if let application = current as? UIApplication,
let method = class_getInstanceMethod(type(of: application), selector)
{
typealias OpenURL = @convention(c) (UIApplication, Selector, NSURL, NSDictionary, AnyObject?)
-> Void
let open = unsafeBitCast(method_getImplementation(method), to: OpenURL.self)
let completion: @convention(block) (Bool) -> Void = { opened in
NSLog("Flotilla: UIApplication.open %@", opened ? "succeeded" : "failed")
}
open(application, selector, url as NSURL, NSDictionary(), completion as AnyObject)
return true
}
responder = current.next
}
return false
}
private func finish() {
extensionContext?.completeRequest(returningItems: nil)
}
}

View file

@ -71,6 +71,7 @@
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2", "@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2", "@tiptap/pm": "^2.27.2",
"@tiptap/suggestion": "^2.27.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
@ -98,6 +99,10 @@
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4", "nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"prosemirror-model": "^1.25.7",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.8",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2", "throttle-debounce": "^5.0.2",

Binary file not shown.

View file

@ -6,7 +6,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import {shareEventToChat} from "@app/share" import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@ -19,7 +19,7 @@
const showInfo = () => pushModal(EventInfo, {url, event: board.event}) const showInfo = () => pushModal(EventInfo, {url, event: board.event})
const share = () => shareEventToChat(url, "Shelf", board.event) const share = () => shareEvent(url, "Shelf", board.event)
let ul: Element let ul: Element

View file

@ -17,6 +17,7 @@
enumerate, enumerate,
formatTimestampAsDate, formatTimestampAsDate,
} from "@welshman/lib" } from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util" import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
import {makeEvent, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util" import {makeEvent, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
import {parse, isLink} from "@welshman/content" import {parse, isLink} from "@welshman/content"
@ -44,8 +45,9 @@
import {userSettingsValues} from "@app/settings" import {userSettingsValues} from "@app/settings"
import {deriveChat, makeChatId} from "@app/chats" import {deriveChat, makeChatId} from "@app/chats"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {DraftKey} from "@app/drafts" import {DraftKey, type Draft} from "@app/drafts"
import {prependParent} from "@app/rooms" import {prependParent} from "@app/rooms"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -57,7 +59,7 @@
const chatId = makeChatId(pubkeys) const chatId = makeChatId(pubkeys)
const chat = deriveChat(chatId) const chat = deriveChat(chatId)
const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`) const draftKey = new DraftKey<Draft>(`dm:${chatId}`)
const others = remove($user.pubkey, pubkeys) const others = remove($user.pubkey, pubkeys)
const messagingRelayLists = $app.use(MessagingRelayLists).index.$ const messagingRelayLists = $app.use(MessagingRelayLists).index.$
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk))) const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
@ -183,6 +185,26 @@
let compose: ChatCompose | undefined = $state() let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state() let parent: TrustedEvent | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state()
let share: Maybe<Share> = $state()
// Claim the share once we're on screen. Sharing into the conversation you're already looking at
// doesn't re-create this component, so this can't be read once on mount.
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
const elements = $derived.by(() => { const elements = $derived.by(() => {
const elements = [] const elements = []
@ -318,13 +340,13 @@
<ChatComposeEdit clear={clearEventToEdit} /> <ChatComposeEdit clear={clearEventToEdit} />
{/if} {/if}
</div> </div>
{#key eventToEdit} {#key initialValues}
<ChatCompose <ChatCompose
bind:this={compose} bind:this={compose}
{onSubmit} {onSubmit}
{onEscape} {onEscape}
{onEditPrevious} {onEditPrevious}
initialValues={eventToEdit} {initialValues}
draftKey={eventToEdit ? undefined : draftKey} draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} /> disabled={Boolean(missingRelayLists.length)} />
{/key} {/key}

View file

@ -11,22 +11,19 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {type DraftKey} from "@app/drafts" import {type DraftKey, type Draft} from "@app/drafts"
import type {Share} from "@app/share"
type Values = {
content?: string | object
}
type Props = { type Props = {
disabled?: boolean disabled?: boolean
draftKey?: DraftKey<Values> draftKey?: DraftKey<Draft>
onEscape?: () => void onEscape?: () => void
onEditPrevious?: () => void onEditPrevious?: () => void
onSubmit: (event: EventContent) => void onSubmit: (event: EventContent) => void
initialValues?: Values initialValues?: Share
} }
let { const {
initialValues, initialValues,
disabled = false, disabled = false,
draftKey, draftKey,
@ -35,10 +32,6 @@
onSubmit, onSubmit,
}: Props = $props() }: Props = $props()
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile && !disabled const autofocus = !isMobile && !disabled
const uploading = writable(false) const uploading = writable(false)
@ -81,7 +74,9 @@
ed.chain().clearContent().run() ed.chain().clearContent().run()
} }
let content = $state(initialValues?.content ?? "") let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
)
const onChange = (json: object) => { const onChange = (json: object) => {
content = json content = json
@ -103,6 +98,12 @@
onMount(async () => { onMount(async () => {
const ed = await editor const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown) ed.view.dom.addEventListener("keydown", handleKeyDown)
if (initialValues?.type === "file") {
ed.chain()
.addFile(initialValues.value, ed.state.selection.from + 1)
.run()
}
}) })
onDestroy(async () => { onDestroy(async () => {

View file

@ -15,7 +15,7 @@
import Report from "@app/components/Report.svelte" import Report from "@app/components/Report.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte" import PinboardSelect from "@app/components/PinboardSelect.svelte"
import {shareEventToChat} from "@app/share" import {shareEvent} from "@app/share"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveUserIsSpaceAdmin} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -40,7 +40,7 @@
const addToLibrary = () => pushModal(PinboardSelect, {url, event}) const addToLibrary = () => pushModal(PinboardSelect, {url, event})
const share = () => shareEventToChat(url, noun, event) const share = () => shareEvent(url, noun, event)
const showDelete = () => pushModal(EventDeleteConfirm, {url, event}) const showDelete = () => pushModal(EventDeleteConfirm, {url, event})

View file

@ -13,7 +13,7 @@
import PinEdit from "@app/components/PinEdit.svelte" import PinEdit from "@app/components/PinEdit.svelte"
import {deletes} from "@app/core" import {deletes} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveUserIsSpaceAdmin} from "@app/management"
import {shareEventToChat} from "@app/share" import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -29,7 +29,7 @@
const showInfo = () => pushModal(EventInfo, {url, event: pin.event}) const showInfo = () => pushModal(EventInfo, {url, event: pin.event})
const share = () => shareEventToChat(url, "Link", pin.event) const share = () => shareEvent(url, "Link", pin.event)
const edit = () => pushModal(PinEdit, {url, pin}) const edit = () => pushModal(PinEdit, {url, pin})

View file

@ -30,10 +30,8 @@
</div> </div>
</div> </div>
{#if $userSpaceUrls.includes(url)} {#if $userSpaceUrls.includes(url)}
<div <div class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary text-primary-content flex justify-center items-center">
class="tip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary text-primary-content" <Icon icon={CheckCircle} class="scale-120" />
data-tip="You are already a member of this space.">
<Icon icon={CheckCircle} class="scale-110" />
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -7,6 +7,7 @@
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import cx from "classnames" import cx from "classnames"
import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util" import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {publish} from "@welshman/app" import {publish} from "@welshman/app"
@ -16,7 +17,6 @@
import ChatRound from "@assets/icons/chat-round.svg?dataurl" import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl" import Close from "@assets/icons/close.svg?dataurl"
import {fade, fly} from "@lib/transition" import {fade, fly} from "@lib/transition"
import {popKey} from "@lib/implicit"
import {documentActive} from "@lib/html" import {documentActive} from "@lib/html"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
@ -46,6 +46,7 @@
import {makeFeed} from "@app/feeds" import {makeFeed} from "@app/feeds"
import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications" import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications"
import {makeRoomPath} from "@app/routes" import {makeRoomPath} from "@app/routes"
import {pendingShare, type Share} from "@app/share"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -183,7 +184,7 @@
} }
const onSubmit = async ({content, tags}: EventContent) => { const onSubmit = async ({content, tags}: EventContent) => {
if (!content && !share) { if (!content && !sharedEvent) {
return return
} }
@ -216,8 +217,8 @@
command.publishToRelays([url]) command.publishToRelays([url])
} }
if (share) { if (sharedEvent) {
template = await prependParent(share, template, url) template = await prependParent(sharedEvent, template, url)
} }
if (parent) { if (parent) {
@ -324,7 +325,7 @@
let isUserScrolling = $state(false) let isUserScrolling = $state(false)
let loadingBackward = $state(true) let loadingBackward = $state(true)
let loadingForward = $state(true) let loadingForward = $state(true)
let share = $state(popKey<TrustedEvent | undefined>("share")) let share: Maybe<Share> = $state()
let parent: TrustedEvent | undefined = $state() let parent: TrustedEvent | undefined = $state()
let element: HTMLElement | undefined = $state() let element: HTMLElement | undefined = $state()
let lastVisibleAt = now() let lastVisibleAt = now()
@ -338,6 +339,27 @@
let compose: RoomCompose | undefined = $state() let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state()
// Claim the share once we're on screen. Sharing into the room you're already looking at
// doesn't re-create this component, so this can't be read once on mount.
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const sharedEvent = $derived(share?.type === "event" ? share.value : undefined)
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
const clearIsUserScrolling = debounce(150, () => { const clearIsUserScrolling = debounce(150, () => {
isUserScrolling = false isUserScrolling = false
}) })
@ -619,21 +641,21 @@
{#if parent} {#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" /> <RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if} {/if}
{#if share} {#if sharedEvent}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" /> <RoomComposeParent event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if} {/if}
{#if eventToEdit} {#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} /> <RoomComposeEdit clear={clearEventToEdit} />
{/if} {/if}
</div> </div>
{#key eventToEdit} {#key initialValues}
<RoomCompose <RoomCompose
{url} {url}
{h} {h}
{onSubmit} {onSubmit}
{onEscape} {onEscape}
{onEditPrevious} {onEditPrevious}
initialValues={eventToEdit} {initialValues}
bind:this={compose} /> bind:this={compose} />
{/key} {/key}
{/if} {/if}

View file

@ -13,29 +13,22 @@
import ComposeMenu from "@app/components/ComposeMenu.svelte" import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" import {makeEditor} from "@app/editor"
import {DraftKey} from "@app/drafts" import {DraftKey, type Draft} from "@app/drafts"
import type {Share} from "@app/share"
import {onDestroy, onMount} from "svelte" import {onDestroy, onMount} from "svelte"
type Values = {
content?: string | object
}
type Props = { type Props = {
url?: string url?: string
h?: string h?: string
onEscape?: () => void onEscape?: () => void
onEditPrevious?: () => void onEditPrevious?: () => void
onSubmit: (event: EventContent) => void onSubmit: (event: EventContent) => void
initialValues?: Values initialValues?: Share
} }
let {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props() const {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draftKey = url || h ? new DraftKey<Values>(`room:${url ?? ""}:${h ?? ""}`) : undefined const draftKey = url || h ? new DraftKey<Draft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
if (!initialValues) {
initialValues = draftKey?.get()
}
const autofocus = !isMobile const autofocus = !isMobile
@ -76,7 +69,9 @@
} }
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let content = $state(initialValues?.content ?? "") let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
)
const onChange = (json: object) => { const onChange = (json: object) => {
content = json content = json
@ -98,6 +93,12 @@
onMount(async () => { onMount(async () => {
const ed = await editor const ed = await editor
ed.view.dom.addEventListener("keydown", handleKeyDown) ed.view.dom.addEventListener("keydown", handleKeyDown)
if (initialValues?.type === "file") {
ed.chain()
.addFile(initialValues.value, ed.state.selection.from + 1)
.run()
}
}) })
onDestroy(async () => { onDestroy(async () => {

View file

@ -0,0 +1,137 @@
<script lang="ts">
import {derived} from "svelte/store"
import cx from "classnames"
import {remove} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {chatSearch} from "@app/chats"
import {user} from "@app/core"
import {deriveUserRoomSearch} from "@app/rooms"
import {makeChatPath, makeRoomPath} from "@app/routes"
import {shareTo, type Share} from "@app/share"
type Props = {
share: Share
}
const {share}: Props = $props()
const roomSearch = deriveUserRoomSearch()
const toggle = (path: string) => {
selection = path === selection ? "" : path
}
const back = () => history.back()
const submit = () => shareTo(selection, share, {replaceState: true})
let term = $state("")
let selection = $state("")
const roomResults = $derived($roomSearch.searchOptions(term))
const chatResults = $derived($chatSearch.searchOptions(term))
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Share</ModalTitle>
<ModalSubtitle>Where would you like to share this?</ModalSubtitle>
</ModalHeader>
<label class="input input-sm flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search rooms and conversations..." />
</label>
<div class="flex min-h-96 flex-col gap-4">
{#if roomResults.length > 0}
<p class="text-xs uppercase tracking-wide opacity-60">Rooms</p>
<div class="flex flex-col gap-2">
{#each roomResults as { url, h } (makeRoomPath(url, h))}
{@const path = makeRoomPath(url, h)}
<Button
type="button"
class={cx(
"flex w-full min-w-0 items-center justify-between gap-2 button",
selection === path ? "button-primary" : "button-neutral",
)}
onclick={() => toggle(path)}>
<RoomNameWithImage {url} {h} />
<RelayName {url} class="min-w-0 truncate text-xs opacity-60" />
</Button>
{/each}
</div>
{/if}
{#if chatResults.length > 0}
<p class="text-xs uppercase tracking-wide opacity-60">Direct Messages</p>
<div class="flex flex-col gap-2">
{#each chatResults as chat (chat.id)}
{@const path = makeChatPath(chat.pubkeys)}
{@const others = remove($user.pubkey, chat.pubkeys)}
<Button
type="button"
class={cx(
"flex w-full min-w-0 items-center justify-start gap-2 button",
selection === path ? "button-primary" : "button-neutral",
)}
onclick={() => toggle(path)}>
{#if others.length > 1}
<ProfileCircles pubkeys={others} size={5} />
<span class="min-w-0 truncate">
<ProfileName pubkey={others[0]} />
+{others.length - 1}
</span>
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
<span class="min-w-0 truncate">
<ProfileName pubkey={others[0]} />
</span>
{:else}
<ProfileCircle pubkey={$user.pubkey} size={5} />
Note to self
{/if}
</Button>
{/each}
</div>
{/if}
{#if roomResults.length === 0 && chatResults.length === 0}
<p class="py-12 text-center opacity-75">
{#if term}
Nothing matched your search.
{:else}
Join a space or start a conversation to have somewhere to share to.
{/if}
</p>
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="button button-primary" disabled={!selection}>
Share
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>

View file

@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import {setKey} from "@lib/implicit"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
@ -15,6 +13,7 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {makeRoomPath} from "@app/routes" import {makeRoomPath} from "@app/routes"
import {shareTo} from "@app/share"
import {rooms} from "@app/core" import {rooms} from "@app/core"
type Props = { type Props = {
@ -30,8 +29,7 @@
const back = () => history.back() const back = () => history.back()
const onSubmit = () => { const onSubmit = () => {
setKey("share", event) shareTo(makeRoomPath(url, selection), {type: "event", value: event}, {replaceState: true})
goto(makeRoomPath(url, selection), {replaceState: true})
} }
const toggleRoom = (h: string) => { const toggleRoom = (h: string) => {

View file

@ -1,5 +1,9 @@
const store = new Map<string, unknown>() const store = new Map<string, unknown>()
export type Draft = {
content?: string | object
}
export class DraftKey<T> { export class DraftKey<T> {
constructor(private key: string) {} constructor(private key: string) {}

View file

@ -20,7 +20,7 @@ import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension" import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte" import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
import {NativeClipboardPasteExtension} from "@app/editor/clipboard" import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
import {compressFileForUpload, uploadFile} from "@app/uploads" import {UPLOAD_MIME_TYPES, compressFileForUpload, uploadFile} from "@app/uploads"
import {userSpaceUrls} from "@app/rooms" import {userSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS} from "@app/env" import {PLATFORM_RELAYS} from "@app/env"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -108,6 +108,7 @@ export const makeEditor = async ({
}, },
fileUpload: { fileUpload: {
config: { config: {
allowedMimeTypes: UPLOAD_MIME_TYPES,
upload: async (attrs: FileAttributes) => upload: async (attrs: FileAttributes) =>
uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}), uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}),
onDrop: () => uploading?.set(true), onDrop: () => uploading?.set(true),

View file

@ -15,7 +15,7 @@ import {
toNostrURI, toNostrURI,
} from "@welshman/util" } from "@welshman/util"
import type {EventContent, TrustedEvent} from "@welshman/util" import type {EventContent, TrustedEvent} from "@welshman/util"
import {RoomLists, makeRoomKey} from "@welshman/app" import {RoomLists, makeRoomKey, createSearch} from "@welshman/app"
import type {Room, RoomMeta} from "@welshman/app" import type {Room, RoomMeta} from "@welshman/app"
import { import {
deriveUserItem, deriveUserItem,
@ -25,10 +25,12 @@ import {
rooms, rooms,
router, router,
thunks, thunks,
relays,
user, user,
} from "@app/core" } from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveEventsForUrl} from "@app/repository" import {deriveEventsForUrl} from "@app/repository"
import {makeRoomPath} from "@app/routes"
export enum MembershipStatus { export enum MembershipStatus {
Initial, Initial,
@ -218,3 +220,30 @@ export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
}, },
) )
export const deriveUserRoomSearch = () =>
derived(
[userSpaceUrls, userRoomList, rooms.get().byUrl.$],
([$userSpaceUrls, $userRoomList, $roomsByUrl]) => {
const options = $userSpaceUrls.flatMap(url => {
const favorites = new Set($userRoomList?.roomsForUrl(url) ?? [])
// Spaces that aren't NIP-29 relays keep all their messages in the space-wide chat
const roomIds = relays.get().get(url)?.hasNip(29)
? ($roomsByUrl.get(url) ?? []).map(room => room.h)
: ["chat"]
return roomIds.map(h => ({
url,
h,
name: displayRoom(url, h),
isFavorite: favorites.has(h),
}))
})
return createSearch(options, {
getValue: option => makeRoomPath(option.url, option.h),
fuseOptions: {keys: ["name", "url"]},
sortFn: ({item}) => (item.isFavorite ? 0 : 1),
})
},
)

View file

@ -107,10 +107,10 @@ export const goToMovedSpace = (oldUrl: string, newUrl: string) =>
export const goToHome = () => { export const goToHome = () => {
if (PLATFORM_RELAYS.length > 0) { if (PLATFORM_RELAYS.length > 0) {
goToSpace(PLATFORM_RELAYS[0], get(page).url.hash) return goToSpace(PLATFORM_RELAYS[0], get(page).url.hash)
} else {
goto("/home" + get(page).url.hash)
} }
return goto("/home" + get(page).url.hash)
} }
// Content types, events // Content types, events

View file

@ -1,18 +1,108 @@
import {writable} from "svelte/store"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {Capacitor, registerPlugin} from "@capacitor/core"
import type {PluginListenerHandle} from "@capacitor/core"
import {noop} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {setKey} from "@lib/implicit" import {app, relays} from "@app/core"
import {relays} from "@app/core"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpaceChatPath} from "@app/routes" import {makeSpaceChatPath} from "@app/routes"
import EventShare from "@app/components/EventShare.svelte" import {pushToast} from "@app/toast"
import {UPLOAD_MIME_TYPES} from "@app/uploads"
import ShareDialog from "@app/components/Share.svelte"
import ShareEvent from "@app/components/ShareEvent.svelte"
// Share an event to chat. On NIP-29 relays we prompt for a room to share into; export type Share =
// otherwise we stash the event and jump to the space chat composer, which quotes it. | {type: "event"; value: TrustedEvent}
export const shareEventToChat = (url: string, noun: string, event: TrustedEvent) => { | {type: "text"; value: string}
| {type: "file"; value: File}
export const pendingShare = writable<Maybe<Share>>(undefined)
// Set pendingShare after goto so the current view doesn't pop it
export const shareTo = async (
path: string,
share: Share,
options: {replaceState?: boolean} = {},
) => {
await goto(path, options)
pendingShare.set(share)
}
export const shareEvent = (url: string, noun: string, event: TrustedEvent) => {
if (relays.get().get(url)?.hasNip(29)) { if (relays.get().get(url)?.hasNip(29)) {
pushModal(EventShare, {url, noun, event}) pushModal(ShareEvent, {url, noun, event})
} else { } else {
setKey("share", event) shareTo(makeSpaceChatPath(url), {type: "event", value: event})
goto(makeSpaceChatPath(url))
} }
} }
const openShareDialog = (share: Share) => {
if (app.get().user?.pubkey) {
pushModal(ShareDialog, {share})
}
}
export const shareText = (value: string) => openShareDialog({type: "text", value})
type NativeShare = {
text?: string
path?: string
name?: string
type?: string
}
// Shared media is copied somewhere capacitor's file server can reach, since neither an android
// content uri nor an ios app group url means anything to the web view.
export const shareFromNative = async ({
text,
path,
name = "",
type: mimeType = "",
}: NativeShare) => {
if (text) {
shareText(text)
} else if (path) {
if (!UPLOAD_MIME_TYPES.includes(mimeType)) {
return pushToast({theme: "error", message: "Flotilla can't share that type of file."})
}
try {
const response = await fetch(Capacitor.convertFileSrc(path))
if (!response.ok) {
throw new Error(`Failed to read ${path} (${response.status})`)
}
const value = new File([await response.blob()], name, {type: mimeType})
openShareDialog({type: "file", value})
} catch (e) {
console.error(e)
pushToast({theme: "error", message: "Something went wrong reading the shared file."})
}
}
}
type ShareIntentPlugin = {
addListener(
eventName: "shareReceived",
listener: (share: NativeShare) => void,
): Promise<PluginListenerHandle>
}
const ShareIntent = registerPlugin<ShareIntentPlugin>("ShareIntent")
export const setupShareIntents = () => {
if (Capacitor.getPlatform() === "android") {
const listener = ShareIntent.addListener("shareReceived", shareFromNative)
return () => {
listener.then(handle => handle.remove())
}
}
return noop
}

View file

@ -61,6 +61,22 @@ export const getBlossomServer = async (options: GetBlossomServerOptions = {}) =>
return first(DEFAULT_BLOSSOM_SERVERS)! return first(DEFAULT_BLOSSOM_SERVERS)!
} }
// The editor's default list leaves out the formats an iPhone camera actually produces, so
// anything shared from Photos would be rejected without a word. Heic gets re-encoded by the
// compressor on its way to a blossom server; quicktime is uploaded as-is.
export const UPLOAD_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/heif",
"video/mp4",
"video/mpeg",
"video/webm",
"video/quicktime",
]
export type CompressFileOptions = { export type CompressFileOptions = {
maxWidth?: number maxWidth?: number
maxHeight?: number maxHeight?: number

View file

@ -28,6 +28,7 @@
import {kv, ss, storage} from "@app/storage" import {kv, ss, storage} from "@app/storage"
import {device} from "@app/device" import {device} from "@app/device"
import {userSettingsValues, notificationSettings} from "@app/settings" import {userSettingsValues, notificationSettings} from "@app/settings"
import {setupShareIntents, shareFromNative} from "@app/share"
import {shouldUnwrap, syncApplicationData} from "@app/sync" import {shouldUnwrap, syncApplicationData} from "@app/sync"
import * as env from "@app/env" import * as env from "@app/env"
import {theme} from "@app/theme" import {theme} from "@app/theme"
@ -68,6 +69,13 @@
return return
} }
// The iOS share extension can't talk to us directly, so it hands its payload over as a
// flotilla://share url
if (url.host === "share") {
shareFromNative(Object.fromEntries(url.searchParams))
return
}
if (url.host === "x-callback-url") { if (url.host === "x-callback-url") {
if (url.pathname === "/authError") { if (url.pathname === "/authError") {
const errorMessage = url.searchParams.get("errorMessage") const errorMessage = url.searchParams.get("errorMessage")
@ -161,6 +169,9 @@
// History, navigation, application data // History, navigation, application data
unsubscribers.push(setupHistory(), setupAnalytics(), syncApplicationData()) unsubscribers.push(setupHistory(), setupAnalytics(), syncApplicationData())
// Listen for links shared into the app from elsewhere on the device
unsubscribers.push(setupShareIntents())
// Initialize keyboard state tracking // Initialize keyboard state tracking
unsubscribers.push(syncKeyboard()) unsubscribers.push(syncKeyboard())

View file

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import {goToHome} from "@app/routes" import {goToHome} from "@app/routes"
$effect(goToHome) $effect(() => {
goToHome()
})
</script> </script>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {goToHome} from "@app/routes"
import {shareFromNative} from "@app/share"
const params = Object.fromEntries($page.url.searchParams)
onMount(async () => {
await goToHome()
shareFromNative(params)
})
</script>

View file

@ -29,7 +29,7 @@
import {app, deletes, network} from "@app/core" import {app, deletes, network} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveUserIsSpaceAdmin} from "@app/management"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {shareEventToChat} from "@app/share" import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -80,7 +80,7 @@
menuOpen = false menuOpen = false
if ($board) { if ($board) {
shareEventToChat(url, "Shelf", $board.event) shareEvent(url, "Shelf", $board.event)
} }
} }

View file

@ -13,7 +13,9 @@ export default defineConfig({
// carries node_modules from welshman's own install — a second prosemirror, whose Plugin and // carries node_modules from welshman's own install — a second prosemirror, whose Plugin and
// PluginKey classes tiptap refuses to mix with ours ("Adding different instances of a keyed // PluginKey classes tiptap refuses to mix with ours ("Adding different instances of a keyed
// plugin"). Deduping resolves these to this project's copy wherever they are imported from, and // plugin"). Deduping resolves these to this project's copy wherever they are imported from, and
// does nothing when only one copy exists. // does nothing when only one copy exists. Vite resolves a deduped package from the project root
// and nowhere else, so every name listed here has to be a direct dependency — under pnpm a
// transitive one isn't reachable from the root and the build fails to resolve it.
resolve: { resolve: {
dedupe: [ dedupe: [
"@tiptap/core", "@tiptap/core",