diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2b5733c5..257a04ad 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,17 @@ + + + + + + + + + + file.outputStream().use { output -> input.copyTo(output) } + } + + return JSObject() + .put("path", file.absolutePath) + .put("name", file.name) + .put("type", type) + } +} diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 73aa3434..927b0abd 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* 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 */; }; 3478F0332E846FEB002431E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */; }; 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 */; }; /* 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 */ + 052C05E2302BD03100DD0620 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 052C05E3302BD03100DD0620 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 052C05E4302BD03100DD0620 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Flotilla Chat.entitlements"; sourceTree = ""; }; + 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 = ""; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -38,6 +68,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 052C05C3302BD03100DD0620 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 504EC3011FED79650016851F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -49,6 +86,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 052C05E1302BD03100DD0620 /* ShareExtension */ = { + isa = PBXGroup; + children = ( + 052C05E2302BD03100DD0620 /* ShareViewController.swift */, + 052C05E3302BD03100DD0620 /* Info.plist */, + 052C05E4302BD03100DD0620 /* ShareExtension.entitlements */, + ); + path = ShareExtension; + sourceTree = ""; + }; 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -63,6 +110,7 @@ 3478F0322E846FEB002431E0 /* PrivacyInfo.xcprivacy */, 051414282E0CC28400BE0BC8 /* Flotilla Chat.entitlements */, 504EC3061FED79650016851F /* App */, + 052C05E1302BD03100DD0620 /* ShareExtension */, 504EC3051FED79650016851F /* Products */, 7F8756D8B27F46E3366F6CEA /* Pods */, 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, @@ -73,6 +121,7 @@ isa = PBXGroup; children = ( 504EC3041FED79650016851F /* Flotilla Chat.app */, + 052C05C6302BD03100DD0620 /* ShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -106,6 +155,25 @@ /* End PBXGroup 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 */ = { isa = PBXNativeTarget; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Flotilla Chat" */; @@ -115,10 +183,12 @@ 504EC3011FED79650016851F /* Frameworks */, 504EC3021FED79650016851F /* Resources */, 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + 052C05D1302BD03100DD0620 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 052C05CF302BD03100DD0620 /* PBXTargetDependency */, ); name = "Flotilla Chat"; productName = App; @@ -132,9 +202,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 920; + LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 2630; TargetAttributes = { + 052C05C5302BD03100DD0620 = { + CreatedOnToolsVersion = 26.3; + }; 504EC3031FED79650016851F = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; @@ -156,11 +229,19 @@ projectRoot = ""; targets = ( 504EC3031FED79650016851F /* Flotilla Chat */, + 052C05C5302BD03100DD0620 /* ShareExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 052C05C4302BD03100DD0620 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 504EC3021FED79650016851F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -214,6 +295,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 052C05C2302BD03100DD0620 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 052C05E5302BD03100DD0620 /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 504EC3001FED79650016851F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -224,6 +313,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 052C05CF302BD03100DD0620 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 052C05C5302BD03100DD0620 /* ShareExtension */; + targetProxy = 052C05CE302BD03100DD0620 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 504EC30B1FED79650016851F /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -244,6 +341,78 @@ /* End PBXVariantGroup 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 */ = { isa = XCBuildConfiguration; buildSettings = { @@ -424,6 +593,15 @@ /* End XCBuildConfiguration 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" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/App/App.xcodeproj/xcshareddata/xcschemes/Flotilla Chat.xcscheme b/ios/App/App.xcodeproj/xcshareddata/xcschemes/Flotilla Chat.xcscheme new file mode 100644 index 00000000..e36c9d70 --- /dev/null +++ b/ios/App/App.xcodeproj/xcshareddata/xcschemes/Flotilla Chat.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index a4078e58..0589543c 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -16,6 +16,17 @@ $(PRODUCT_NAME) CFBundlePackageType APPL + CFBundleURLTypes + + + CFBundleURLName + social.flotilla + CFBundleURLSchemes + + flotilla + + + CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion diff --git a/ios/App/Flotilla Chat.entitlements b/ios/App/Flotilla Chat.entitlements index f17ed4c6..9f4b5d28 100644 --- a/ios/App/Flotilla Chat.entitlements +++ b/ios/App/Flotilla Chat.entitlements @@ -8,5 +8,9 @@ applinks:app.flotilla.social + com.apple.security.application-groups + + group.social.flotilla + diff --git a/ios/App/ShareExtension/Info.plist b/ios/App/ShareExtension/Info.plist new file mode 100644 index 00000000..829221ba --- /dev/null +++ b/ios/App/ShareExtension/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Flotilla + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 1 + NSExtensionActivationSupportsMovieWithMaxCount + 1 + NSExtensionActivationSupportsText + + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/ios/App/ShareExtension/ShareExtension.entitlements b/ios/App/ShareExtension/ShareExtension.entitlements new file mode 100644 index 00000000..2fda2ca6 --- /dev/null +++ b/ios/App/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.social.flotilla + + + diff --git a/ios/App/ShareExtension/ShareViewController.swift b/ios/App/ShareExtension/ShareViewController.swift new file mode 100644 index 00000000..238ca835 --- /dev/null +++ b/ios/App/ShareExtension/ShareViewController.swift @@ -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) + } +} diff --git a/package.json b/package.json index 4ee51538..3e9a6e8d 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@sveltejs/adapter-static": "^3.0.10", "@tiptap/core": "^2.27.2", "@tiptap/pm": "^2.27.2", + "@tiptap/suggestion": "^2.27.2", "@types/qrcode": "^1.5.6", "@types/throttle-debounce": "^5.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-tools": "^2.19.4", "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", "qrcode": "^1.5.4", "throttle-debounce": "^5.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d421bbaa..f746b83e 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/src/app/components/BoardMenu.svelte b/src/app/components/BoardMenu.svelte index 489ddf4f..bc9e9cea 100644 --- a/src/app/components/BoardMenu.svelte +++ b/src/app/components/BoardMenu.svelte @@ -6,7 +6,7 @@ import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import EventInfo from "@app/components/EventInfo.svelte" - import {shareEventToChat} from "@app/share" + import {shareEvent} from "@app/share" import {pushModal} from "@app/modal" type Props = { @@ -19,7 +19,7 @@ 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 diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index 4578b3a3..94ae8fb6 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -17,6 +17,7 @@ enumerate, formatTimestampAsDate, } from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util" import {makeEvent, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util" import {parse, isLink} from "@welshman/content" @@ -44,8 +45,9 @@ import {userSettingsValues} from "@app/settings" import {deriveChat, makeChatId} from "@app/chats" import {pushModal} from "@app/modal" - import {DraftKey} from "@app/drafts" + import {DraftKey, type Draft} from "@app/drafts" import {prependParent} from "@app/rooms" + import {pendingShare, type Share} from "@app/share" import {pushToast} from "@app/toast" type Props = { @@ -57,7 +59,7 @@ const chatId = makeChatId(pubkeys) const chat = deriveChat(chatId) - const draftKey = new DraftKey<{content?: string | object}>(`dm:${chatId}`) + const draftKey = new DraftKey(`dm:${chatId}`) const others = remove($user.pubkey, pubkeys) const messagingRelayLists = $app.use(MessagingRelayLists).index.$ const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk))) @@ -183,6 +185,26 @@ let compose: ChatCompose | undefined = $state() let parent: TrustedEvent | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state() + let share: Maybe = $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 = [] @@ -318,13 +340,13 @@ {/if} - {#key eventToEdit} + {#key initialValues} {/key} diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte index a74cfca3..8412e31c 100644 --- a/src/app/components/ChatCompose.svelte +++ b/src/app/components/ChatCompose.svelte @@ -11,22 +11,19 @@ import Spinner from "@lib/components/Spinner.svelte" import EditorContent from "@app/editor/EditorContent.svelte" import {makeEditor} from "@app/editor" - import {type DraftKey} from "@app/drafts" - - type Values = { - content?: string | object - } + import {type DraftKey, type Draft} from "@app/drafts" + import type {Share} from "@app/share" type Props = { disabled?: boolean - draftKey?: DraftKey + draftKey?: DraftKey onEscape?: () => void onEditPrevious?: () => void onSubmit: (event: EventContent) => void - initialValues?: Values + initialValues?: Share } - let { + const { initialValues, disabled = false, draftKey, @@ -35,10 +32,6 @@ onSubmit, }: Props = $props() - if (!initialValues) { - initialValues = draftKey?.get() - } - const autofocus = !isMobile && !disabled const uploading = writable(false) @@ -81,7 +74,9 @@ ed.chain().clearContent().run() } - let content = $state(initialValues?.content ?? "") + let content = $state( + initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""), + ) const onChange = (json: object) => { content = json @@ -103,6 +98,12 @@ onMount(async () => { const ed = await editor ed.view.dom.addEventListener("keydown", handleKeyDown) + + if (initialValues?.type === "file") { + ed.chain() + .addFile(initialValues.value, ed.state.selection.from + 1) + .run() + } }) onDestroy(async () => { diff --git a/src/app/components/EventMenu.svelte b/src/app/components/EventMenu.svelte index 38687b72..5790f75d 100644 --- a/src/app/components/EventMenu.svelte +++ b/src/app/components/EventMenu.svelte @@ -15,7 +15,7 @@ import Report from "@app/components/Report.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import PinboardSelect from "@app/components/PinboardSelect.svelte" - import {shareEventToChat} from "@app/share" + import {shareEvent} from "@app/share" import {deriveUserIsSpaceAdmin} from "@app/management" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" @@ -40,7 +40,7 @@ const addToLibrary = () => pushModal(PinboardSelect, {url, event}) - const share = () => shareEventToChat(url, noun, event) + const share = () => shareEvent(url, noun, event) const showDelete = () => pushModal(EventDeleteConfirm, {url, event}) diff --git a/src/app/components/PinMenu.svelte b/src/app/components/PinMenu.svelte index bfa69ea3..1246e8e0 100644 --- a/src/app/components/PinMenu.svelte +++ b/src/app/components/PinMenu.svelte @@ -13,7 +13,7 @@ import PinEdit from "@app/components/PinEdit.svelte" import {deletes} from "@app/core" import {deriveUserIsSpaceAdmin} from "@app/management" - import {shareEventToChat} from "@app/share" + import {shareEvent} from "@app/share" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" @@ -29,7 +29,7 @@ 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}) diff --git a/src/app/components/RelaySummary.svelte b/src/app/components/RelaySummary.svelte index 26d91f32..a468896b 100644 --- a/src/app/components/RelaySummary.svelte +++ b/src/app/components/RelaySummary.svelte @@ -30,10 +30,8 @@ {#if $userSpaceUrls.includes(url)} -
- +
+
{/if}
diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index ab47ff1b..86e2c9f9 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -7,6 +7,7 @@ import {debounce} from "throttle-debounce" import cx from "classnames" import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" + import type {Maybe} from "@welshman/lib" import type {TrustedEvent, EventContent} from "@welshman/util" import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util" import {publish} from "@welshman/app" @@ -16,7 +17,6 @@ import ChatRound from "@assets/icons/chat-round.svg?dataurl" import Close from "@assets/icons/close.svg?dataurl" import {fade, fly} from "@lib/transition" - import {popKey} from "@lib/implicit" import {documentActive} from "@lib/html" import Button from "@lib/components/Button.svelte" import Divider from "@lib/components/Divider.svelte" @@ -46,6 +46,7 @@ import {makeFeed} from "@app/feeds" import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications" import {makeRoomPath} from "@app/routes" + import {pendingShare, type Share} from "@app/share" import {pushToast} from "@app/toast" type Props = { @@ -183,7 +184,7 @@ } const onSubmit = async ({content, tags}: EventContent) => { - if (!content && !share) { + if (!content && !sharedEvent) { return } @@ -216,8 +217,8 @@ command.publishToRelays([url]) } - if (share) { - template = await prependParent(share, template, url) + if (sharedEvent) { + template = await prependParent(sharedEvent, template, url) } if (parent) { @@ -324,7 +325,7 @@ let isUserScrolling = $state(false) let loadingBackward = $state(true) let loadingForward = $state(true) - let share = $state(popKey("share")) + let share: Maybe = $state() let parent: TrustedEvent | undefined = $state() let element: HTMLElement | undefined = $state() let lastVisibleAt = now() @@ -338,6 +339,27 @@ let compose: RoomCompose | 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, () => { isUserScrolling = false }) @@ -619,21 +641,21 @@ {#if parent} {/if} - {#if share} - + {#if sharedEvent} + {/if} {#if eventToEdit} {/if} - {#key eventToEdit} + {#key initialValues} {/key} {/if} diff --git a/src/app/components/RoomCompose.svelte b/src/app/components/RoomCompose.svelte index 17e99704..385b6b3d 100644 --- a/src/app/components/RoomCompose.svelte +++ b/src/app/components/RoomCompose.svelte @@ -13,29 +13,22 @@ import ComposeMenu from "@app/components/ComposeMenu.svelte" import EditorContent from "@app/editor/EditorContent.svelte" 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" - type Values = { - content?: string | object - } - type Props = { url?: string h?: string onEscape?: () => void onEditPrevious?: () => 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(`room:${url ?? ""}:${h ?? ""}`) : undefined - - if (!initialValues) { - initialValues = draftKey?.get() - } + const draftKey = url || h ? new DraftKey(`room:${url ?? ""}:${h ?? ""}`) : undefined const autofocus = !isMobile @@ -76,7 +69,9 @@ } 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) => { content = json @@ -98,6 +93,12 @@ onMount(async () => { const ed = await editor ed.view.dom.addEventListener("keydown", handleKeyDown) + + if (initialValues?.type === "file") { + ed.chain() + .addFile(initialValues.value, ed.state.selection.from + 1) + .run() + } }) onDestroy(async () => { diff --git a/src/app/components/Share.svelte b/src/app/components/Share.svelte new file mode 100644 index 00000000..1e8d58ae --- /dev/null +++ b/src/app/components/Share.svelte @@ -0,0 +1,137 @@ + + + + + + Share + Where would you like to share this? + + +
+ {#if roomResults.length > 0} +

Rooms

+
+ {#each roomResults as { url, h } (makeRoomPath(url, h))} + {@const path = makeRoomPath(url, h)} + + {/each} +
+ {/if} + {#if chatResults.length > 0} +

Direct Messages

+
+ {#each chatResults as chat (chat.id)} + {@const path = makeChatPath(chat.pubkeys)} + {@const others = remove($user.pubkey, chat.pubkeys)} + + {/each} +
+ {/if} + {#if roomResults.length === 0 && chatResults.length === 0} +

+ {#if term} + Nothing matched your search. + {:else} + Join a space or start a conversation to have somewhere to share to. + {/if} +

+ {/if} +
+
+ + + + +
diff --git a/src/app/components/EventShare.svelte b/src/app/components/ShareEvent.svelte similarity index 92% rename from src/app/components/EventShare.svelte rename to src/app/components/ShareEvent.svelte index d1908b4e..94485147 100644 --- a/src/app/components/EventShare.svelte +++ b/src/app/components/ShareEvent.svelte @@ -1,10 +1,8 @@ diff --git a/src/routes/share/+page.svelte b/src/routes/share/+page.svelte new file mode 100644 index 00000000..2201efb0 --- /dev/null +++ b/src/routes/share/+page.svelte @@ -0,0 +1,14 @@ + diff --git a/src/routes/spaces/[relay]/library/[address]/+page.svelte b/src/routes/spaces/[relay]/library/[address]/+page.svelte index 136a9e9d..a79713a3 100644 --- a/src/routes/spaces/[relay]/library/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/library/[address]/+page.svelte @@ -29,7 +29,7 @@ import {app, deletes, network} from "@app/core" import {deriveUserIsSpaceAdmin} from "@app/management" import {decodeRelay} from "@app/relays" - import {shareEventToChat} from "@app/share" + import {shareEvent} from "@app/share" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" @@ -80,7 +80,7 @@ menuOpen = false if ($board) { - shareEventToChat(url, "Shelf", $board.event) + shareEvent(url, "Shelf", $board.event) } } diff --git a/vite.config.ts b/vite.config.ts index 752a5bed..81434ad6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,7 +13,9 @@ export default defineConfig({ // 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 // 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: { dedupe: [ "@tiptap/core",