├── .gitignore ├── .swiftlint.yml ├── ActionExtension-iOS ├── Action.js ├── ActionExtension-iOS.entitlements ├── ActionViewController.swift ├── ContentView.swift ├── Info.plist └── Media.xcassets │ ├── AppIconExtension.appiconset │ ├── Contents.json │ ├── icon-40.png │ ├── icon-40@2x-1.png │ ├── icon-40@2x.png │ ├── icon-40@3x.png │ ├── icon-60@2x.png │ ├── icon-60@3x.png │ ├── icon-76.png │ ├── icon-76@2x.png │ ├── icon-83.5@2x.png │ ├── icon-small.png │ ├── icon-small@2x-1.png │ ├── icon-small@2x.png │ ├── icon-small@3x.png │ ├── ios-marketing.png │ ├── notification-icon@2x.png │ ├── notification-icon@3x.png │ ├── notification-icon~ipad.png │ └── notification-icon~ipad@2x.png │ ├── Contents.json │ └── TouchBarBezel.colorset │ └── Contents.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── Shared ├── Account │ ├── AccountLoginView.swift │ ├── AccountLogoutView.swift │ ├── AccountModel.swift │ └── AccountView.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon-40.png │ │ ├── icon-40@2x-1.png │ │ ├── icon-40@2x.png │ │ ├── icon-40@3x.png │ │ ├── icon-60@2x.png │ │ ├── icon-60@3x.png │ │ ├── icon-76.png │ │ ├── icon-76@2x.png │ │ ├── icon-83.5@2x.png │ │ ├── icon-small-1.png │ │ ├── icon-small.png │ │ ├── icon-small@2x-1.png │ │ ├── icon-small@2x.png │ │ ├── icon-small@3x.png │ │ ├── icon.png │ │ ├── icon@2x.png │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ ├── icon_512x512@2x.png │ │ ├── ios-marketing.png │ │ ├── notification-icon@2x.png │ │ ├── notification-icon@3x.png │ │ ├── notification-icon~ipad.png │ │ └── notification-icon~ipad@2x.png │ ├── Contents.json │ └── does.not.exist.imageset │ │ ├── Contents.json │ │ ├── does.not.exist.png │ │ ├── does.not.exist@2x.png │ │ └── does.not.exist@3x.png ├── ErrorHandling │ ├── ErrorConstants.swift │ └── ErrorHandling.swift ├── Extensions │ ├── Bundle+AppVersion.swift │ ├── NSManagedObjectContext+ExecuteAndMergeChanges.swift │ ├── UserDefaults+Extensions.swift │ ├── WriteFreelyModel+API.swift │ ├── WriteFreelyModel+APIHandlers.swift │ └── WriteFreelyModel+Keychain.swift ├── LocalStorageManager.swift ├── Logging │ └── Logging.swift ├── Models │ ├── LocalStorageModel.xcdatamodeld │ │ └── LocalStorageModel.xcdatamodel │ │ │ └── contents │ ├── PostStatus.swift │ └── WriteFreelyModel.swift ├── Navigation │ ├── ContentView.swift │ ├── NoSelectedPostView.swift │ └── WFNavigation.swift ├── PostCollection │ ├── CollectionListView.swift │ └── CollectionPicker.swift ├── PostEditor │ ├── PostEditorModel.swift │ └── PostEditorStatusToolbarView.swift ├── PostList │ ├── PostCellView.swift │ ├── PostListFilteredView.swift │ ├── PostListModel.swift │ ├── PostListView.swift │ ├── PostStatusBadgeView.swift │ └── SearchablePostListFilteredView.swift ├── Preferences │ ├── PreferencesModel.swift │ └── PreferencesView.swift ├── Resources │ ├── Hack-Regular.ttf │ ├── Licenses │ │ ├── Hack-License.txt │ │ ├── Lora-Cyrillic-OFL.txt │ │ ├── OpenSans-License.txt │ │ └── Sparkle-License.txt │ ├── LoraGX.ttf │ └── OpenSans-Regular.ttf └── WriteFreely_MultiPlatformApp.swift ├── Technotes ├── EditorLaunchingPolicy.md └── MacSoftwareUpdater.md ├── Tests iOS ├── Info.plist └── Tests_iOS.swift ├── Tests macOS ├── Info.plist └── Tests_macOS.swift ├── WFACollection+CoreDataClass.swift ├── WFACollection+CoreDataProperties.swift ├── WFAPost+CoreDataClass.swift ├── WFAPost+CoreDataProperties.swift ├── WriteFreely-MultiPlatform (iOS).entitlements ├── WriteFreely-MultiPlatform.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── iOS ├── Extensions │ ├── EnvironmentValues+Extensions.swift │ ├── UIHostingView.swift │ └── View+Keyboard.swift ├── Info.plist ├── LaunchScreen.storyboard ├── PostEditor │ ├── MultilineTextView.swift │ ├── PostEditorView.swift │ ├── PostTextEditingView.swift │ └── RemoteChangePromptView.swift ├── PrivacyInfo.xcprivacy └── Settings │ ├── SettingsHeaderView.swift │ └── SettingsView.swift └── macOS ├── AppDelegate.swift ├── Credits.rtf ├── Info.plist ├── Navigation ├── ActivePostToolbarView.swift ├── HelpCommands.swift └── PostCommands.swift ├── PostEditor ├── MacEditorTextView.swift ├── PostEditorSharingPicker.swift ├── PostEditorView.swift └── PostTextEditingView.swift ├── Settings ├── MacAccountView.swift ├── MacPreferencesView.swift ├── MacUpdatesView.swift ├── MacUpdatesViewModel.swift └── SettingsView.swift └── macOS.entitlements /.gitignore: -------------------------------------------------------------------------------- 1 | ### Swift ### 2 | # Xcode 3 | # 4 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 5 | 6 | ## User settings 7 | xcuserdata/ 8 | 9 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 10 | *.xcscmblueprint 11 | *.xccheckout 12 | 13 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 14 | build/ 15 | DerivedData/ 16 | *.moved-aside 17 | *.pbxuser 18 | !default.pbxuser 19 | *.mode1v3 20 | !default.mode1v3 21 | *.mode2v3 22 | !default.mode2v3 23 | *.perspectivev3 24 | !default.perspectivev3 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | 29 | ## App packaging 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 45 | # hence it is not needed unless you have added a package configuration file to your project 46 | # .swiftpm 47 | 48 | .build/ 49 | 50 | # CocoaPods 51 | # We recommend against adding the Pods directory to your .gitignore. However 52 | # you should judge for yourself, the pros and cons are mentioned at: 53 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 54 | # Pods/ 55 | # Add this line if you want to avoid checking in source code from the Xcode workspace 56 | # *.xcworkspace 57 | 58 | # Carthage 59 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 60 | # Carthage/Checkouts 61 | 62 | Carthage/Build/ 63 | 64 | # Accio dependency management 65 | Dependencies/ 66 | .accio/ 67 | 68 | # fastlane 69 | # It is recommended to not store the screenshots in the git repo. 70 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 71 | # For more information about the recommended setup visit: 72 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 73 | 74 | fastlane/report.xml 75 | fastlane/Preview.html 76 | fastlane/screenshots/**/*.png 77 | fastlane/test_output 78 | 79 | # Code Injection 80 | # After new code Injection tools there's a generated folder /iOSInjectionProject 81 | # https://github.com/johnno1962/injectionforxcode 82 | 83 | iOSInjectionProject/ 84 | 85 | ### SwiftPackageManager ### 86 | Packages 87 | xcuserdata 88 | *.xcodeproj 89 | 90 | 91 | ### Xcode ### 92 | # Xcode 93 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 94 | 95 | 96 | 97 | 98 | ## Gcc Patch 99 | /*.gcno 100 | 101 | ### Xcode Patch ### 102 | *.xcodeproj/* 103 | !*.xcodeproj/project.pbxproj 104 | !*.xcodeproj/xcshareddata/ 105 | !*.xcworkspace/contents.xcworkspacedata 106 | **/xcshareddata/WorkspaceSettings.xcsettings 107 | /WriteFreely-MultiPlatform.xcodeproj/xcuserdata/angelo.xcuserdatad/xcschemes/xcschememanagement.plist 108 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | type_name: 2 | allowed_symbols: ["_"] # Used in SwiftUI boilerplate naming 3 | identifier_name: 4 | excluded: ["id"] # Required for Identifiable conformance 5 | -------------------------------------------------------------------------------- /ActionExtension-iOS/Action.js: -------------------------------------------------------------------------------- 1 | var Action = function() {}; 2 | 3 | Action.prototype = { 4 | 5 | run: function(parameters) { 6 | parameters.completionFunction({ 7 | "URL": document.URL, 8 | "title": document.title, 9 | "selection": document.getSelection().toString() 10 | }); 11 | }, 12 | 13 | finalize: function(parameters) { 14 | var customJavaScript = parameters["customJavaScript"]; 15 | eval(customJavaScript); 16 | } 17 | 18 | }; 19 | 20 | var ExtensionPreprocessingJS = new Action 21 | -------------------------------------------------------------------------------- /ActionExtension-iOS/ActionExtension-iOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.abunchtell.writefreely 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ActionExtension-iOS/ActionViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class ActionViewController: UIViewController { 4 | 5 | let moc = LocalStorageManager.standard.container.viewContext 6 | 7 | override var prefersStatusBarHidden: Bool { true } 8 | 9 | override func viewDidLoad() { 10 | super.viewDidLoad() 11 | 12 | let contentView = ContentView() 13 | .environment(\.extensionContext, extensionContext) 14 | .environment(\.managedObjectContext, moc) 15 | 16 | view = UIHostingView(rootView: contentView) 17 | view.isOpaque = true 18 | view.backgroundColor = .systemBackground 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /ActionExtension-iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | NSExtensionActivationRule 10 | 11 | NSExtensionActivationSupportsWebPageWithMaxCount 12 | 1 13 | 14 | NSExtensionJavaScriptPreprocessingFile 15 | Action 16 | NSExtensionServiceAllowsFinderPreviewItem 17 | 18 | NSExtensionServiceAllowsTouchBarItem 19 | 20 | NSExtensionServiceFinderPreviewIconName 21 | NSActionTemplate 22 | NSExtensionServiceTouchBarBezelColorName 23 | TouchBarBezel 24 | NSExtensionServiceTouchBarIconName 25 | NSActionTemplate 26 | 27 | NSExtensionPointIdentifier 28 | com.apple.ui-services 29 | NSExtensionPrincipalClass 30 | $(PRODUCT_MODULE_NAME).ActionViewController 31 | 32 | UIAppFonts 33 | 34 | LoraGX.ttf 35 | OpenSans-Regular.ttf 36 | Hack-Regular.ttf 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "notification-icon@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "notification-icon@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "icon-small@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "icon-small@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "icon-40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "icon-40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "icon-60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "icon-60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "notification-icon~ipad.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "notification-icon~ipad@2x.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "icon-small.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "icon-small@2x-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "icon-40.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "icon-40@2x-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "icon-76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "icon-76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "icon-83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "ios-marketing.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40@2x-1.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40@2x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-40@3x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-60@2x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-60@3x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-76.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-76@2x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-83.5@2x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small@2x-1.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small@2x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/icon-small@3x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/ios-marketing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/ios-marketing.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon@2x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon@3x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon~ipad.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon~ipad@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/ActionExtension-iOS/Media.xcassets/AppIconExtension.appiconset/notification-icon~ipad@2x.png -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ActionExtension-iOS/Media.xcassets/TouchBarBezel.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "mac", 9 | "color" : { 10 | "reference" : "systemPurpleColor" 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [hello@write.as](mailto:hello@write.as). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 121 | 122 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 123 | enforcement ladder](https://github.com/mozilla/diversity). 124 | 125 | [homepage]: https://www.contributor-covenant.org 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | https://www.contributor-covenant.org/faq. Translations are available at 129 | https://www.contributor-covenant.org/translations. 130 | 131 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We're happy you're considering contributing to the WriteFreely SwiftUI multiplatform app! 4 | 5 | Before making a contribution, please be sure to familiarize yourself with our contributor's [code of conduct](CODE_OF_CONDUCT.md). 6 | 7 | Otherwise, it won't take long to get up to speed on this. Here are our development resources: 8 | 9 | * We accept and respond to bugs here on [GitHub](https://github.com/writeas/writefreely-swiftui-multiplatform/issues). 10 | * We're usually in #writeas on freenode, but if not, find us on our [Slack channel](http://slack.write.as). 11 | 12 | ## Testing 13 | 14 | We try to write tests for all public methods in the codebase, but aren't there yet. While not required, including tests with your new code will bring us closer to where we want to be and speed up our review. 15 | 16 | ## Submitting changes 17 | 18 | Please send a [pull request](https://github.com/writeas/writefreely-swiftui-multiplatform/compare) with a clear list of what you've done. 19 | 20 | Please follow our coding conventions below and make sure all of your commits are atomic. Larger changes should have commits with more detailed information on what changed, any impact on existing code, rationales, etc. 21 | 22 | ## Coding conventions 23 | 24 | We strive for consistency above all. Reading the codebase should give you a good idea of the conventions we follow. 25 | 26 | * We use [SwiftLint](https://github.com/realm/SwiftLint) as a build script 27 | * We fix all warnings before committing anything (including linting warnings!) 28 | * We aim to document all public methods using Swift code documentation 29 | * Swift files are broken up into logical functional components 30 | 31 | ## Design conventions 32 | 33 | We maintain a few high-level design principles in all decisions we make. Keep these in mind while devising new functionality: 34 | 35 | * Updates should be backwards compatible or provide a seamless migration path from *any* previous version 36 | * Each method should perform one action and do it well 37 | * Each method will ideally work well in a script 38 | * Avoid clever functionality and assume each function will be used in ways we didn't imagine 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WriteFreely SwiftUI MultiPlatform Client 2 | 3 | A multiplatform (iOS, iPadOS, and macOS) client for [WriteFreely](https://writefreely.org/), built in SwiftUI. 4 | 5 | ## How To Get The Apps 6 | 7 | The iOS app is now [available on the App Store](https://apps.apple.com/us/app/writefreely/id1531530896) for iPhones and iPads running iOS 14. Check out [this help forum topic](https://discuss.write.as/t/using-the-writefreely-ios-app/1946) for a guide on using the app. 8 | 9 | The Mac app is still under development. 10 | 11 | ## Getting Started 12 | 13 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. 14 | 15 | ### Prerequisites 16 | 17 | ⚠️ Building and testing the iOS targets will work on any version of macOS that supports Xcode 12, but building and testing the macOS target requires macOS 11 (Big Sur). 18 | 19 | - Xcode 12 20 | - [SwiftLint](https://github.com/realm/SwiftLint) 21 | 22 | SwiftLint is run as a build phase for all targets, so that linting warnings and errors are shown in Xcode. 23 | 24 | ## Running the tests 25 | 26 | To run the tests, select the scheme you want to test (iOS or macOS) and choose **Product** → **Test** from the Xcode menu. 27 | 28 | ## Contributing 29 | 30 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 31 | 32 | Need help getting started? Find out more about the project's [office hours](https://discuss.write.as/t/office-hours-for-writefreely-swift-projects/2788). 33 | 34 | ## Versioning 35 | 36 | We use [SemVer](https://semver.org/) for versioning and track changes in [CHANGELOG.md](CHANGELOG.md). For the versions available, see the 37 | [tags on this repository](https://github.com/writeas/writefreely-swiftui-multiplatform/tags). 38 | 39 | ## Authors 40 | 41 | - **Angelo Stavrow** - _Initial work_ - [AngeloStavrow](https://github.com/AngeloStavrow) 42 | 43 | See also the list of [contributors](https://github.com/writeas/writefreely-swiftui-multiplatform/contributors) who participated in this project. 44 | 45 | ## License 46 | 47 | This project is licensed under the GPL v3 License. See the [LICENSE.md](LICENSE.md) file for details. 48 | -------------------------------------------------------------------------------- /Shared/Account/AccountLoginView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AccountLoginView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @EnvironmentObject var errorHandling: ErrorHandling 6 | 7 | @State private var alertMessage: String = "" 8 | @State private var username: String = "" 9 | @State private var password: String = "" 10 | @State private var server: String = "" 11 | var body: some View { 12 | VStack { 13 | Text("Log in to publish and share your posts.") 14 | .font(.caption) 15 | .foregroundColor(.secondary) 16 | HStack { 17 | Image(systemName: "person.circle") 18 | .foregroundColor(.gray) 19 | #if os(iOS) 20 | TextField("Username", text: $username) 21 | .autocapitalization(.none) 22 | .disableAutocorrection(true) 23 | .textFieldStyle(RoundedBorderTextFieldStyle()) 24 | #else 25 | TextField("Username", text: $username) 26 | #endif 27 | } 28 | HStack { 29 | Image(systemName: "lock.circle") 30 | .foregroundColor(.gray) 31 | #if os(iOS) 32 | SecureField("Password", text: $password) 33 | .autocapitalization(.none) 34 | .disableAutocorrection(true) 35 | .textFieldStyle(RoundedBorderTextFieldStyle()) 36 | #else 37 | SecureField("Password", text: $password) 38 | #endif 39 | } 40 | HStack { 41 | Image(systemName: "link.circle") 42 | .foregroundColor(.gray) 43 | #if os(iOS) 44 | TextField("Server URL", text: $server) 45 | .keyboardType(.URL) 46 | .autocapitalization(.none) 47 | .disableAutocorrection(true) 48 | .textFieldStyle(RoundedBorderTextFieldStyle()) 49 | #else 50 | TextField("Server URL", text: $server) 51 | #endif 52 | } 53 | Spacer() 54 | if model.isLoggingIn { 55 | ProgressView("Logging in...") 56 | .padding() 57 | } else { 58 | Button(action: { 59 | #if os(iOS) 60 | hideKeyboard() 61 | #endif 62 | // If the server string is not prefixed with a scheme, prepend "https://" to it. 63 | if !(server.hasPrefix("https://") || server.hasPrefix("http://")) { 64 | server = "https://\(server)" 65 | } 66 | // We only need the protocol and host from the URL, so drop anything else. 67 | let url = URLComponents(string: server) 68 | if let validURL = url { 69 | let scheme = validURL.scheme 70 | let host = validURL.host 71 | var hostURL = URLComponents() 72 | hostURL.scheme = scheme 73 | hostURL.host = host 74 | server = hostURL.string ?? server 75 | model.login( 76 | to: URL(string: server)!, 77 | as: username, password: password 78 | ) 79 | } else { 80 | self.errorHandling.handle(error: AccountError.invalidServerURL) 81 | } 82 | }, label: { 83 | Text("Log In") 84 | }) 85 | .disabled( 86 | model.account.isLoggedIn || (username.isEmpty || password.isEmpty || server.isEmpty) 87 | ) 88 | .padding() 89 | } 90 | } 91 | .onChange(of: model.hasError) { value in 92 | if value { 93 | if let error = model.currentError { 94 | self.errorHandling.handle(error: error) 95 | } else { 96 | self.errorHandling.handle(error: AppError.genericError()) 97 | } 98 | model.hasError = false 99 | } 100 | } 101 | } 102 | } 103 | 104 | struct AccountLoginView_Previews: PreviewProvider { 105 | static var previews: some View { 106 | AccountLoginView() 107 | .environmentObject(WriteFreelyModel()) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Shared/Account/AccountLogoutView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AccountLogoutView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @EnvironmentObject var errorHandling: ErrorHandling 6 | 7 | @State private var isPresentingLogoutConfirmation: Bool = false 8 | @State private var editedPostsWarningString: String = "" 9 | 10 | var body: some View { 11 | #if os(iOS) 12 | VStack { 13 | Spacer() 14 | VStack { 15 | Text("Logged in as \(model.account.username)") 16 | Text("on \(model.account.server)") 17 | } 18 | Spacer() 19 | Button(action: logoutHandler, label: { 20 | Text("Log Out") 21 | }) 22 | } 23 | .actionSheet(isPresented: $isPresentingLogoutConfirmation, content: { 24 | ActionSheet( 25 | title: Text("Log Out?"), 26 | message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"), 27 | buttons: [ 28 | .destructive(Text("Log Out"), action: { 29 | model.logout() 30 | }), 31 | .cancel() 32 | ] 33 | ) 34 | }) 35 | #else 36 | VStack { 37 | Spacer() 38 | VStack { 39 | Text("Logged in as \(model.account.username)") 40 | Text("on \(model.account.server)") 41 | } 42 | Spacer() 43 | Button(action: logoutHandler, label: { 44 | Text("Log Out") 45 | }) 46 | } 47 | .alert(isPresented: $isPresentingLogoutConfirmation) { 48 | Alert( 49 | title: Text("Log Out?"), 50 | message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"), 51 | primaryButton: .cancel(Text("Cancel"), action: { self.isPresentingLogoutConfirmation = false }), 52 | secondaryButton: .destructive(Text("Log Out"), action: model.logout ) 53 | ) 54 | } 55 | #endif 56 | } 57 | 58 | func logoutHandler() { 59 | let request = WFAPost.createFetchRequest() 60 | request.predicate = NSPredicate(format: "status == %i", 1) 61 | do { 62 | let editedPosts = try LocalStorageManager.standard.container.viewContext.fetch(request) 63 | if editedPosts.count == 1 { 64 | editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited post. " 65 | } 66 | if editedPosts.count > 1 { 67 | editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited posts. " 68 | } 69 | } catch { 70 | self.errorHandling.handle(error: LocalStoreError.couldNotFetchPosts("cached")) 71 | } 72 | self.isPresentingLogoutConfirmation = true 73 | } 74 | } 75 | 76 | struct AccountLogoutView_Previews: PreviewProvider { 77 | static var previews: some View { 78 | AccountLogoutView() 79 | .environmentObject(WriteFreelyModel()) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Shared/Account/AccountModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import WriteFreely 3 | 4 | struct AccountModel { 5 | @AppStorage(WFDefaults.isLoggedIn, store: UserDefaults.shared) var isLoggedIn: Bool = false 6 | private let defaults = UserDefaults.shared 7 | 8 | var server: String = "" 9 | var username: String = "" 10 | 11 | private(set) var user: WFUser? 12 | 13 | mutating func login(_ user: WFUser) { 14 | self.user = user 15 | self.username = user.username ?? "" 16 | self.isLoggedIn = true 17 | defaults.set(user.username, forKey: WFDefaults.usernameStringKey) 18 | defaults.set(server, forKey: WFDefaults.serverStringKey) 19 | } 20 | 21 | mutating func logout() { 22 | self.user = nil 23 | self.isLoggedIn = false 24 | defaults.removeObject(forKey: WFDefaults.usernameStringKey) 25 | defaults.removeObject(forKey: WFDefaults.serverStringKey) 26 | } 27 | 28 | mutating func restoreState() { 29 | server = defaults.string(forKey: WFDefaults.serverStringKey) ?? "" 30 | username = defaults.string(forKey: WFDefaults.usernameStringKey) ?? "" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Shared/Account/AccountView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AccountView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @EnvironmentObject var errorHandling: ErrorHandling 6 | 7 | var body: some View { 8 | if model.account.isLoggedIn { 9 | HStack { 10 | Spacer() 11 | AccountLogoutView() 12 | .withErrorHandling() 13 | Spacer() 14 | } 15 | .padding() 16 | } else { 17 | AccountLoginView() 18 | .withErrorHandling() 19 | .padding(.top) 20 | } 21 | EmptyView() 22 | .onChange(of: model.hasError) { value in 23 | if value { 24 | if let error = model.currentError { 25 | self.errorHandling.handle(error: error) 26 | } else { 27 | self.errorHandling.handle(error: AppError.genericError()) 28 | } 29 | model.hasError = false 30 | } 31 | } 32 | } 33 | } 34 | 35 | struct AccountLogin_Previews: PreviewProvider { 36 | static var previews: some View { 37 | AccountView() 38 | .environmentObject(WriteFreelyModel()) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "notification-icon@2x.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "notification-icon@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "icon-small.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "icon-small@2x.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "icon-small@3x.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "icon-40@2x-1.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "icon-40@3x.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "icon.png", 47 | "idiom" : "iphone", 48 | "scale" : "1x", 49 | "size" : "57x57" 50 | }, 51 | { 52 | "filename" : "icon@2x.png", 53 | "idiom" : "iphone", 54 | "scale" : "2x", 55 | "size" : "57x57" 56 | }, 57 | { 58 | "filename" : "icon-60@2x.png", 59 | "idiom" : "iphone", 60 | "scale" : "2x", 61 | "size" : "60x60" 62 | }, 63 | { 64 | "filename" : "icon-60@3x.png", 65 | "idiom" : "iphone", 66 | "scale" : "3x", 67 | "size" : "60x60" 68 | }, 69 | { 70 | "filename" : "notification-icon~ipad.png", 71 | "idiom" : "ipad", 72 | "scale" : "1x", 73 | "size" : "20x20" 74 | }, 75 | { 76 | "filename" : "notification-icon~ipad@2x.png", 77 | "idiom" : "ipad", 78 | "scale" : "2x", 79 | "size" : "20x20" 80 | }, 81 | { 82 | "filename" : "icon-small-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "1x", 85 | "size" : "29x29" 86 | }, 87 | { 88 | "filename" : "icon-small@2x-1.png", 89 | "idiom" : "ipad", 90 | "scale" : "2x", 91 | "size" : "29x29" 92 | }, 93 | { 94 | "filename" : "icon-40.png", 95 | "idiom" : "ipad", 96 | "scale" : "1x", 97 | "size" : "40x40" 98 | }, 99 | { 100 | "filename" : "icon-40@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "40x40" 104 | }, 105 | { 106 | "filename" : "icon-76.png", 107 | "idiom" : "ipad", 108 | "scale" : "1x", 109 | "size" : "76x76" 110 | }, 111 | { 112 | "filename" : "icon-76@2x.png", 113 | "idiom" : "ipad", 114 | "scale" : "2x", 115 | "size" : "76x76" 116 | }, 117 | { 118 | "filename" : "icon-83.5@2x.png", 119 | "idiom" : "ipad", 120 | "scale" : "2x", 121 | "size" : "83.5x83.5" 122 | }, 123 | { 124 | "filename" : "ios-marketing.png", 125 | "idiom" : "ios-marketing", 126 | "scale" : "1x", 127 | "size" : "1024x1024" 128 | }, 129 | { 130 | "filename" : "icon_16x16.png", 131 | "idiom" : "mac", 132 | "scale" : "1x", 133 | "size" : "16x16" 134 | }, 135 | { 136 | "filename" : "icon_16x16@2x.png", 137 | "idiom" : "mac", 138 | "scale" : "2x", 139 | "size" : "16x16" 140 | }, 141 | { 142 | "filename" : "icon_32x32.png", 143 | "idiom" : "mac", 144 | "scale" : "1x", 145 | "size" : "32x32" 146 | }, 147 | { 148 | "filename" : "icon_32x32@2x.png", 149 | "idiom" : "mac", 150 | "scale" : "2x", 151 | "size" : "32x32" 152 | }, 153 | { 154 | "filename" : "icon_128x128.png", 155 | "idiom" : "mac", 156 | "scale" : "1x", 157 | "size" : "128x128" 158 | }, 159 | { 160 | "filename" : "icon_128x128@2x.png", 161 | "idiom" : "mac", 162 | "scale" : "2x", 163 | "size" : "128x128" 164 | }, 165 | { 166 | "filename" : "icon_256x256.png", 167 | "idiom" : "mac", 168 | "scale" : "1x", 169 | "size" : "256x256" 170 | }, 171 | { 172 | "filename" : "icon_256x256@2x.png", 173 | "idiom" : "mac", 174 | "scale" : "2x", 175 | "size" : "256x256" 176 | }, 177 | { 178 | "filename" : "icon_512x512.png", 179 | "idiom" : "mac", 180 | "scale" : "1x", 181 | "size" : "512x512" 182 | }, 183 | { 184 | "filename" : "icon_512x512@2x.png", 185 | "idiom" : "mac", 186 | "scale" : "2x", 187 | "size" : "512x512" 188 | } 189 | ], 190 | "info" : { 191 | "author" : "xcode", 192 | "version" : 1 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-40.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-40@2x-1.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-76.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-small-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-small-1.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-small.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-small@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-small@2x-1.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-small@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon-small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon-small@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/ios-marketing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/ios-marketing.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/notification-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/notification-icon@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/notification-icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/notification-icon@3x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/AppIcon.appiconset/notification-icon~ipad@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/does.not.exist.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "does.not.exist.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "does.not.exist@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "does.not.exist@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@2x.png -------------------------------------------------------------------------------- /Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Assets.xcassets/does.not.exist.imageset/does.not.exist@3x.png -------------------------------------------------------------------------------- /Shared/ErrorHandling/ErrorConstants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Network Errors 4 | 5 | enum NetworkError: Error { 6 | case noConnectionError 7 | } 8 | 9 | extension NetworkError: LocalizedError { 10 | public var errorDescription: String? { 11 | switch self { 12 | case .noConnectionError: 13 | return NSLocalizedString( 14 | "There is no internet connection at the moment. Please reconnect or try again later.", 15 | comment: "" 16 | ) 17 | } 18 | } 19 | } 20 | 21 | // MARK: - Keychain Errors 22 | 23 | enum KeychainError: Error { 24 | case couldNotStoreAccessToken 25 | case couldNotPurgeAccessToken 26 | case couldNotFetchAccessToken 27 | } 28 | 29 | extension KeychainError: LocalizedError { 30 | public var errorDescription: String? { 31 | switch self { 32 | case .couldNotStoreAccessToken: 33 | return NSLocalizedString("There was a problem storing your access token in the Keychain.", comment: "") 34 | case .couldNotPurgeAccessToken: 35 | return NSLocalizedString("Something went wrong purging the token from the Keychain.", comment: "") 36 | case .couldNotFetchAccessToken: 37 | return NSLocalizedString("Something went wrong fetching the token from the Keychain.", comment: "") 38 | } 39 | } 40 | } 41 | 42 | // MARK: - Account Errors 43 | 44 | enum AccountError: Error { 45 | case invalidPassword 46 | case usernameNotFound 47 | case serverNotFound 48 | case invalidServerURL 49 | case unknownLoginError 50 | case genericAuthError 51 | } 52 | 53 | extension AccountError: LocalizedError { 54 | public var errorDescription: String? { 55 | switch self { 56 | case .serverNotFound: 57 | return NSLocalizedString( 58 | "The server could not be found. Please check the information you've entered and try again.", 59 | comment: "" 60 | ) 61 | case .invalidPassword: 62 | return NSLocalizedString( 63 | "Invalid password. Please check that you've entered your password correctly and try logging in again.", 64 | comment: "" 65 | ) 66 | case .usernameNotFound: 67 | return NSLocalizedString( 68 | "Username not found. Did you use your email address by mistake?", 69 | comment: "" 70 | ) 71 | case .invalidServerURL: 72 | return NSLocalizedString( 73 | "Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length 74 | comment: "" 75 | ) 76 | case .genericAuthError: 77 | return NSLocalizedString("Something went wrong, please try logging in again.", comment: "") 78 | case .unknownLoginError: 79 | return NSLocalizedString("An unknown error occurred while trying to login.", comment: "") 80 | } 81 | } 82 | } 83 | 84 | // MARK: - User Defaults Errors 85 | 86 | enum UserDefaultsError: Error { 87 | case couldNotMigrateStandardDefaults 88 | } 89 | 90 | extension UserDefaultsError: LocalizedError { 91 | public var errorDescription: String? { 92 | switch self { 93 | case .couldNotMigrateStandardDefaults: 94 | return NSLocalizedString("Could not migrate user defaults to group container", comment: "") 95 | } 96 | } 97 | } 98 | 99 | // MARK: - Local Store Errors 100 | 101 | enum LocalStoreError: Error { 102 | case couldNotSaveContext 103 | case couldNotFetchCollections 104 | case couldNotFetchPosts(String = "") 105 | case couldNotPurgePosts(String = "") 106 | case couldNotPurgeCollections 107 | case couldNotLoadStore(String) 108 | case couldNotMigrateStore(String) 109 | case couldNotDeleteStoreAfterMigration(String) 110 | case genericError(String = "") 111 | } 112 | 113 | extension LocalStoreError: LocalizedError { 114 | public var errorDescription: String? { 115 | switch self { 116 | case .couldNotSaveContext: 117 | return NSLocalizedString("Error saving context", comment: "") 118 | case .couldNotFetchCollections: 119 | return NSLocalizedString("Failed to fetch blogs from local store.", comment: "") 120 | case .couldNotFetchPosts(let postFilter): 121 | if postFilter.isEmpty { 122 | return NSLocalizedString("Failed to fetch posts from local store.", comment: "") 123 | } else { 124 | return NSLocalizedString("Failed to fetch \(postFilter) posts from local store.", comment: "") 125 | } 126 | case .couldNotPurgePosts(let postFilter): 127 | if postFilter.isEmpty { 128 | return NSLocalizedString("Failed to purge \(postFilter) posts from local store.", comment: "") 129 | } else { 130 | return NSLocalizedString("Failed to purge posts from local store.", comment: "") 131 | } 132 | case .couldNotPurgeCollections: 133 | return NSLocalizedString("Failed to purge cached collections", comment: "") 134 | case .couldNotLoadStore(let errorDescription): 135 | return NSLocalizedString("Something went wrong loading local store: \(errorDescription)", comment: "") 136 | case .couldNotMigrateStore(let errorDescription): 137 | return NSLocalizedString("Something went wrong migrating local store: \(errorDescription)", comment: "") 138 | case .couldNotDeleteStoreAfterMigration(let errorDescription): 139 | return NSLocalizedString("Something went wrong deleting old store: \(errorDescription)", comment: "") 140 | case .genericError(let customContent): 141 | if customContent.isEmpty { 142 | return NSLocalizedString("Something went wrong accessing device storage", comment: "") 143 | } else { 144 | return NSLocalizedString(customContent, comment: "") 145 | } 146 | } 147 | } 148 | } 149 | 150 | // MARK: - Application Errors 151 | 152 | enum AppError: Error { 153 | case couldNotGetLoggedInClient 154 | case couldNotGetPostId 155 | case genericError(String = "") 156 | } 157 | 158 | extension AppError: LocalizedError { 159 | public var errorDescription: String? { 160 | switch self { 161 | case .couldNotGetLoggedInClient: 162 | return NSLocalizedString("Something went wrong trying to access the WriteFreely client.", comment: "") 163 | case .couldNotGetPostId: 164 | return NSLocalizedString("Something went wrong trying to get the post's unique ID.", comment: "") 165 | case .genericError(let customContent): 166 | if customContent.isEmpty { 167 | return NSLocalizedString("Something went wrong", comment: "") 168 | } else { 169 | return NSLocalizedString(customContent, comment: "") 170 | } 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Shared/ErrorHandling/ErrorHandling.swift: -------------------------------------------------------------------------------- 1 | // Based on https://www.ralfebert.com/swiftui/generic-error-handling/ 2 | 3 | import SwiftUI 4 | 5 | struct ErrorAlert: Identifiable { 6 | var id = UUID() 7 | var message: String 8 | var dismissAction: (() -> Void)? 9 | } 10 | 11 | class ErrorHandling: ObservableObject { 12 | @Published var currentAlert: ErrorAlert? 13 | 14 | func handle(error: Error) { 15 | currentAlert = ErrorAlert(message: error.localizedDescription) 16 | } 17 | } 18 | 19 | struct HandleErrorByShowingAlertViewModifier: ViewModifier { 20 | @StateObject var errorHandling = ErrorHandling() 21 | 22 | func body(content: Content) -> some View { 23 | content 24 | .environmentObject(errorHandling) 25 | .background( 26 | EmptyView() 27 | .alert(item: $errorHandling.currentAlert) { currentAlert in 28 | Alert(title: Text("Error"), 29 | message: Text(currentAlert.message), 30 | dismissButton: .default(Text("OK")) { 31 | currentAlert.dismissAction?() 32 | }) 33 | } 34 | ) 35 | } 36 | } 37 | 38 | extension View { 39 | func withErrorHandling() -> some View { 40 | modifier(HandleErrorByShowingAlertViewModifier()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Shared/Extensions/Bundle+AppVersion.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private struct InfoPlistConstants { 4 | static let versionNumber = "CFBundleShortVersionString" 5 | static let buildNumber = "CFBundleVersion" 6 | } 7 | 8 | extension Bundle { 9 | public var appMarketingVersion: String { 10 | guard let result = infoDictionary?[InfoPlistConstants.versionNumber] as? String else { 11 | return "⚠️" 12 | } 13 | return result 14 | } 15 | 16 | public var appBuildVersion: String { 17 | guard let result = infoDictionary?[InfoPlistConstants.buildNumber] as? String else { 18 | return "⚠️" 19 | } 20 | return result 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Shared/Extensions/NSManagedObjectContext+ExecuteAndMergeChanges.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | extension NSManagedObjectContext { 4 | /// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given 5 | /// managed object context up to date. 6 | /// 7 | /// Credit: https://www.avanderlee.com/swift/nsbatchdeleterequest-core-data/ 8 | /// 9 | /// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute. 10 | /// - Throws: An error if anything went wrong executing the batch deletion. 11 | public func executeAndMergeChanges(using batchDeleteRequest: NSBatchDeleteRequest) throws { 12 | batchDeleteRequest.resultType = .resultTypeObjectIDs 13 | let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult 14 | let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []] 15 | NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self]) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Shared/Extensions/UserDefaults+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum WFDefaults { 4 | static let isLoggedIn = "isLoggedIn" 5 | static let showAllPostsFlag = "showAllPostsFlag" 6 | static let selectedCollectionURL = "selectedCollectionURL" 7 | static let lastDraftURL = "lastDraftURL" 8 | static let colorSchemeIntegerKey = "colorSchemeIntegerKey" 9 | static let defaultFontIntegerKey = "defaultFontIntegerKey" 10 | static let usernameStringKey = "usernameStringKey" 11 | static let serverStringKey = "serverStringKey" 12 | #if os(macOS) 13 | static let automaticallyChecksForUpdates = "automaticallyChecksForUpdates" 14 | static let subscribeToBetaUpdates = "subscribeToBetaUpdates" 15 | #endif 16 | static let didHaveFatalError = "didHaveFatalError" 17 | static let fatalErrorDescription = "fatalErrorDescription" 18 | } 19 | 20 | extension UserDefaults { 21 | 22 | private static let appGroupName: String = "group.com.abunchtell.writefreely" 23 | private static let didMigrateDefaultsToAppGroup: String = "didMigrateDefaultsToAppGroup" 24 | private static let didRemoveStandardDefaults: String = "didRemoveStandardDefaults" 25 | 26 | static var shared: UserDefaults { 27 | if let groupDefaults = UserDefaults(suiteName: UserDefaults.appGroupName), 28 | groupDefaults.bool(forKey: UserDefaults.didMigrateDefaultsToAppGroup) { 29 | return groupDefaults 30 | } else { 31 | do { 32 | let groupDefaults = try UserDefaults.standard.migrateDefaultsToAppGroup() 33 | return groupDefaults 34 | } catch { 35 | return UserDefaults.standard 36 | } 37 | } 38 | } 39 | 40 | private func migrateDefaultsToAppGroup() throws -> UserDefaults { 41 | let userDefaults = UserDefaults.standard 42 | let groupDefaults = UserDefaults(suiteName: UserDefaults.appGroupName) 43 | 44 | if let groupDefaults = groupDefaults { 45 | if groupDefaults.bool(forKey: UserDefaults.didMigrateDefaultsToAppGroup) { 46 | return groupDefaults 47 | } 48 | 49 | for (key, value) in userDefaults.dictionaryRepresentation() { 50 | groupDefaults.set(value, forKey: key) 51 | } 52 | groupDefaults.set(true, forKey: UserDefaults.didMigrateDefaultsToAppGroup) 53 | return groupDefaults 54 | } else { 55 | throw UserDefaultsError.couldNotMigrateStandardDefaults 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /Shared/Extensions/WriteFreelyModel+Keychain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension WriteFreelyModel { 4 | 5 | func saveTokenToKeychain(_ token: String, username: String?, server: String) throws { 6 | let query: [String: Any] = [ 7 | kSecClass as String: kSecClassGenericPassword, 8 | kSecValueData as String: token.data(using: .utf8)!, 9 | kSecAttrAccount as String: username ?? "anonymous", 10 | kSecAttrService as String: server 11 | ] 12 | let status = SecItemAdd(query as CFDictionary, nil) 13 | guard status == errSecDuplicateItem || status == errSecSuccess else { 14 | throw KeychainError.couldNotStoreAccessToken 15 | } 16 | } 17 | 18 | func purgeTokenFromKeychain(username: String?, server: String) throws { 19 | let query: [String: Any] = [ 20 | kSecClass as String: kSecClassGenericPassword, 21 | kSecAttrAccount as String: username ?? "anonymous", 22 | kSecAttrService as String: server 23 | ] 24 | let status = SecItemDelete(query as CFDictionary) 25 | guard status == errSecSuccess || status == errSecItemNotFound else { 26 | throw KeychainError.couldNotPurgeAccessToken 27 | } 28 | } 29 | 30 | func fetchTokenFromKeychain(username: String?, server: String) throws -> String? { 31 | let query: [String: Any] = [ 32 | kSecClass as String: kSecClassGenericPassword, 33 | kSecAttrAccount as String: username ?? "anonymous", 34 | kSecAttrService as String: server, 35 | kSecMatchLimit as String: kSecMatchLimitOne, 36 | kSecReturnAttributes as String: true, 37 | kSecReturnData as String: true 38 | ] 39 | var secItem: CFTypeRef? 40 | let status = SecItemCopyMatching(query as CFDictionary, &secItem) 41 | guard status != errSecItemNotFound else { 42 | throw KeychainError.couldNotFetchAccessToken 43 | } 44 | guard status == errSecSuccess else { 45 | throw KeychainError.couldNotFetchAccessToken 46 | } 47 | guard let existingSecItem = secItem as? [String: Any], 48 | let tokenData = existingSecItem[kSecValueData as String] as? Data, 49 | let token = String(data: tokenData, encoding: .utf8) else { 50 | throw KeychainError.couldNotFetchAccessToken 51 | } 52 | return token 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Shared/LocalStorageManager.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | #if os(iOS) 4 | import UIKit 5 | #elseif os(macOS) 6 | import AppKit 7 | #endif 8 | 9 | final class LocalStorageManager { 10 | 11 | private let logger = Logging(for: String(describing: LocalStorageManager.self)) 12 | 13 | public static var standard = LocalStorageManager() 14 | public let container: NSPersistentContainer 15 | private let containerName = "LocalStorageModel" 16 | 17 | private init() { 18 | container = NSPersistentContainer(name: containerName) 19 | setupStore(in: container) 20 | registerObservers() 21 | } 22 | 23 | func saveContext() { 24 | if container.viewContext.hasChanges { 25 | do { 26 | logger.log("Saving context to local store started...") 27 | try container.viewContext.save() 28 | logger.log("Context saved to local store.") 29 | } catch { 30 | logger.logCrashAndSetFlag(error: LocalStoreError.couldNotSaveContext) 31 | } 32 | } 33 | } 34 | 35 | func purgeUserCollections() throws { 36 | let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFACollection") 37 | let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) 38 | 39 | do { 40 | logger.log("Purging user collections from local store...") 41 | try container.viewContext.executeAndMergeChanges(using: deleteRequest) 42 | logger.log("User collections purged from local store.") 43 | } catch { 44 | logger.log("\(LocalStoreError.couldNotPurgeCollections.localizedDescription)", level: .error) 45 | throw LocalStoreError.couldNotPurgeCollections 46 | } 47 | } 48 | 49 | } 50 | 51 | private extension LocalStorageManager { 52 | 53 | var oldStoreURL: URL { 54 | let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! 55 | return appSupport.appendingPathComponent("LocalStorageModel.sqlite") 56 | } 57 | 58 | var sharedStoreURL: URL { 59 | let id = "group.com.abunchtell.writefreely" 60 | let groupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: id)! 61 | return groupContainer.appendingPathComponent("LocalStorageModel.sqlite") 62 | } 63 | 64 | func setupStore(in container: NSPersistentContainer) { 65 | if !FileManager.default.fileExists(atPath: oldStoreURL.path) { 66 | container.persistentStoreDescriptions.first!.url = sharedStoreURL 67 | } 68 | 69 | container.loadPersistentStores { _, error in 70 | self.logger.log("Loading local store...") 71 | if let error = error { 72 | self.logger.logCrashAndSetFlag(error: LocalStoreError.couldNotLoadStore(error.localizedDescription)) 73 | } 74 | self.logger.log("Loaded local store.") 75 | } 76 | migrateStore(for: container) 77 | container.viewContext.automaticallyMergesChangesFromParent = true 78 | container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 79 | } 80 | 81 | func migrateStore(for container: NSPersistentContainer) { 82 | // Check if the shared store exists before attempting a migration — for example, in case we've already attempted 83 | // and successfully completed a migration, but the deletion of the old store failed for some reason. 84 | guard !FileManager.default.fileExists(atPath: sharedStoreURL.path) else { return } 85 | 86 | let coordinator = container.persistentStoreCoordinator 87 | 88 | // Get a reference to the old store. 89 | guard let oldStore = coordinator.persistentStore(for: oldStoreURL) else { 90 | return 91 | } 92 | 93 | // Attempt to migrate the old store over to the shared store URL. 94 | do { 95 | self.logger.log("Migrating local store to shared store...") 96 | try coordinator.migratePersistentStore(oldStore, 97 | to: sharedStoreURL, 98 | options: nil, 99 | withType: NSSQLiteStoreType) 100 | self.logger.log("Migrated local store to shared store.") 101 | } catch { 102 | logger.logCrashAndSetFlag(error: LocalStoreError.couldNotMigrateStore(error.localizedDescription)) 103 | } 104 | 105 | // Attempt to delete the old store. 106 | do { 107 | logger.log("Deleting migrated local store...") 108 | try FileManager.default.removeItem(at: oldStoreURL) 109 | logger.log("Deleted migrated local store.") 110 | } catch { 111 | logger.logCrashAndSetFlag( 112 | error: LocalStoreError.couldNotDeleteStoreAfterMigration(error.localizedDescription) 113 | ) 114 | } 115 | } 116 | 117 | func registerObservers() { 118 | let center = NotificationCenter.default 119 | 120 | #if os(iOS) 121 | let notification = UIApplication.willResignActiveNotification 122 | #elseif os(macOS) 123 | let notification = NSApplication.willResignActiveNotification 124 | #endif 125 | 126 | // We don't need to worry about removing this observer because we're targeting iOS 9+ / macOS 10.11+; the 127 | // system will clean this up the next time it would be posted to. 128 | // See: https://developer.apple.com/documentation/foundation/notificationcenter/1413994-removeobserver 129 | // And: https://developer.apple.com/documentation/foundation/notificationcenter/1407263-removeobserver 130 | // swiftlint:disable:next discarded_notification_center_observer 131 | center.addObserver(forName: notification, object: nil, queue: nil, using: self.saveContextOnResignActive) 132 | } 133 | 134 | func saveContextOnResignActive(_ notification: Notification) { 135 | saveContext() 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /Shared/Logging/Logging.swift: -------------------------------------------------------------------------------- 1 | // Credit for much of this class: https://steipete.com/posts/logging-in-swift/ 2 | 3 | import Foundation 4 | import os 5 | import OSLog 6 | 7 | protocol LogWriter { 8 | func log(_ message: String, withSensitiveInfo privateInfo: String?, level: OSLogType) 9 | func logCrashAndSetFlag(error: Error) 10 | } 11 | 12 | @available(iOS 15, *) 13 | protocol LogReader { 14 | func fetchLogs() -> [String] 15 | } 16 | 17 | final class Logging { 18 | 19 | private let logger: Logger 20 | private let subsystem = Bundle.main.bundleIdentifier! 21 | 22 | init(for category: String = "") { 23 | self.logger = Logger(subsystem: subsystem, category: category) 24 | } 25 | 26 | } 27 | 28 | extension Logging: LogWriter { 29 | 30 | func log( 31 | _ message: String, 32 | withSensitiveInfo privateInfo: String? = nil, 33 | level: OSLogType = .default 34 | ) { 35 | if let privateInfo = privateInfo { 36 | logger.log(level: level, "\(message): \(privateInfo, privacy: .sensitive)") 37 | } else { 38 | logger.log(level: level, "\(message)") 39 | } 40 | } 41 | 42 | func logCrashAndSetFlag(error: Error) { 43 | let errorDescription = error.localizedDescription 44 | UserDefaults.shared.set(true, forKey: WFDefaults.didHaveFatalError) 45 | UserDefaults.shared.set(errorDescription, forKey: WFDefaults.fatalErrorDescription) 46 | logger.log(level: .error, "\(errorDescription)") 47 | fatalError(errorDescription) 48 | } 49 | 50 | } 51 | 52 | extension Logging: LogReader { 53 | 54 | @available(iOS 15, *) 55 | func fetchLogs() -> [String] { 56 | var logs: [String] = [] 57 | 58 | do { 59 | let osLog = try getLogEntries() 60 | for logEntry in osLog { 61 | let formattedEntry = formatEntry(logEntry) 62 | logs.append(formattedEntry) 63 | } 64 | } catch { 65 | logs.append("Could not fetch logs") 66 | } 67 | 68 | return logs 69 | } 70 | 71 | @available(iOS 15, *) 72 | private func getLogEntries() throws -> [OSLogEntryLog] { 73 | let logStore = try OSLogStore(scope: .currentProcessIdentifier) 74 | let oneHourAgo = logStore.position(date: Date().addingTimeInterval(-3600)) 75 | let allEntries = try Array(logStore.__entriesEnumerator(position: oneHourAgo, predicate: nil)) 76 | return allEntries 77 | .compactMap { $0 as? OSLogEntryLog } 78 | .filter { $0.subsystem == subsystem } 79 | } 80 | 81 | @available(iOS 15, *) 82 | private func formatEntry(_ logEntry: OSLogEntryLog) -> String { 83 | /// The desired format is: 84 | /// `date [process/category] LEVEL: composedMessage (threadIdentifier)` 85 | var level: String = "" 86 | switch logEntry.level { 87 | case .debug: 88 | level = "DEBUG" 89 | case .info: 90 | level = "INFO" 91 | case .notice: 92 | level = "NOTICE" 93 | case .error: 94 | level = "ERROR" 95 | case .fault: 96 | level = "FAULT" 97 | default: 98 | level = "UNDEFINED" 99 | } 100 | // swiftlint:disable:next line_length 101 | return "\(logEntry.date) [\(logEntry.process)/\(logEntry.category)] \(level): \(logEntry.composedMessage) (\(logEntry.threadIdentifier))" 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /Shared/Models/LocalStorageModel.xcdatamodeld/LocalStorageModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Shared/Models/PostStatus.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum PostStatus: Int32 { 4 | case local = 0 5 | case edited = 1 6 | case published = 2 7 | } 8 | -------------------------------------------------------------------------------- /Shared/Models/WriteFreelyModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import WriteFreely 3 | import Security 4 | import Network 5 | 6 | // MARK: - WriteFreelyModel 7 | 8 | final class WriteFreelyModel: ObservableObject { 9 | 10 | // MARK: - Models 11 | @Published var account = AccountModel() 12 | @Published var preferences = PreferencesModel() 13 | @Published var posts = PostListModel() 14 | @Published var editor = PostEditorModel() 15 | 16 | // MARK: - Error handling 17 | @Published var hasError: Bool = false 18 | var currentError: Error? { 19 | didSet { 20 | if let localizedErrorDescription = currentError?.localizedDescription, 21 | localizedErrorDescription == "The operation couldn’t be completed. (WriteFreely.WFError error -2.)", 22 | !hasNetworkConnection { 23 | #if DEBUG 24 | print("⚠️ currentError is WriteFreely.WFError -2 and there is no network connection.") 25 | #endif 26 | currentError = NetworkError.noConnectionError 27 | } 28 | #if DEBUG 29 | print("⚠️ currentError -> didSet \(currentError?.localizedDescription ?? "nil")") 30 | print(" > hasError was: \(self.hasError)") 31 | #endif 32 | DispatchQueue.main.async { 33 | #if DEBUG 34 | print(" > self.currentError != nil: \(self.currentError != nil)") 35 | #endif 36 | self.hasError = self.currentError != nil 37 | #if DEBUG 38 | print(" > hasError is now: \(self.hasError)") 39 | #endif 40 | } 41 | } 42 | } 43 | 44 | // MARK: - State 45 | @Published var isLoggingIn: Bool = false 46 | @Published var isProcessingRequest: Bool = false 47 | @Published var hasNetworkConnection: Bool = true 48 | @Published var selectedPost: WFAPost? 49 | @Published var selectedCollection: WFACollection? 50 | @Published var showAllPosts: Bool = true 51 | @Published var isPresentingDeleteAlert: Bool = false 52 | @Published var postToDelete: WFAPost? 53 | #if os(iOS) 54 | @Published var isPresentingSettingsView: Bool = false 55 | #endif 56 | 57 | static var shared = WriteFreelyModel() 58 | 59 | // swiftlint:disable line_length 60 | let helpURL = URL(string: "https://discuss.write.as/c/help/5")! 61 | let howToURL = URL(string: "https://discuss.write.as/t/using-the-writefreely-ios-app/1946")! 62 | let reviewURL = URL(string: "https://apps.apple.com/app/id1531530896?action=write-review")! 63 | let licensesURL = URL(string: "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses")! 64 | // swiftlint:enable line_length 65 | 66 | internal var client: WFClient? 67 | private let defaults = UserDefaults.shared 68 | private let monitor = NWPathMonitor() 69 | private let queue = DispatchQueue(label: "NetworkMonitor") 70 | internal var postToUpdate: WFAPost? 71 | 72 | init() { 73 | DispatchQueue.main.async { 74 | self.preferences.appearance = self.defaults.integer(forKey: WFDefaults.colorSchemeIntegerKey) 75 | self.preferences.font = self.defaults.integer(forKey: WFDefaults.defaultFontIntegerKey) 76 | self.account.restoreState() 77 | 78 | // Set the appearance 79 | self.preferences.updateAppearance(to: Appearance(rawValue: self.preferences.appearance) ?? .system) 80 | 81 | if self.account.isLoggedIn { 82 | guard let serverURL = URL(string: self.account.server) else { 83 | self.currentError = AccountError.invalidServerURL 84 | return 85 | } 86 | do { 87 | guard let token = try self.fetchTokenFromKeychain( 88 | username: self.account.username, 89 | server: self.account.server 90 | ) else { 91 | self.currentError = KeychainError.couldNotFetchAccessToken 92 | return 93 | } 94 | 95 | self.account.login(WFUser(token: token, username: self.account.username)) 96 | self.client = WFClient(for: serverURL) 97 | self.client?.user = self.account.user 98 | self.fetchUserCollections() 99 | self.fetchUserPosts() 100 | } catch { 101 | self.currentError = KeychainError.couldNotFetchAccessToken 102 | return 103 | } 104 | } 105 | } 106 | 107 | monitor.pathUpdateHandler = { path in 108 | DispatchQueue.main.async { 109 | self.hasNetworkConnection = path.status == .satisfied 110 | } 111 | } 112 | monitor.start(queue: queue) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Shared/Navigation/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @EnvironmentObject var errorHandling: ErrorHandling 6 | 7 | var body: some View { 8 | #if os(macOS) 9 | WFNavigation( 10 | collectionList: { 11 | CollectionListView() 12 | .withErrorHandling() 13 | .toolbar { 14 | if #available(macOS 13, *) { 15 | EmptyView() 16 | } else { 17 | Button( 18 | action: { 19 | NSApp.keyWindow?.contentViewController?.tryToPerform( 20 | #selector(NSSplitViewController.toggleSidebar(_:)), with: nil 21 | ) 22 | }, 23 | label: { Image(systemName: "sidebar.left") } 24 | ) 25 | .help("Toggle the sidebar's visibility.") 26 | } 27 | Spacer() 28 | Button(action: { 29 | withAnimation { 30 | // Un-set the currently selected post 31 | self.model.selectedPost = nil 32 | } 33 | // Create the new-post managed object 34 | let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) 35 | withAnimation { 36 | DispatchQueue.main.async { 37 | // Load the new post in the editor 38 | self.model.selectedPost = managedPost 39 | } 40 | } 41 | }, label: { Image(systemName: "square.and.pencil") }) 42 | .help("Create a new local draft.") 43 | } 44 | .frame(width: 200) 45 | }, 46 | postList: { 47 | ZStack { 48 | PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) 49 | .withErrorHandling() 50 | .frame(width: 300) 51 | if model.isProcessingRequest { 52 | ZStack { 53 | Color(NSColor.controlBackgroundColor).opacity(0.75) 54 | ProgressView() 55 | } 56 | } 57 | } 58 | }, 59 | postDetail: { 60 | NoSelectedPostView(isConnected: $model.hasNetworkConnection) 61 | } 62 | ) 63 | .environmentObject(model) 64 | .onChange(of: model.hasError) { value in 65 | if value { 66 | if let error = model.currentError { 67 | self.errorHandling.handle(error: error) 68 | } else { 69 | self.errorHandling.handle(error: AppError.genericError()) 70 | } 71 | model.hasError = false 72 | } 73 | } 74 | #else 75 | WFNavigation( 76 | collectionList: { 77 | CollectionListView() 78 | .withErrorHandling() 79 | }, 80 | postList: { 81 | PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) 82 | .withErrorHandling() 83 | }, 84 | postDetail: { 85 | NoSelectedPostView(isConnected: $model.hasNetworkConnection) 86 | } 87 | ) 88 | .environmentObject(model) 89 | .onChange(of: model.hasError) { value in 90 | if value { 91 | if let error = model.currentError { 92 | self.errorHandling.handle(error: error) 93 | } else { 94 | self.errorHandling.handle(error: AppError.genericError()) 95 | } 96 | model.hasError = false 97 | } 98 | } 99 | #endif 100 | } 101 | } 102 | 103 | struct ContentView_Previews: PreviewProvider { 104 | static var previews: some View { 105 | let context = LocalStorageManager.standard.container.viewContext 106 | let model = WriteFreelyModel() 107 | 108 | return ContentView() 109 | .environment(\.managedObjectContext, context) 110 | .environmentObject(model) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Shared/Navigation/NoSelectedPostView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct NoSelectedPostView: View { 4 | @Binding var isConnected: Bool 5 | 6 | var body: some View { 7 | VStack(spacing: 8) { 8 | Text("Select a post, or create a new local draft.") 9 | if !isConnected { 10 | Label("You are not connected to the internet", systemImage: "wifi.exclamationmark") 11 | .font(.caption) 12 | .foregroundColor(.secondary) 13 | } 14 | } 15 | .frame(width: 500, height: 500) 16 | } 17 | } 18 | 19 | struct NoSelectedPostViewIsDisconnected_Previews: PreviewProvider { 20 | static var previews: some View { 21 | NoSelectedPostView(isConnected: Binding.constant(true)) 22 | } 23 | } 24 | 25 | struct NoSelectedPostViewIsConnected_Previews: PreviewProvider { 26 | static var previews: some View { 27 | NoSelectedPostView(isConnected: Binding.constant(false)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Shared/Navigation/WFNavigation.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct WFNavigation: View 4 | where CollectionList: View, PostList: View, PostDetail: View { 5 | 6 | private var collectionList: CollectionList 7 | private var postList: PostList 8 | private var postDetail: PostDetail 9 | 10 | init( 11 | @ViewBuilder collectionList: () -> CollectionList, 12 | @ViewBuilder postList: () -> PostList, 13 | @ViewBuilder postDetail: () -> PostDetail 14 | ) { 15 | self.collectionList = collectionList() 16 | self.postList = postList() 17 | self.postDetail = postDetail() 18 | } 19 | 20 | var body: some View { 21 | #if os(macOS) 22 | NavigationSplitView { 23 | collectionList 24 | } content: { 25 | postList 26 | } detail: { 27 | postDetail 28 | } 29 | #else 30 | NavigationView { 31 | collectionList 32 | postList 33 | postDetail 34 | } 35 | #endif 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Shared/PostCollection/CollectionListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CollectionListView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @EnvironmentObject var errorHandling: ErrorHandling 6 | @FetchRequest(sortDescriptors: []) var collections: FetchedResults 7 | @State var selectedCollection: WFACollection? 8 | 9 | var body: some View { 10 | List(selection: $selectedCollection) { 11 | if model.account.isLoggedIn { 12 | NavigationLink("All Posts", destination: PostListView(selectedCollection: nil, showAllPosts: true)) 13 | NavigationLink( 14 | model.account.server == "https://write.as" ? "Anonymous" : "Drafts", 15 | destination: PostListView(selectedCollection: nil, showAllPosts: false) 16 | ) 17 | Section(header: Text("Your Blogs")) { 18 | ForEach(collections, id: \.self) { collection in 19 | NavigationLink(destination: PostListView(selectedCollection: collection, showAllPosts: false), 20 | tag: collection, 21 | selection: $selectedCollection, 22 | label: { Text("\(collection.title)") }) 23 | } 24 | } 25 | } else { 26 | NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) { 27 | Text("Drafts") 28 | } 29 | } 30 | } 31 | .navigationTitle( 32 | model.account.isLoggedIn ? "\(URL(string: model.account.server)?.host ?? "WriteFreely")" : "WriteFreely" 33 | ) 34 | .listStyle(SidebarListStyle()) 35 | .onChange(of: model.selectedCollection) { collection in 36 | model.selectedPost = nil 37 | if collection != model.editor.fetchSelectedCollectionFromAppStorage() { 38 | self.model.editor.selectedCollectionURL = collection?.objectID.uriRepresentation() 39 | } 40 | } 41 | .onChange(of: model.showAllPosts) { value in 42 | model.selectedPost = nil 43 | if value != model.editor.showAllPostsFlag { 44 | self.model.editor.showAllPostsFlag = model.showAllPosts 45 | } 46 | } 47 | .onChange(of: model.hasError) { value in 48 | if value { 49 | if let error = model.currentError { 50 | self.errorHandling.handle(error: error) 51 | } else { 52 | self.errorHandling.handle(error: AppError.genericError()) 53 | } 54 | model.hasError = false 55 | } 56 | } 57 | } 58 | } 59 | 60 | struct CollectionListView_LoggedOutPreviews: PreviewProvider { 61 | static var previews: some View { 62 | let context = LocalStorageManager.standard.container.viewContext 63 | let model = WriteFreelyModel() 64 | 65 | return CollectionListView() 66 | .environment(\.managedObjectContext, context) 67 | .environmentObject(model) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Shared/PostCollection/CollectionPicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CollectionSidebar: View { 4 | @EnvironmentObject var postStore: PostStore 5 | @Binding var selectedCollection: PostCollection? 6 | 7 | private let collections = [ 8 | allPostsCollection, 9 | defaultDraftCollection, 10 | testPostCollection1, 11 | testPostCollection2, 12 | testPostCollection3 13 | ] 14 | 15 | var body: some View { 16 | List { 17 | ForEach(collections) { collection in 18 | NavigationLink( 19 | destination: PostList(title: collection.title, posts: showPosts(for: collection)).tag(collection)) { 20 | Text(collection.title) 21 | } 22 | } 23 | } 24 | .listStyle(SidebarListStyle()) 25 | } 26 | 27 | func showPosts(for collection: PostCollection) -> [Post] { 28 | if collection == allPostsCollection { 29 | return postStore.posts 30 | } else { 31 | return postStore.posts.filter { 32 | $0.collection.title == collection.title 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Shared/PostEditor/PostEditorModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreData 3 | 4 | enum PostAppearance: String { 5 | case sans = "OpenSans-Regular" 6 | case mono = "Hack-Regular" 7 | case serif = "Lora-Regular" 8 | } 9 | 10 | struct PostEditorModel { 11 | @AppStorage(WFDefaults.showAllPostsFlag, store: UserDefaults.shared) var showAllPostsFlag: Bool = false 12 | @AppStorage(WFDefaults.selectedCollectionURL, store: UserDefaults.shared) var selectedCollectionURL: URL? 13 | @AppStorage(WFDefaults.lastDraftURL, store: UserDefaults.shared) var lastDraftURL: URL? 14 | 15 | private(set) var initialPostTitle: String? 16 | private(set) var initialPostBody: String? 17 | 18 | #if os(macOS) 19 | var postToUpdate: WFAPost? 20 | #endif 21 | 22 | func saveLastDraft(_ post: WFAPost) { 23 | self.lastDraftURL = post.status != PostStatus.published.rawValue ? post.objectID.uriRepresentation() : nil 24 | } 25 | 26 | func clearLastDraft() { 27 | self.lastDraftURL = nil 28 | } 29 | 30 | func fetchLastDraftFromAppStorage() -> WFAPost? { 31 | guard let postURL = lastDraftURL else { return nil } 32 | guard let post = fetchManagedObject(from: postURL) as? WFAPost else { return nil } 33 | return post 34 | } 35 | 36 | func generateNewLocalPost(withFont appearance: Int) -> WFAPost { 37 | let managedPost = WFAPost(context: LocalStorageManager.standard.container.viewContext) 38 | managedPost.createdDate = Date() 39 | managedPost.title = "" 40 | managedPost.body = "" 41 | managedPost.status = PostStatus.local.rawValue 42 | managedPost.collectionAlias = WriteFreelyModel.shared.selectedCollection?.alias 43 | switch appearance { 44 | case 1: 45 | managedPost.appearance = "sans" 46 | case 2: 47 | managedPost.appearance = "wrap" 48 | default: 49 | managedPost.appearance = "serif" 50 | } 51 | if #available(iOS 16, macOS 13, *) { 52 | if let languageCode = Locale.current.language.languageCode?.identifier { 53 | managedPost.language = languageCode 54 | managedPost.rtl = Locale.Language(identifier: languageCode).characterDirection == .rightToLeft 55 | } 56 | } else { 57 | if let languageCode = Locale.current.languageCode { 58 | managedPost.language = languageCode 59 | managedPost.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft 60 | } 61 | } 62 | return managedPost 63 | } 64 | 65 | func fetchSelectedCollectionFromAppStorage() -> WFACollection? { 66 | guard let collectionURL = selectedCollectionURL else { return nil } 67 | guard let collection = fetchManagedObject(from: collectionURL) as? WFACollection else { return nil } 68 | return collection 69 | } 70 | 71 | /// Sets the initial values for title and body on a published post. 72 | /// 73 | /// Used to detect if the title and body have changed back to their initial values. If the passed `WFAPost` isn't 74 | /// published, any title and post values already stored are reset to `nil`. 75 | /// - Parameter post: The `WFAPost` for which we're setting initial title/body values. 76 | mutating func setInitialValues(for post: WFAPost) { 77 | if post.status != PostStatus.published.rawValue { 78 | initialPostTitle = nil 79 | initialPostBody = nil 80 | return 81 | } 82 | initialPostTitle = post.title 83 | initialPostBody = post.body 84 | } 85 | 86 | private func fetchManagedObject(from objectURL: URL) -> NSManagedObject? { 87 | let coordinator = LocalStorageManager.standard.container.persistentStoreCoordinator 88 | guard let managedObjectID = coordinator.managedObjectID(forURIRepresentation: objectURL) else { return nil } 89 | let object = LocalStorageManager.standard.container.viewContext.object(with: managedObjectID) 90 | return object 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Shared/PostEditor/PostEditorStatusToolbarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostEditorStatusToolbarView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | 6 | @ObservedObject var post: WFAPost 7 | 8 | var body: some View { 9 | if post.hasNewerRemoteCopy { 10 | #if os(iOS) 11 | PostStatusBadgeView(post: post) 12 | #else 13 | HStack { 14 | HStack { 15 | Text("⚠️ Newer copy on server. Replace local copy?") 16 | .font(.callout) 17 | .foregroundColor(.secondary) 18 | Button(action: { 19 | model.editor.postToUpdate = post 20 | model.updateFromServer(post: post) 21 | DispatchQueue.main.async { 22 | model.selectedPost = nil 23 | } 24 | }, label: { 25 | Image(systemName: "square.and.arrow.down") 26 | }) 27 | .accessibilityLabel(Text("Update post")) 28 | .accessibilityHint(Text("Replace this post with the server version")) 29 | } 30 | .padding(.horizontal) 31 | .background(Color.primary.opacity(0.1)) 32 | .clipShape(Capsule()) 33 | .padding(.trailing) 34 | PostStatusBadgeView(post: post) 35 | } 36 | #endif 37 | } else if post.wasDeletedFromServer && post.status != PostStatus.local.rawValue { 38 | #if os(iOS) 39 | PostStatusBadgeView(post: post) 40 | #else 41 | HStack { 42 | HStack { 43 | Text("⚠️ Post deleted from server. Delete local copy?") 44 | .font(.callout) 45 | .foregroundColor(.secondary) 46 | Button(action: { 47 | model.selectedPost = nil 48 | DispatchQueue.main.async { 49 | model.posts.remove(post) 50 | } 51 | }, label: { 52 | Image(systemName: "trash") 53 | }) 54 | .accessibilityLabel(Text("Delete")) 55 | .accessibilityHint(Text("Delete this post from your Mac")) 56 | } 57 | .padding(.horizontal) 58 | .background(Color.primary.opacity(0.1)) 59 | .clipShape(Capsule()) 60 | .padding(.trailing) 61 | PostStatusBadgeView(post: post) 62 | } 63 | #endif 64 | } else { 65 | PostStatusBadgeView(post: post) 66 | } 67 | } 68 | } 69 | 70 | struct PESTView_StandardPreviews: PreviewProvider { 71 | static var previews: some View { 72 | let context = LocalStorageManager.standard.container.viewContext 73 | let model = WriteFreelyModel() 74 | let testPost = WFAPost(context: context) 75 | testPost.status = PostStatus.published.rawValue 76 | 77 | return PostEditorStatusToolbarView(post: testPost) 78 | .environmentObject(model) 79 | } 80 | } 81 | 82 | struct PESTView_OutdatedLocalCopyPreviews: PreviewProvider { 83 | static var previews: some View { 84 | let context = LocalStorageManager.standard.container.viewContext 85 | let model = WriteFreelyModel() 86 | let updatedPost = WFAPost(context: context) 87 | updatedPost.status = PostStatus.published.rawValue 88 | updatedPost.hasNewerRemoteCopy = true 89 | 90 | return PostEditorStatusToolbarView(post: updatedPost) 91 | .environmentObject(model) 92 | } 93 | } 94 | 95 | struct PESTView_DeletedRemoteCopyPreviews: PreviewProvider { 96 | static var previews: some View { 97 | let context = LocalStorageManager.standard.container.viewContext 98 | let model = WriteFreelyModel() 99 | let deletedPost = WFAPost(context: context) 100 | deletedPost.status = PostStatus.published.rawValue 101 | deletedPost.wasDeletedFromServer = true 102 | 103 | return PostEditorStatusToolbarView(post: deletedPost) 104 | .environmentObject(model) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Shared/PostList/PostCellView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostCellView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @ObservedObject var post: WFAPost 6 | var collectionName: String? 7 | 8 | static let createdDateFormat: DateFormatter = { 9 | let formatter = DateFormatter() 10 | formatter.locale = Locale.current 11 | formatter.dateStyle = .long 12 | formatter.timeStyle = .short 13 | return formatter 14 | }() 15 | 16 | var titleText: String { 17 | if post.title.isEmpty { 18 | return model.posts.getBodyPreview(of: post) 19 | } 20 | return post.title 21 | } 22 | 23 | var body: some View { 24 | HStack { 25 | VStack(alignment: .leading) { 26 | if let collectionName = collectionName { 27 | Text(collectionName) 28 | .font(.caption) 29 | .foregroundColor(.secondary) 30 | .padding(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4)) 31 | .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.secondary, lineWidth: 1)) 32 | } 33 | Text(titleText) 34 | .font(.headline) 35 | Text(post.createdDate ?? Date(), formatter: Self.createdDateFormat) 36 | .font(.caption) 37 | .foregroundColor(.secondary) 38 | .padding(.top, -3) 39 | } 40 | Spacer() 41 | PostStatusBadgeView(post: post) 42 | } 43 | .padding(5) 44 | .contextMenu { 45 | Button( 46 | action: didTapDeleteContextMenuItem, 47 | label: { Label("Delete", systemImage: "trash") } 48 | ) 49 | .disabled(post.status != PostStatus.local.rawValue) 50 | } 51 | } 52 | 53 | private func didTapDeleteContextMenuItem() { 54 | guard post.status == PostStatus.local.rawValue else { return } 55 | if post === model.selectedPost { 56 | model.selectedPost = nil 57 | model.editor.clearLastDraft() 58 | } 59 | 60 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 61 | model.posts.remove(post) 62 | } 63 | } 64 | } 65 | 66 | struct PostCell_AllPostsPreviews: PreviewProvider { 67 | static var previews: some View { 68 | let context = LocalStorageManager.standard.container.viewContext 69 | let testPost = WFAPost(context: context) 70 | testPost.title = "Test Post Title" 71 | testPost.body = "Here's some cool sample body text." 72 | testPost.createdDate = Date() 73 | 74 | return PostCellView(post: testPost, collectionName: "My Cool Blog") 75 | .environment(\.managedObjectContext, context) 76 | } 77 | } 78 | 79 | struct PostCell_NormalPreviews: PreviewProvider { 80 | static var previews: some View { 81 | let context = LocalStorageManager.standard.container.viewContext 82 | let testPost = WFAPost(context: context) 83 | testPost.title = "Test Post Title" 84 | testPost.body = "Here's some cool sample body text." 85 | testPost.collectionAlias = "My Cool Blog" 86 | testPost.createdDate = Date() 87 | 88 | return PostCellView(post: testPost) 89 | .environment(\.managedObjectContext, context) 90 | } 91 | } 92 | 93 | struct PostCell_NoTitlePreviews: PreviewProvider { 94 | static var previews: some View { 95 | let context = LocalStorageManager.standard.container.viewContext 96 | let testPost = WFAPost(context: context) 97 | testPost.title = "" 98 | testPost.body = "Here's some cool sample body text." 99 | testPost.collectionAlias = "My Cool Blog" 100 | testPost.createdDate = Date() 101 | 102 | return PostCellView(post: testPost) 103 | .environment(\.managedObjectContext, context) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Shared/PostList/PostListFilteredView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostListFilteredView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @Binding var postCount: Int 6 | @FetchRequest(entity: WFACollection.entity(), sortDescriptors: []) var collections: FetchedResults 7 | 8 | var fetchRequest: FetchRequest 9 | 10 | init(collection: WFACollection?, showAllPosts: Bool, postCount: Binding) { 11 | if showAllPosts { 12 | fetchRequest = FetchRequest( 13 | entity: WFAPost.entity(), 14 | sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)] 15 | ) 16 | } else { 17 | if let collectionAlias = collection?.alias { 18 | fetchRequest = FetchRequest( 19 | entity: WFAPost.entity(), 20 | sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)], 21 | predicate: NSPredicate(format: "collectionAlias == %@", collectionAlias) 22 | ) 23 | } else { 24 | fetchRequest = FetchRequest( 25 | entity: WFAPost.entity(), 26 | sortDescriptors: [NSSortDescriptor(key: "createdDate", ascending: false)], 27 | predicate: NSPredicate(format: "collectionAlias == nil") 28 | ) 29 | } 30 | } 31 | _postCount = postCount 32 | } 33 | 34 | var body: some View { 35 | #if os(iOS) 36 | if #available(iOS 15, *) { 37 | SearchablePostListFilteredView( 38 | postCount: $postCount, 39 | collections: collections, 40 | fetchRequest: fetchRequest, 41 | onDelete: delete(_:) 42 | ) 43 | .environmentObject(model) 44 | .onAppear(perform: { 45 | self.postCount = fetchRequest.wrappedValue.count 46 | }) 47 | .onChange(of: fetchRequest.wrappedValue.count, perform: { value in 48 | self.postCount = value 49 | }) 50 | } else { 51 | List(selection: $model.selectedPost) { 52 | ForEach(fetchRequest.wrappedValue, id: \.self) { post in 53 | NavigationLink( 54 | destination: PostEditorView(post: post), 55 | tag: post, 56 | selection: $model.selectedPost, 57 | label: { 58 | if model.showAllPosts { 59 | if let collection = collections.filter({ $0.alias == post.collectionAlias }).first { 60 | PostCellView(post: post, collectionName: collection.title) 61 | } else { 62 | // swiftlint:disable:next line_length 63 | let collectionName = model.account.server == "https://write.as" ? "Anonymous" : "Drafts" 64 | PostCellView(post: post, collectionName: collectionName) 65 | } 66 | } else { 67 | PostCellView(post: post) 68 | } 69 | }) 70 | .deleteDisabled(post.status != PostStatus.local.rawValue) 71 | } 72 | .onDelete(perform: { indexSet in 73 | for index in indexSet { 74 | let post = fetchRequest.wrappedValue[index] 75 | delete(post) 76 | } 77 | }) 78 | } 79 | .onAppear(perform: { 80 | self.postCount = fetchRequest.wrappedValue.count 81 | }) 82 | .onChange(of: fetchRequest.wrappedValue.count, perform: { value in 83 | self.postCount = value 84 | }) 85 | } 86 | #else 87 | SearchablePostListFilteredView( 88 | postCount: $postCount, 89 | collections: collections, 90 | fetchRequest: fetchRequest, 91 | onDelete: delete(_:) 92 | ) 93 | .environmentObject(model) 94 | .alert(isPresented: $model.isPresentingDeleteAlert) { 95 | Alert( 96 | title: Text("Delete Post?"), 97 | message: Text("This action cannot be undone."), 98 | primaryButton: .cancel { 99 | model.postToDelete = nil 100 | }, 101 | secondaryButton: .destructive(Text("Delete"), action: { 102 | if let postToDelete = model.postToDelete { 103 | model.selectedPost = nil 104 | DispatchQueue.main.async { 105 | model.editor.clearLastDraft() 106 | model.posts.remove(postToDelete) 107 | } 108 | model.postToDelete = nil 109 | } 110 | }) 111 | ) 112 | } 113 | .onDeleteCommand(perform: { 114 | guard let selectedPost = model.selectedPost else { return } 115 | if selectedPost.status == PostStatus.local.rawValue { 116 | model.postToDelete = selectedPost 117 | model.isPresentingDeleteAlert = true 118 | } 119 | }) 120 | #endif 121 | } 122 | 123 | func delete(_ post: WFAPost) { 124 | DispatchQueue.main.async { 125 | if post == model.selectedPost { 126 | model.selectedPost = nil 127 | model.editor.clearLastDraft() 128 | } 129 | model.posts.remove(post) 130 | } 131 | } 132 | } 133 | 134 | struct PostListFilteredView_Previews: PreviewProvider { 135 | static var previews: some View { 136 | return PostListFilteredView( 137 | collection: nil, 138 | showAllPosts: false, 139 | postCount: .constant(999) 140 | ) 141 | .environmentObject(WriteFreelyModel()) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Shared/PostList/PostListModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CoreData 3 | 4 | class PostListModel: ObservableObject { 5 | func remove(_ post: WFAPost) { 6 | withAnimation { 7 | LocalStorageManager.standard.container.viewContext.delete(post) 8 | LocalStorageManager.standard.saveContext() 9 | } 10 | } 11 | 12 | func purgePublishedPosts() throws { 13 | let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFAPost") 14 | fetchRequest.predicate = NSPredicate(format: "status != %i", 0) 15 | let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) 16 | 17 | do { 18 | try LocalStorageManager.standard.container.viewContext.executeAndMergeChanges(using: deleteRequest) 19 | } catch { 20 | throw LocalStoreError.couldNotPurgePosts("cached") 21 | } 22 | } 23 | 24 | func getBodyPreview(of post: WFAPost) -> String { 25 | var elidedPostBody: String = "" 26 | 27 | // Strip any markdown from the post body. 28 | let strippedPostBody = stripMarkdown(from: post.body) 29 | 30 | // Extract lede from post. 31 | elidedPostBody = extractLede(from: strippedPostBody) 32 | 33 | return elidedPostBody 34 | } 35 | } 36 | 37 | private extension PostListModel { 38 | 39 | func stripMarkdown(from string: String) -> String { 40 | var strippedString = string 41 | strippedString = stripHeadingOctothorpes(from: strippedString) 42 | strippedString = stripImages(from: strippedString, keepAltText: true) 43 | return strippedString 44 | } 45 | 46 | func stripHeadingOctothorpes(from string: String) -> String { 47 | let newLines = CharacterSet.newlines 48 | var processedComponents: [String] = [] 49 | let components = string.components(separatedBy: newLines) 50 | for component in components { 51 | if component.isEmpty { 52 | continue 53 | } 54 | var newString = component 55 | while newString.first == "#" { 56 | newString.removeFirst() 57 | } 58 | if newString.hasPrefix(" ") { 59 | newString.removeFirst() 60 | } 61 | processedComponents.append(newString) 62 | } 63 | let headinglessString = processedComponents.joined(separator: "\n\n") 64 | return headinglessString 65 | } 66 | 67 | func stripImages(from string: String, keepAltText: Bool = false) -> String { 68 | let pattern = #"!\[[\"]?(.*?)[\"|]?\]\(.*?\)"# 69 | var processedComponents: [String] = [] 70 | let components = string.components(separatedBy: .newlines) 71 | for component in components { 72 | if component.isEmpty { continue } 73 | var processedString: String = component 74 | if keepAltText { 75 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 76 | if let matches = regex?.matches( 77 | in: component, options: [], range: NSRange(location: 0, length: component.utf16.count) 78 | ) { 79 | for match in matches { 80 | if let range = Range(match.range(at: 1), in: component) { 81 | processedString = "\(component[range])" 82 | } 83 | } 84 | } 85 | } else { 86 | let range = component.startIndex.. String { 101 | let truncatedString = string.prefix(80) 102 | let terminatingPunctuation = ".。?" 103 | let terminatingCharacters = CharacterSet(charactersIn: terminatingPunctuation).union(.newlines) 104 | 105 | var lede: String = "" 106 | let sentences = truncatedString.components(separatedBy: terminatingCharacters) 107 | if let firstSentence = (sentences.filter { !$0.isEmpty }).first { 108 | if truncatedString.count > firstSentence.count { 109 | if terminatingPunctuation.contains(truncatedString[firstSentence.endIndex]) { 110 | lede = String(truncatedString[...firstSentence.endIndex]) 111 | } else { 112 | lede = firstSentence 113 | } 114 | } else if truncatedString.count == firstSentence.count { 115 | if string.count > 80 { 116 | if let endOfStringIndex = truncatedString.lastIndex(of: " ") { 117 | lede = truncatedString[.. (String, Color) { 20 | var badgeLabel: String 21 | var badgeColor: Color 22 | 23 | switch status { 24 | case .local: 25 | badgeLabel = "local" 26 | badgeColor = Color(red: 0.75, green: 0.5, blue: 0.85, opacity: 1.0) 27 | case .edited: 28 | badgeLabel = "edited" 29 | badgeColor = Color(red: 0.75, green: 0.7, blue: 0.1, opacity: 1.0) 30 | case .published: 31 | badgeLabel = "published" 32 | badgeColor = .gray 33 | } 34 | 35 | return (badgeLabel, badgeColor) 36 | } 37 | } 38 | 39 | struct PostStatusBadge_LocalDraftPreviews: PreviewProvider { 40 | static var previews: some View { 41 | let context = LocalStorageManager.standard.container.viewContext 42 | let testPost = WFAPost(context: context) 43 | testPost.status = PostStatus.local.rawValue 44 | 45 | return PostStatusBadgeView(post: testPost) 46 | .environment(\.managedObjectContext, context) 47 | } 48 | } 49 | 50 | struct PostStatusBadge_EditedPreviews: PreviewProvider { 51 | static var previews: some View { 52 | let context = LocalStorageManager.standard.container.viewContext 53 | let testPost = WFAPost(context: context) 54 | testPost.status = PostStatus.edited.rawValue 55 | 56 | return PostStatusBadgeView(post: testPost) 57 | .environment(\.managedObjectContext, context) 58 | } 59 | } 60 | 61 | struct PostStatusBadge_PublishedPreviews: PreviewProvider { 62 | static var previews: some View { 63 | let context = LocalStorageManager.standard.container.viewContext 64 | let testPost = WFAPost(context: context) 65 | testPost.status = PostStatus.published.rawValue 66 | 67 | return PostStatusBadgeView(post: testPost) 68 | .environment(\.managedObjectContext, context) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Shared/PostList/SearchablePostListFilteredView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 15, macOS 12.0, *) 4 | struct SearchablePostListFilteredView: View { 5 | @EnvironmentObject var model: WriteFreelyModel 6 | @Binding var postCount: Int 7 | @State private var searchString = "" 8 | 9 | var collections: FetchedResults 10 | var fetchRequest: FetchRequest 11 | var onDelete: (WFAPost) -> Void 12 | 13 | var body: some View { 14 | List(selection: $model.selectedPost) { 15 | ForEach(fetchRequest.wrappedValue, id: \.self) { post in 16 | if !searchString.isEmpty && 17 | !post.title.localizedCaseInsensitiveContains(searchString) && 18 | !post.body.localizedCaseInsensitiveContains(searchString) { 19 | EmptyView() 20 | } else { 21 | NavigationLink( 22 | destination: PostEditorView(post: post), 23 | tag: post, 24 | selection: $model.selectedPost, 25 | label: { 26 | if model.showAllPosts { 27 | if let collection = collections.filter({ $0.alias == post.collectionAlias }).first { 28 | PostCellView(post: post, collectionName: collection.title) 29 | } else { 30 | // swiftlint:disable:next line_length 31 | let collectionName = model.account.server == "https://write.as" ? "Anonymous" : "Drafts" 32 | PostCellView(post: post, collectionName: collectionName) 33 | } 34 | } else { 35 | PostCellView(post: post) 36 | } 37 | }) 38 | .deleteDisabled(post.status != PostStatus.local.rawValue) 39 | } 40 | } 41 | .onDelete(perform: { indexSet in 42 | for index in indexSet { 43 | let post = fetchRequest.wrappedValue[index] 44 | delete(post) 45 | } 46 | }) 47 | } 48 | #if os(iOS) 49 | .searchable(text: $searchString, prompt: "Search across posts") 50 | #else 51 | .searchable(text: $searchString, placement: .toolbar, prompt: "Search across posts") 52 | #endif 53 | } 54 | 55 | func delete(_ post: WFAPost) { 56 | onDelete(post) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Shared/Preferences/PreferencesModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum Appearance: Int { 4 | case system = 0 5 | case light = 1 6 | case dark = 2 7 | } 8 | 9 | class PreferencesModel: ObservableObject { 10 | private let defaults = UserDefaults.shared 11 | 12 | @Published var selectedColorScheme: ColorScheme? 13 | @Published var appearance: Int = 0 14 | @Published var font: Int = 0 { 15 | didSet { 16 | defaults.set(font, forKey: WFDefaults.defaultFontIntegerKey) 17 | } 18 | } 19 | 20 | @available(iOSApplicationExtension, unavailable) 21 | func updateAppearance(to appearance: Appearance) { 22 | #if os(iOS) 23 | var window: UIWindow? { 24 | guard let scene = UIApplication.shared.connectedScenes.first, 25 | let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate, 26 | let window = windowSceneDelegate.window else { 27 | return nil 28 | } 29 | return window 30 | } 31 | #endif 32 | 33 | switch appearance { 34 | case .light: 35 | #if os(macOS) 36 | NSApp.appearance = NSAppearance(named: .aqua) 37 | #else 38 | window?.overrideUserInterfaceStyle = .light 39 | #endif 40 | case .dark: 41 | #if os(macOS) 42 | NSApp.appearance = NSAppearance(named: .darkAqua) 43 | #else 44 | window?.overrideUserInterfaceStyle = .dark 45 | #endif 46 | default: 47 | #if os(macOS) 48 | NSApp.appearance = nil 49 | #else 50 | window?.overrideUserInterfaceStyle = .unspecified 51 | #endif 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Shared/Preferences/PreferencesView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PreferencesView: View { 4 | @ObservedObject var preferences: PreferencesModel 5 | 6 | /* We're stuck dropping into AppKit/UIKit to set light/dark schemes for now, 7 | * because setting the .preferredColorScheme modifier on views in SwiftUI is 8 | * currently unreliable. 9 | * 10 | * Feedback submitted to Apple: 11 | * 12 | * FB8382883: "On macOS 11β4, preferredColorScheme modifier does not respect .light ColorScheme" 13 | * FB8383053: "On iOS 14β4/macOS 11β4, it is not possible to unset preferredColorScheme after setting 14 | * it to either .light or .dark" 15 | */ 16 | 17 | #if os(iOS) 18 | var window: UIWindow? { 19 | guard let scene = UIApplication.shared.connectedScenes.first, 20 | let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate, 21 | let window = windowSceneDelegate.window else { 22 | return nil 23 | } 24 | return window 25 | } 26 | #endif 27 | 28 | var body: some View { 29 | VStack { 30 | VStack { 31 | Text("Choose the preferred appearance for the app.") 32 | .font(.caption) 33 | .foregroundColor(.secondary) 34 | Picker(selection: $preferences.appearance, label: Text("Appearance")) { 35 | Text("System").tag(0) 36 | Text("Light").tag(1) 37 | Text("Dark").tag(2) 38 | } 39 | .pickerStyle(SegmentedPickerStyle()) 40 | } 41 | .padding(.bottom) 42 | 43 | VStack { 44 | Text("Choose the default font for new posts.") 45 | .font(.caption) 46 | .foregroundColor(.secondary) 47 | Picker(selection: $preferences.font, label: Text("Default Font")) { 48 | Text("Serif").tag(0) 49 | Text("Sans-Serif").tag(1) 50 | Text("Monospace").tag(2) 51 | } 52 | .pickerStyle(SegmentedPickerStyle()) 53 | .padding(.bottom) 54 | switch preferences.font { 55 | case 1: 56 | Text("Sample Text") 57 | .frame(width: 240, height: 50, alignment: .center) 58 | .font(.custom("OpenSans-Regular", size: 20)) 59 | case 2: 60 | Text("Sample Text") 61 | .frame(width: 240, height: 50, alignment: .center) 62 | .font(.custom("Hack-Regular", size: 20)) 63 | default: 64 | Text("Sample Text") 65 | .frame(width: 240, height: 50, alignment: .center) 66 | .font(.custom("Lora", size: 20)) 67 | } 68 | } 69 | .padding(.bottom) 70 | } 71 | .onChange(of: preferences.appearance) { value in 72 | preferences.updateAppearance(to: Appearance(rawValue: value) ?? .system) 73 | UserDefaults.shared.set(value, forKey: WFDefaults.colorSchemeIntegerKey) 74 | } 75 | } 76 | } 77 | 78 | struct SwiftUIView_Previews: PreviewProvider { 79 | static var previews: some View { 80 | PreferencesView(preferences: PreferencesModel()) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Shared/Resources/Hack-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Resources/Hack-Regular.ttf -------------------------------------------------------------------------------- /Shared/Resources/Licenses/Hack-License.txt: -------------------------------------------------------------------------------- 1 | The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License 2 | 3 | The work in the DejaVu project was committed to the public domain. 4 | 5 | Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font 6 | Names "Bitstream" and "Vera" 7 | 8 | ### MIT License 9 | 10 | Copyright (c) 2018 Source Foundry Authors 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | 30 | ### BITSTREAM VERA LICENSE 31 | 32 | Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. 33 | 34 | Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license 35 | ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, 36 | including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font 37 | Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: 38 | 39 | The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of 40 | the Font Software typefaces. 41 | 42 | The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the 43 | Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to 44 | names not containing either the words "Bitstream" or the word "Vera". 45 | 46 | This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is 47 | distributed under the "Bitstream Vera" names. 48 | 49 | The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software 50 | typefaces may be sold by itself. 51 | 52 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 53 | ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, 54 | TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR 55 | OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION 56 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER 57 | DEALINGS IN THE FONT SOFTWARE. 58 | 59 | Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in 60 | advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written 61 | authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: 62 | fonts at gnome dot org. 63 | -------------------------------------------------------------------------------- /Shared/Resources/Licenses/Lora-Cyrillic-OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /Shared/Resources/Licenses/Sparkle-License.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2013 Andy Matuschak. 2 | Copyright (c) 2009-2013 Elgato Systems GmbH. 3 | Copyright (c) 2011-2014 Kornel Lesiński. 4 | Copyright (c) 2015-2017 Mayur Pawashe. 5 | Copyright (c) 2014 C.W. Betts. 6 | Copyright (c) 2014 Petroules Corporation. 7 | Copyright (c) 2014 Big Nerd Ranch. 8 | All rights reserved. 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy of 11 | this software and associated documentation files (the "Software"), to deal in 12 | the Software without restriction, including without limitation the rights to 13 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 14 | the Software, and to permit persons to whom the Software is furnished to do so, 15 | subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 22 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 23 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 24 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 25 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | ================= 28 | EXTERNAL LICENSES 29 | ================= 30 | 31 | bspatch.c and bsdiff.c, from bsdiff 4.3 : 32 | Copyright (c) 2003-2005 Colin Percival. 33 | 34 | sais.c and sais.c, from sais-lite (2010/08/07) : 35 | Copyright (c) 2008-2010 Yuta Mori. 36 | 37 | SUDSAVerifier.m: 38 | Copyright (c) 2011 Mark Hamlin. 39 | 40 | All rights reserved. 41 | 42 | Redistribution and use in source and binary forms, with or without 43 | modification, are permitted providing that the following conditions 44 | are met: 45 | 1. Redistributions of source code must retain the above copyright 46 | notice, this list of conditions and the following disclaimer. 47 | 2. Redistributions in binary form must reproduce the above copyright 48 | notice, this list of conditions and the following disclaimer in the 49 | documentation and/or other materials provided with the distribution. 50 | 51 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 52 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 53 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 54 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 55 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 56 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 57 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 58 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 59 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 60 | IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 61 | POSSIBILITY OF SUCH DAMAGE. 62 | -------------------------------------------------------------------------------- /Shared/Resources/LoraGX.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Resources/LoraGX.ttf -------------------------------------------------------------------------------- /Shared/Resources/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/writefreely/writefreely-swiftui-multiplatform/3203697dc81cdb85f8f86aadbddcec767c86c5f2/Shared/Resources/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /Technotes/EditorLaunchingPolicy.md: -------------------------------------------------------------------------------- 1 | # Editor Launching Policy 2 | 3 | _Last updated: Wednesday, 23 September, 2020_ 4 | 5 | This technote defines the policy for what is loaded in the post editor on app launch. 6 | 7 | The app shall always launch to the post editor. Determining what post should be loaded in the editor requires defining the following: 8 | 9 | - **Last Draft:** The last post with either a `local` or `edited` status to have been loaded into the post editor. It's important to note that a 10 | `published` post that is loaded into the post editor and is then changed becomes an `edited` post, and therefore qualifies as a last draft. 11 | 12 | The launch policy is as follows: 13 | 14 | The app shall launch to the last draft, _except_ when: 15 | 16 | - There is no last draft (i.e., on the first launch of the app); or 17 | - The user's actions signal that they are done working with this last draft: 18 | - The last draft was `published` before quitting the app 19 | - The user's last action in the app was to leave the post editor (iOS) or deselect any post from the post list (macOS). 20 | 21 | In these cases, the app shall launch to a new, blank, `local` post. 22 | -------------------------------------------------------------------------------- /Technotes/MacSoftwareUpdater.md: -------------------------------------------------------------------------------- 1 | # Mac Software Updater 2 | 3 | To make updating the Mac app easy, we're using the [Sparkle framework][1]. 4 | 5 | ## Troubleshooting 6 | 7 | ### If Xcode throws an error when you try to build the project 8 | 9 | You may need to reset the package caches: 10 | 11 | 1. From the **File** menu in Xcode, choose **Swift Packages** → **Reset Package Caches** 12 | 2. Again from the **File** menu, choose **Swift Packages** → **Update to Latest Package Versions** 13 | 14 | You should then be able to build and run the Mac target. 15 | 16 | ### If you can't run `generate_keys` because "Apple cannot check it for malicious software" 17 | 18 | If you run into a code signing issue with Sparkle, right-click on `generate_keys` in the Finder and choose Open ([reference][2]). 19 | 20 | ## Deploying Updates 21 | 22 | To [publish an update to the app][4], you'll need the **Sparkle-for-Swift-Package-Manager.zip** [archive][3] —  23 | specifically, you'll need the `generate_appcast` tool. Download and de-compress the archive. 24 | 25 | You will need some credentials and signing certificates to proceed with this process; speak to the project maintainer if 26 | you're responsible for creating the update, and confirm you have: 27 | 28 | - the app's Developer ID Application certificate (check your Mac's system Keychain) 29 | - the Sparkle EdDSA signing key (again, check your Mac's system Keychain) 30 | 31 | Sign and notarize the app archive, then click on **Export Notarized App** in Xcode's Organizer window. Open the Terminal 32 | and navigate to where you de-compressed the Sparkle-for-Swift-Package-Manager archive, then create a zip file that 33 | preserves symlinks: 34 | 35 | ```bash 36 | % ditto -c -k --sequesterRsrc --keepParent 37 | ``` 38 | 39 | For example, if you export the notarized app to the desktop, all prior updates are located in `~/Developer/WriteFreely/Updates`, and 40 | the final archive should be called `WFMac.zip`, you would run: 41 | 42 | ```bash 43 | % ditto -c -k --sequesterRsrc --keepParent ~/Desktop/WriteFreely\ for\ Mac.app ~/Developer/WriteFreely/Updates/WFMac.zip 44 | ``` 45 | 46 | Then, generate an appcast file: 47 | 48 | ```bash 49 | % ./bin/generate_appcast ~/Developer/WriteFreely/Updates 50 | ``` 51 | 52 | Once that's done, upload the appcast.xml and WFMac.zip files to the update distribution server (`files.writefreely.org/apps/mac`) 53 | and they'll be made available to users. 54 | 55 | 56 | [1]: https://sparkle-project.org 57 | [2]: https://github.com/sparkle-project/Sparkle/issues/1701#issuecomment-752249920 58 | [3]: https://github.com/sparkle-project/Sparkle/releases/tag/1.24.0 59 | [4]: https://sparkle-project.org/documentation/publishing/ 60 | -------------------------------------------------------------------------------- /Tests iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests iOS/Tests_iOS.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class Tests_iOS: XCTestCase { 4 | 5 | override func setUpWithError() throws { 6 | // Put setup code here. This method is called before the invocation of each test method in the class. 7 | 8 | // In UI tests it is usually best to stop immediately when a failure occurs. 9 | continueAfterFailure = false 10 | 11 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests 12 | // before they run. The setUp method is a good place to do this. 13 | } 14 | 15 | override func tearDownWithError() throws { 16 | // Put teardown code here. This method is called after the invocation of each test method in the class. 17 | } 18 | 19 | func testExample() throws { 20 | // UI tests must launch the application that they test. 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Use recording to get started writing UI tests. 25 | // Use XCTAssert and related functions to verify your tests produce the correct results. 26 | } 27 | 28 | func testLaunchPerformance() throws { 29 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 30 | // This measures how long it takes to launch your application. 31 | measure(metrics: [XCTApplicationLaunchMetric()]) { 32 | XCUIApplication().launch() 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests macOS/Tests_macOS.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class Tests_macOS: XCTestCase { 4 | 5 | override func setUpWithError() throws { 6 | // Put setup code here. This method is called before the invocation of each test method in the class. 7 | 8 | // In UI tests it is usually best to stop immediately when a failure occurs. 9 | continueAfterFailure = false 10 | 11 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests 12 | // before they run. The setUp method is a good place to do this. 13 | } 14 | 15 | override func tearDownWithError() throws { 16 | // Put teardown code here. This method is called after the invocation of each test method in the class. 17 | } 18 | 19 | func testExample() throws { 20 | // UI tests must launch the application that they test. 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Use recording to get started writing UI tests. 25 | // Use XCTAssert and related functions to verify your tests produce the correct results. 26 | } 27 | 28 | func testLaunchPerformance() throws { 29 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 30 | // This measures how long it takes to launch your application. 31 | measure(metrics: [XCTApplicationLaunchMetric()]) { 32 | XCUIApplication().launch() 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /WFACollection+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | @objc(WFACollection) 5 | public class WFACollection: NSManagedObject { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /WFACollection+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | extension WFACollection { 5 | 6 | @nonobjc public class func createFetchRequest() -> NSFetchRequest { 7 | return NSFetchRequest(entityName: "WFACollection") 8 | } 9 | 10 | @NSManaged public var alias: String? 11 | @NSManaged public var blogDescription: String? 12 | @NSManaged public var email: String? 13 | @NSManaged public var isPublic: Bool 14 | @NSManaged public var styleSheet: String? 15 | @NSManaged public var title: String 16 | @NSManaged public var url: String? 17 | 18 | } 19 | 20 | extension WFACollection: Identifiable { 21 | 22 | } 23 | -------------------------------------------------------------------------------- /WFAPost+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | @objc(WFAPost) 5 | public class WFAPost: NSManagedObject { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /WFAPost+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WFAPost+CoreDataProperties.swift 3 | // WriteFreely-MultiPlatform 4 | // 5 | // Created by Angelo Stavrow on 2020-09-08. 6 | // 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | extension WFAPost { 13 | 14 | @nonobjc public class func createFetchRequest() -> NSFetchRequest { 15 | return NSFetchRequest(entityName: "WFAPost") 16 | } 17 | 18 | @NSManaged public var appearance: String? 19 | @NSManaged public var body: String 20 | @NSManaged public var collectionAlias: String? 21 | @NSManaged public var createdDate: Date? 22 | @NSManaged public var language: String? 23 | @NSManaged public var postId: String? 24 | @NSManaged public var rtl: Bool 25 | @NSManaged public var slug: String? 26 | @NSManaged public var status: Int32 27 | @NSManaged public var title: String 28 | @NSManaged public var updatedDate: Date? 29 | @NSManaged public var hasNewerRemoteCopy: Bool 30 | @NSManaged public var wasDeletedFromServer: Bool 31 | 32 | } 33 | 34 | extension WFAPost: Identifiable { 35 | 36 | } 37 | -------------------------------------------------------------------------------- /WriteFreely-MultiPlatform (iOS).entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.abunchtell.writefreely 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /WriteFreely-MultiPlatform.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /WriteFreely-MultiPlatform.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iOS/Extensions/EnvironmentValues+Extensions.swift: -------------------------------------------------------------------------------- 1 | // Credit: 2 | // https://github.com/sindresorhus/Blear/blob/9ce7cd6ad8d6a88f8d0be12b1ef9152baeeacf96/Blear/Utilities.swift#L1052-L1064 3 | 4 | import SwiftUI 5 | 6 | extension EnvironmentValues { 7 | 8 | private struct ExtensionContext: EnvironmentKey { 9 | static var defaultValue: NSExtensionContext? 10 | } 11 | 12 | /// The `.extensionContext` of an app extension view controller. 13 | var extensionContext: NSExtensionContext? { 14 | get { self[ExtensionContext.self] } 15 | set { 16 | self[ExtensionContext.self] = newValue 17 | } 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Extensions/UIHostingView.swift: -------------------------------------------------------------------------------- 1 | // Credit: 2 | // https://github.com/sindresorhus/Blear/blob/9ce7cd6ad8d6a88f8d0be12b1ef9152baeeacf96/Blear/Utilities.swift#L317-L368 3 | 4 | import SwiftUI 5 | 6 | final class UIHostingView: UIView { 7 | private let rootViewHostingController: UIHostingController 8 | 9 | var rootView: Content { 10 | get { rootViewHostingController.rootView } 11 | set { 12 | rootViewHostingController.rootView = newValue 13 | } 14 | } 15 | 16 | required init(rootView: Content) { 17 | self.rootViewHostingController = UIHostingController(rootView: rootView) 18 | super.init(frame: .zero) 19 | rootViewHostingController.view.backgroundColor = .clear 20 | addSubview(rootViewHostingController.view) 21 | } 22 | 23 | @available(*, unavailable) 24 | required init?(coder: NSCoder) { 25 | fatalError("init(coder:) has not been implemented") 26 | } 27 | 28 | override func layoutSubviews() { 29 | super.layoutSubviews() 30 | rootViewHostingController.view.frame = bounds 31 | } 32 | 33 | override func sizeToFit() { 34 | guard let superview = superview else { 35 | super.sizeToFit() 36 | return 37 | } 38 | 39 | frame.size = rootViewHostingController.sizeThatFits(in: superview.frame.size) 40 | } 41 | 42 | override func sizeThatFits(_ size: CGSize) -> CGSize { 43 | rootViewHostingController.sizeThatFits(in: size) 44 | } 45 | 46 | override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize { 47 | rootViewHostingController.sizeThatFits(in: targetSize) 48 | } 49 | 50 | override func systemLayoutSizeFitting( 51 | _ targetSize: CGSize, 52 | withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, 53 | verticalFittingPriority: UILayoutPriority 54 | ) -> CGSize { 55 | rootViewHostingController.sizeThatFits(in: targetSize) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /iOS/Extensions/View+Keyboard.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | func hideKeyboard() { 5 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | WriteFreely 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSRequiresIPhoneOS 24 | 25 | UIAppFonts 26 | 27 | LoraGX.ttf 28 | OpenSans-Regular.ttf 29 | Hack-Regular.ttf 30 | 31 | UIApplicationSceneManifest 32 | 33 | UIApplicationSupportsMultipleScenes 34 | 35 | 36 | UIApplicationSupportsIndirectInputEvents 37 | 38 | UILaunchStoryboardName 39 | LaunchScreen 40 | UIRequiredDeviceCapabilities 41 | 42 | armv7 43 | 44 | UISupportedInterfaceOrientations 45 | 46 | UIInterfaceOrientationPortrait 47 | UIInterfaceOrientationLandscapeLeft 48 | UIInterfaceOrientationLandscapeRight 49 | UIInterfaceOrientationPortraitUpsideDown 50 | 51 | UISupportedInterfaceOrientations~ipad 52 | 53 | UIInterfaceOrientationPortrait 54 | UIInterfaceOrientationPortraitUpsideDown 55 | UIInterfaceOrientationLandscapeLeft 56 | UIInterfaceOrientationLandscapeRight 57 | 58 | ITSAppUsesNonExemptEncryption 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /iOS/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /iOS/PostEditor/MultilineTextView.swift: -------------------------------------------------------------------------------- 1 | // Credit: https://stackoverflow.com/a/58639072 2 | 3 | import SwiftUI 4 | import UIKit 5 | 6 | private struct UITextViewWrapper: UIViewRepresentable { 7 | typealias UIViewType = UITextView 8 | 9 | @Binding var text: String 10 | @Binding var calculatedHeight: CGFloat 11 | @Binding var isEditing: Bool 12 | var textStyle: UIFont 13 | var onDone: (() -> Void)? 14 | 15 | func makeUIView(context: UIViewRepresentableContext) -> UITextView { 16 | let textField = UITextView() 17 | textField.delegate = context.coordinator 18 | 19 | textField.isEditable = true 20 | textField.isSelectable = true 21 | textField.isUserInteractionEnabled = true 22 | textField.isScrollEnabled = false 23 | textField.backgroundColor = UIColor.clear 24 | textField.smartDashesType = .no 25 | 26 | if nil != onDone { 27 | textField.returnKeyType = .next 28 | } 29 | 30 | textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 31 | return textField 32 | } 33 | 34 | func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { 35 | if uiView.text != self.text { 36 | uiView.text = self.text 37 | } 38 | 39 | let font = textStyle 40 | let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) 41 | uiView.font = fontMetrics.scaledFont(for: font) 42 | 43 | if uiView.window != nil && isEditing { 44 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { 45 | uiView.becomeFirstResponder() 46 | } 47 | } 48 | 49 | UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight) 50 | } 51 | 52 | fileprivate static func recalculateHeight(view: UIView, result: Binding) { 53 | let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude)) 54 | if result.wrappedValue != newSize.height { 55 | DispatchQueue.main.async { 56 | result.wrappedValue = newSize.height // !! must be called asynchronously 57 | } 58 | } 59 | } 60 | 61 | func makeCoordinator() -> Coordinator { 62 | return Coordinator(text: $text, height: $calculatedHeight, isFirstResponder: $isEditing, onDone: onDone) 63 | } 64 | 65 | final class Coordinator: NSObject, UITextViewDelegate { 66 | @Binding var isFirstResponder: Bool 67 | var text: Binding 68 | var calculatedHeight: Binding 69 | var onDone: (() -> Void)? 70 | 71 | init( 72 | text: Binding, 73 | height: Binding, 74 | isFirstResponder: Binding, 75 | onDone: (() -> Void)? = nil 76 | ) { 77 | self.text = text 78 | self.calculatedHeight = height 79 | self._isFirstResponder = isFirstResponder 80 | self.onDone = onDone 81 | } 82 | 83 | func textViewDidChange(_ uiView: UITextView) { 84 | text.wrappedValue = uiView.text 85 | UITextViewWrapper.recalculateHeight(view: uiView, result: calculatedHeight) 86 | } 87 | 88 | func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { 89 | if let onDone = self.onDone, text == "\n" { 90 | textView.resignFirstResponder() 91 | onDone() 92 | return false 93 | } 94 | return true 95 | } 96 | 97 | func textViewDidEndEditing(_ textView: UITextView) { 98 | self.isFirstResponder = false 99 | } 100 | } 101 | 102 | } 103 | 104 | struct MultilineTextField: View { 105 | 106 | private var placeholder: String 107 | private var textStyle: UIFont 108 | private var onCommit: (() -> Void)? 109 | 110 | @Binding var isFirstResponder: Bool 111 | @Binding private var text: String 112 | private var internalText: Binding { 113 | Binding(get: { self.text }) { // swiftlint:disable:this multiple_closures_with_trailing_closure 114 | self.text = $0 115 | self.showingPlaceholder = $0.isEmpty 116 | } 117 | } 118 | 119 | @State private var dynamicHeight: CGFloat = 100 120 | @State private var showingPlaceholder = false 121 | 122 | init ( 123 | _ placeholder: String = "", 124 | text: Binding, 125 | font: UIFont, 126 | isFirstResponder: Binding, 127 | onCommit: (() -> Void)? = nil 128 | ) { 129 | self.placeholder = placeholder 130 | self.onCommit = onCommit 131 | self.textStyle = font 132 | self._isFirstResponder = isFirstResponder 133 | self._text = text 134 | self._showingPlaceholder = State(initialValue: self.text.isEmpty) 135 | } 136 | 137 | var body: some View { 138 | UITextViewWrapper( 139 | text: self.internalText, 140 | calculatedHeight: $dynamicHeight, 141 | isEditing: $isFirstResponder, 142 | textStyle: textStyle, 143 | onDone: onCommit 144 | ) 145 | .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight) 146 | .background(placeholderView, alignment: .topLeading) 147 | } 148 | 149 | var placeholderView: some View { 150 | Group { 151 | if showingPlaceholder { 152 | let font = Font(textStyle) 153 | Text(placeholder).foregroundColor(.gray) 154 | .padding(.leading, 4) 155 | .padding(.top, 8) 156 | .font(font) 157 | } 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /iOS/PostEditor/PostTextEditingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostTextEditingView: View { 4 | @Environment(\.horizontalSizeClass) var horizontalSizeClass 5 | @EnvironmentObject var model: WriteFreelyModel 6 | @ObservedObject var post: WFAPost 7 | @Binding var updatingTitleFromServer: Bool 8 | @Binding var updatingBodyFromServer: Bool 9 | @State private var appearance: PostAppearance = .serif 10 | @State private var titleTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 26)! 11 | @State private var titleIsFirstResponder: Bool = true 12 | @State private var bodyTextStyle: UIFont = UIFont(name: "Lora-Regular", size: 17)! 13 | @State private var bodyIsFirstResponder: Bool = false 14 | private let lineSpacingMultiplier: CGFloat = 0.5 15 | private let textEditorHeight: CGFloat = 50 16 | 17 | init( 18 | post: ObservedObject, 19 | updatingTitleFromServer: Binding, 20 | updatingBodyFromServer: Binding 21 | ) { 22 | self._post = post 23 | self._updatingTitleFromServer = updatingTitleFromServer 24 | self._updatingBodyFromServer = updatingBodyFromServer 25 | UITextView.appearance().backgroundColor = .clear 26 | } 27 | 28 | var body: some View { 29 | ScrollView(.vertical) { 30 | MultilineTextField( 31 | "Title (optional)", 32 | text: $post.title, 33 | font: titleTextStyle, 34 | isFirstResponder: $titleIsFirstResponder, 35 | onCommit: didFinishEditingTitle 36 | ) 37 | .accessibilityLabel(Text("Title (optional)")) 38 | .accessibilityHint(Text("Add or edit the title for your post; use the Return key to skip to the body")) 39 | .onChange(of: post.title) { value in 40 | if post.status == PostStatus.published.rawValue && !updatingTitleFromServer { 41 | post.status = PostStatus.edited.rawValue 42 | } 43 | if updatingTitleFromServer { 44 | updatingTitleFromServer = false 45 | } 46 | if post.status == PostStatus.edited.rawValue && value == model.editor.initialPostTitle { 47 | post.status = PostStatus.published.rawValue 48 | } 49 | } 50 | MultilineTextField( 51 | "Write...", 52 | text: $post.body, 53 | font: bodyTextStyle, 54 | isFirstResponder: $bodyIsFirstResponder 55 | ) 56 | .accessibilityLabel(Text("Body")) 57 | .accessibilityHint(Text("Add or edit the body of your post")) 58 | .onChange(of: post.body) { value in 59 | if post.status == PostStatus.published.rawValue && !updatingBodyFromServer { 60 | post.status = PostStatus.edited.rawValue 61 | } 62 | if updatingBodyFromServer { 63 | updatingBodyFromServer = false 64 | } 65 | if post.status == PostStatus.edited.rawValue && value == model.editor.initialPostBody { 66 | post.status = PostStatus.published.rawValue 67 | } 68 | } 69 | } 70 | .onChange(of: titleIsFirstResponder, perform: { value in 71 | self.bodyIsFirstResponder = !value 72 | }) 73 | .onAppear(perform: { 74 | switch post.appearance { 75 | case "sans": 76 | self.appearance = .sans 77 | case "wrap", "mono", "code": 78 | self.appearance = .mono 79 | default: 80 | self.appearance = .serif 81 | } 82 | self.titleTextStyle = UIFont(name: appearance.rawValue, size: 26)! 83 | self.bodyTextStyle = UIFont(name: appearance.rawValue, size: 17)! 84 | }) 85 | .onDisappear { 86 | hideKeyboard() 87 | } 88 | } 89 | 90 | private func didFinishEditingTitle() { 91 | self.titleIsFirstResponder = false 92 | self.bodyIsFirstResponder = true 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /iOS/PostEditor/RemoteChangePromptView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum RemotePostChangeType { 4 | case remoteCopyUpdated 5 | case remoteCopyDeleted 6 | } 7 | 8 | struct RemoteChangePromptView: View { 9 | @Environment(\.horizontalSizeClass) var horizontalSizeClass 10 | @State private var promptText: String = "This is placeholder prompt text. Replace it?" 11 | @State private var promptIcon: Image = Image(systemName: "questionmark.square.dashed") 12 | @State private var accessibilityLabel: String = "Replace" 13 | @State private var accessibilityHint: String = "Replace this text with an accessibility hint" 14 | @State var remoteChangeType: RemotePostChangeType 15 | @State var buttonHandler: () -> Void 16 | 17 | var body: some View { 18 | HStack { 19 | Text("⚠️ \(promptText)") 20 | .font(horizontalSizeClass == .compact ? .caption : .body) 21 | .foregroundColor(.secondary) 22 | Button(action: buttonHandler, label: { promptIcon }) 23 | .accessibilityLabel(Text(accessibilityLabel)) 24 | .accessibilityHint(Text(accessibilityHint)) 25 | } 26 | .padding(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)) 27 | .background(Color(UIColor.secondarySystemBackground)) 28 | .clipShape(Capsule()) 29 | .padding(.bottom) 30 | .onAppear(perform: { 31 | switch remoteChangeType { 32 | case .remoteCopyUpdated: 33 | promptText = "Newer copy on server. Replace local copy?" 34 | promptIcon = Image(systemName: "square.and.arrow.down") 35 | accessibilityLabel = "Update post" 36 | accessibilityHint = "Replace this post with the server version" 37 | case .remoteCopyDeleted: 38 | promptText = "Post deleted from server. Delete local copy?" 39 | promptIcon = Image(systemName: "trash") 40 | accessibilityLabel = "Delete" 41 | accessibilityHint = "Delete this post from your device" 42 | } 43 | }) 44 | } 45 | } 46 | 47 | struct RemoteChangePromptView_UpdatedPreviews: PreviewProvider { 48 | static var previews: some View { 49 | RemoteChangePromptView( 50 | remoteChangeType: .remoteCopyUpdated, 51 | buttonHandler: { print("Hello, updated post!") } 52 | ) 53 | } 54 | } 55 | 56 | struct RemoteChangePromptView_DeletedPreviews: PreviewProvider { 57 | static var previews: some View { 58 | RemoteChangePromptView( 59 | remoteChangeType: .remoteCopyDeleted, 60 | buttonHandler: { print("Goodbye, deleted post!") } 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /iOS/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryUserDefaults 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | 1C8F.1 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /iOS/Settings/SettingsHeaderView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsHeaderView: View { 4 | @Environment(\.presentationMode) var presentationMode 5 | 6 | var body: some View { 7 | VStack { 8 | HStack { 9 | Text("Settings") 10 | .font(.largeTitle) 11 | .fontWeight(.bold) 12 | Spacer() 13 | Button(action: { 14 | presentationMode.wrappedValue.dismiss() 15 | }, label: { 16 | Image(systemName: "xmark.circle") 17 | }) 18 | .accessibilityLabel(Text("Close")) 19 | .accessibilityHint(Text("Dismiss the Settings sheet")) 20 | } 21 | Text("WriteFreely v\(Bundle.main.appMarketingVersion) (build \(Bundle.main.appBuildVersion))") 22 | .font(.caption) 23 | .foregroundColor(.secondary) 24 | .padding(.top) 25 | } 26 | .padding() 27 | } 28 | } 29 | 30 | struct SettingsHeaderView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | SettingsHeaderView() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /iOS/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | 5 | @EnvironmentObject var model: WriteFreelyModel 6 | @State private var isShowingAlert = false 7 | 8 | private let logger = Logging(for: String(describing: SettingsView.self)) 9 | 10 | var body: some View { 11 | VStack { 12 | SettingsHeaderView() 13 | Form { 14 | Section(header: Text("Login Details")) { 15 | AccountView() 16 | .withErrorHandling() 17 | } 18 | Section(header: Text("Appearance")) { 19 | PreferencesView(preferences: model.preferences) 20 | } 21 | Section(header: Text("Help and Support")) { 22 | Link("View the Guide", destination: model.howToURL) 23 | Link("Visit the Help Forum", destination: model.helpURL) 24 | Link("Write a Review on the App Store", destination: model.reviewURL) 25 | if #available(iOS 15.0, *) { 26 | VStack(alignment: .leading, spacing: 8) { 27 | Button( 28 | action: didTapGenerateLogPostButton, 29 | label: { 30 | Text("Create Log Post") 31 | } 32 | ) 33 | Text("Generates a local post using recent logs. You can share this for troubleshooting.") 34 | .font(.footnote) 35 | .foregroundColor(.secondary) 36 | } 37 | } 38 | } 39 | Section(header: Text("Acknowledgements")) { 40 | VStack { 41 | VStack(alignment: .leading) { 42 | Text("This application makes use of the following open-source projects:") 43 | .padding(.bottom) 44 | Text("• Lora typeface") 45 | .padding(.leading) 46 | Text("• Open Sans typeface") 47 | .padding(.leading) 48 | Text("• Hack typeface") 49 | .padding(.leading) 50 | } 51 | .padding(.bottom) 52 | .foregroundColor(.secondary) 53 | HStack { 54 | Spacer() 55 | Link("View the licenses", destination: model.licensesURL) 56 | Spacer() 57 | } 58 | } 59 | .padding() 60 | } 61 | } 62 | } 63 | .alert(isPresented: $isShowingAlert) { 64 | Alert( 65 | title: Text("Log Post Created"), 66 | message: Text("Check your local drafts for app logs from the past 24 hours.") 67 | ) 68 | } 69 | // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. 70 | } 71 | 72 | @available(iOS 15, *) 73 | private func didTapGenerateLogPostButton() { 74 | logger.log("Generating local log post...") 75 | 76 | DispatchQueue.main.asyncAfter(deadline: .now()) { 77 | // Unset selected post and collection and navigate to local drafts. 78 | self.model.selectedPost = nil 79 | self.model.selectedCollection = nil 80 | self.model.showAllPosts = false 81 | 82 | // Create the new log post. 83 | let newLogPost = model.editor.generateNewLocalPost(withFont: 2) 84 | newLogPost.title = "Logs For Support" 85 | var postBody: [String] = [ 86 | "WriteFreely-Multiplatform v\(Bundle.main.appMarketingVersion) (\(Bundle.main.appBuildVersion))", 87 | "Generated \(Date())", 88 | "" 89 | ] 90 | postBody.append(contentsOf: logger.fetchLogs()) 91 | newLogPost.body = postBody.joined(separator: "\n") 92 | 93 | self.isShowingAlert = true 94 | } 95 | 96 | logger.log("Generated local log post.") 97 | } 98 | } 99 | 100 | struct SettingsView_Previews: PreviewProvider { 101 | static var previews: some View { 102 | SettingsView() 103 | .environmentObject(WriteFreelyModel()) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /macOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | class AppDelegate: NSObject, NSApplicationDelegate { 4 | 5 | // MARK: - Window handling when miniaturized into app icon on the Dock 6 | // Credit to Henry Cooper (pillboxer) on GitHub: 7 | // https://github.com/tact/beta-bugs/issues/31#issuecomment-855914705 8 | 9 | // If the window is currently minimized into the Dock, de-miniaturize it (note that if it's minimized 10 | // and the user uses OPT+TAB to switch to it, it will be de-miniaturized and brought to the foreground). 11 | func applicationDidBecomeActive(_ notification: Notification) { 12 | if let window = NSApp.windows.first { 13 | window.deminiaturize(nil) 14 | } 15 | } 16 | 17 | // If we're miniaturizing the window, deactivate it as well. 18 | // Credit to KHKnobl on GitHub: 19 | // https://github.com/writefreely/writefreely-swiftui-multiplatform/issues/135#issuecomment-1101713817 20 | func applicationDidChangeOcclusionState(_ notification: Notification) { 21 | if let window = NSApp.windows.first, window.isMiniaturized { 22 | NSApp.hide(self) 23 | } 24 | } 25 | 26 | lazy var windows = NSWindow() 27 | func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 28 | if !flag { 29 | for window in sender.windows { 30 | window.makeKeyAndOrderFront(self) 31 | } 32 | } 33 | return true 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /macOS/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf2578 2 | \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 SFProDisplay-Bold;\f1\fnil\fcharset0 SFProDisplay-Regular;} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | {\*\listtable{\list\listtemplateid1\listhybrid{\listlevel\levelnfc23\levelnfcn23\leveljc0\leveljcn0\levelfollow0\levelstartat1\levelspace360\levelindent0{\*\levelmarker \{disc\}}{\leveltext\leveltemplateid1\'01\uc0\u8226 ;}{\levelnumbers;}\fi-360\li720\lin720 }{\listname ;}\listid1}} 6 | {\*\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}} 7 | \margl1440\margr1440\vieww9000\viewh8400\viewkind0 8 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\qc\partightenfactor0 9 | 10 | \f0\b\fs26 \cf0 \ 11 | PRE-RELEASE SOFTWARE 12 | \f1\b0 \ 13 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0 14 | \cf0 \ 15 | WriteFreely for Mac makes use of the following open-source projects:\ 16 | \ 17 | \pard\tx220\tx720\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\li720\fi-720\pardirnatural\partightenfactor0 18 | \ls1\ilvl0\cf0 {\listtext \uc0\u8226 }Lora ({\field{\*\fldinst{HYPERLINK "https://github.com/cyrealtype/Lora-Cyrillic"}}{\fldrslt typeface}})\ 19 | {\listtext \uc0\u8226 }Open Sans ({\field{\*\fldinst{HYPERLINK "https://fonts.google.com/specimen/Open+Sans"}}{\fldrslt typeface}})\ 20 | {\listtext \uc0\u8226 }Hack ({\field{\*\fldinst{HYPERLINK "https://sourcefoundry.org/hack/"}}{\fldrslt typeface}})\ 21 | {\listtext \uc0\u8226 }Sparkle ({\field{\*\fldinst{HYPERLINK "https://sparkle-project.org"}}{\fldrslt framework}})\ 22 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0 23 | \cf0 \ 24 | \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0 25 | {\field{\*\fldinst{HYPERLINK "https://github.com/writeas/writefreely-swiftui-multiplatform/tree/main/Shared/Resources/Licenses"}}{\fldrslt \cf0 View the licenses}}\ 26 | \ 27 | The post editor is based on Thiago Holanda's {\field{\*\fldinst{HYPERLINK "https://gist.github.com/unnamedd/6e8c3fbc806b8deb60fa65d6b9affab0"}}{\fldrslt MacEditorTextView}} gist.\ 28 | } -------------------------------------------------------------------------------- /macOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ATSApplicationFontsPath 6 | . 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleDisplayName 10 | WriteFreely 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIconFile 14 | 15 | CFBundleIdentifier 16 | $(PRODUCT_BUNDLE_IDENTIFIER) 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | WriteFreely 21 | CFBundlePackageType 22 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 23 | CFBundleShortVersionString 24 | $(MARKETING_VERSION) 25 | CFBundleVersion 26 | $(CURRENT_PROJECT_VERSION) 27 | LSApplicationCategoryType 28 | public.app-category.social-networking 29 | LSMinimumSystemVersion 30 | $(MACOSX_DEPLOYMENT_TARGET) 31 | SUFeedURL 32 | https://files.writefreely.org/apps/mac/appcast.xml 33 | SUPublicEDKey 34 | xLenuurDaQb2/dj2ScylLmJx0gSnBmacUsOAgUjErUc= 35 | 36 | 37 | -------------------------------------------------------------------------------- /macOS/Navigation/HelpCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct HelpCommands: Commands { 4 | @ObservedObject var model: WriteFreelyModel 5 | 6 | private let logger = Logging(for: String(describing: PostCommands.self)) 7 | 8 | var body: some Commands { 9 | CommandGroup(replacing: .help) { 10 | Button("Visit Support Forum") { 11 | NSWorkspace().open(model.helpURL) 12 | } 13 | Button(action: createLogsPost, label: { Text("Generate Log for Support") }) 14 | } 15 | } 16 | 17 | private func createLogsPost() { 18 | logger.log("Generating local log post...") 19 | 20 | // Show the spinner going in the post list 21 | model.isProcessingRequest = true 22 | 23 | DispatchQueue.main.asyncAfter(deadline: .now()) { 24 | // Unset selected post and collection and navigate to local drafts. 25 | self.model.selectedPost = nil 26 | self.model.selectedCollection = nil 27 | self.model.showAllPosts = false 28 | 29 | // Create the new log post. 30 | let newLogPost = model.editor.generateNewLocalPost(withFont: 2) 31 | newLogPost.title = "Logs For Support" 32 | var postBody: [String] = [ 33 | "WriteFreely-Multiplatform v\(Bundle.main.appMarketingVersion) (\(Bundle.main.appBuildVersion))", 34 | "Generated \(Date())", 35 | "" 36 | ] 37 | postBody.append(contentsOf: logger.fetchLogs()) 38 | newLogPost.body = postBody.joined(separator: "\n") 39 | 40 | // Hide the spinner in the post list and set the log post as active 41 | self.model.isProcessingRequest = false 42 | self.model.selectedPost = newLogPost 43 | 44 | logger.log("Generated local log post.") 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /macOS/Navigation/PostCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostCommands: Commands { 4 | @ObservedObject var model: WriteFreelyModel 5 | 6 | var body: some Commands { 7 | CommandMenu("Post") { 8 | Button("Find In Posts") { 9 | if let toolbar = NSApp.keyWindow?.toolbar, 10 | let search = toolbar.items.first(where: { 11 | $0.itemIdentifier.rawValue == "com.apple.SwiftUI.search" 12 | }) as? NSSearchToolbarItem { 13 | search.beginSearchInteraction() 14 | } 15 | } 16 | .keyboardShortcut("f", modifiers: [.command, .shift]) 17 | 18 | Group { 19 | Button(action: sendPostUrlToPasteboard, label: { Text("Copy Link To Published Post") }) 20 | .disabled(model.selectedPost?.status == PostStatus.local.rawValue) 21 | } 22 | .disabled(model.selectedPost == nil || !model.account.isLoggedIn) 23 | } 24 | } 25 | 26 | private func sendPostUrlToPasteboard() { 27 | guard let activePost = model.selectedPost else { return } 28 | guard let postId = activePost.postId else { return } 29 | guard let urlString = activePost.slug != nil ? 30 | "\(model.account.server)/\((activePost.collectionAlias)!)/\((activePost.slug)!)" : 31 | "\(model.account.server)/\((postId))" else { return } 32 | NSPasteboard.general.clearContents() 33 | NSPasteboard.general.setString(urlString, forType: .string) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /macOS/PostEditor/PostEditorSharingPicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostEditorSharingPicker: NSViewRepresentable { 4 | @Binding var isPresented: Bool 5 | var sharingItems: [NSURL] = [] 6 | 7 | func makeNSView(context: Context) -> some NSView { 8 | let view = NSView() 9 | return view 10 | } 11 | 12 | func updateNSView(_ nsView: NSViewType, context: Context) { 13 | if isPresented { 14 | let picker = NSSharingServicePicker(items: sharingItems) 15 | picker.delegate = context.coordinator 16 | 17 | DispatchQueue.main.async { 18 | picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY) 19 | } 20 | } 21 | } 22 | 23 | func makeCoordinator() -> Coordinator { 24 | Coordinator(owner: self) 25 | } 26 | 27 | class Coordinator: NSObject, NSSharingServicePickerDelegate { 28 | let owner: PostEditorSharingPicker 29 | 30 | init(owner: PostEditorSharingPicker) { 31 | self.owner = owner 32 | } 33 | 34 | func sharingServicePicker( 35 | _ sharingServicePicker: NSSharingServicePicker, 36 | sharingServicesForItems items: [Any], 37 | proposedSharingServices proposedServices: [NSSharingService] 38 | ) -> [NSSharingService] { 39 | guard let copyIcon = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy URL") else { 40 | return proposedServices 41 | } 42 | 43 | var share = proposedServices 44 | let copyService = NSSharingService( 45 | title: "Copy URL", 46 | image: copyIcon, 47 | alternateImage: copyIcon, 48 | handler: { 49 | if let url = items.first as? NSURL, let urlString = url.absoluteString { 50 | let clipboard = NSPasteboard.general 51 | clipboard.clearContents() 52 | clipboard.setString(urlString, forType: .string) 53 | } 54 | } 55 | ) 56 | share.insert(copyService, at: 0) 57 | share.insert(NSSharingService(named: .addToSafariReadingList)!, at: 1) 58 | return share 59 | } 60 | 61 | func sharingServicePicker( 62 | _ sharingServicePicker: NSSharingServicePicker, 63 | didChoose service: NSSharingService? 64 | ) { 65 | sharingServicePicker.delegate = nil 66 | self.owner.isPresented = false 67 | } 68 | 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /macOS/PostEditor/PostEditorView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostEditorView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @EnvironmentObject var errorHandling: ErrorHandling 6 | 7 | @ObservedObject var post: WFAPost 8 | @State private var isHovering: Bool = false 9 | @State private var updatingFromServer: Bool = false 10 | 11 | var body: some View { 12 | VStack { 13 | if !model.hasNetworkConnection { 14 | Label("You are not connected to the internet", systemImage: "wifi.exclamationmark") 15 | .font(.caption) 16 | .foregroundColor(.secondary) 17 | .padding(.top, 8) 18 | } 19 | PostTextEditingView( 20 | post: post, 21 | updatingFromServer: $updatingFromServer 22 | ) 23 | .background(Color(NSColor.controlBackgroundColor)) 24 | .onAppear(perform: { 25 | model.editor.setInitialValues(for: post) 26 | if post.status != PostStatus.published.rawValue { 27 | DispatchQueue.main.async { 28 | self.model.editor.saveLastDraft(post) 29 | } 30 | } else { 31 | self.model.editor.clearLastDraft() 32 | } 33 | }) 34 | .onChange(of: post.hasNewerRemoteCopy, perform: { _ in 35 | if !post.hasNewerRemoteCopy { 36 | self.updatingFromServer = true 37 | } 38 | }) 39 | .onChange(of: post.status, perform: { value in 40 | if value != PostStatus.published.rawValue { 41 | self.model.editor.saveLastDraft(post) 42 | } else { 43 | self.model.editor.clearLastDraft() 44 | } 45 | DispatchQueue.main.async { 46 | LocalStorageManager.standard.saveContext() 47 | } 48 | }) 49 | .onChange(of: model.hasError) { value in 50 | if value { 51 | if let error = model.currentError { 52 | self.errorHandling.handle(error: error) 53 | } else { 54 | self.errorHandling.handle(error: AppError.genericError()) 55 | } 56 | model.hasError = false 57 | } 58 | } 59 | .onDisappear(perform: { 60 | DispatchQueue.main.async { 61 | model.editor.clearLastDraft() 62 | } 63 | if post.title.count == 0 64 | && post.body.count == 0 65 | && post.status == PostStatus.local.rawValue 66 | && post.updatedDate == nil 67 | && post.postId == nil { 68 | DispatchQueue.main.async { 69 | model.posts.remove(post) 70 | } 71 | } else if post.status != PostStatus.published.rawValue { 72 | DispatchQueue.main.async { 73 | LocalStorageManager.standard.saveContext() 74 | } 75 | } 76 | }) 77 | } 78 | } 79 | } 80 | 81 | struct PostEditorView_EmptyPostPreviews: PreviewProvider { 82 | static var previews: some View { 83 | let context = LocalStorageManager.standard.container.viewContext 84 | let testPost = WFAPost(context: context) 85 | testPost.createdDate = Date() 86 | testPost.appearance = "norm" 87 | 88 | let model = WriteFreelyModel() 89 | 90 | return PostEditorView(post: testPost) 91 | .environment(\.managedObjectContext, context) 92 | .environmentObject(model) 93 | } 94 | } 95 | 96 | struct PostEditorView_ExistingPostPreviews: PreviewProvider { 97 | static var previews: some View { 98 | let context = LocalStorageManager.standard.container.viewContext 99 | let testPost = WFAPost(context: context) 100 | testPost.title = "Test Post Title" 101 | testPost.body = "Here's some cool sample body text." 102 | testPost.createdDate = Date() 103 | testPost.appearance = "code" 104 | 105 | let model = WriteFreelyModel() 106 | 107 | return PostEditorView(post: testPost) 108 | .environment(\.managedObjectContext, context) 109 | .environmentObject(model) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /macOS/PostEditor/PostTextEditingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PostTextEditingView: View { 4 | @EnvironmentObject var model: WriteFreelyModel 5 | @ObservedObject var post: WFAPost 6 | @Binding var updatingFromServer: Bool 7 | @State private var appearance: PostAppearance = .serif 8 | @State private var combinedText = "" 9 | @State private var hasBeenEdited: Bool = false 10 | 11 | let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 12 | 13 | var body: some View { 14 | ZStack(alignment: .topLeading) { 15 | if combinedText.count == 0 { 16 | Text("Write…") 17 | .foregroundColor(Color(NSColor.placeholderTextColor)) 18 | .padding(.horizontal, 16) 19 | .padding(.vertical, 16) 20 | .font(.custom(appearance.rawValue, size: 17, relativeTo: .body)) 21 | } 22 | if post.appearance == "sans" { 23 | MacEditorTextView( 24 | text: $combinedText, 25 | isFirstResponder: combinedText.isEmpty, 26 | isEditable: true, 27 | font: NSFont(name: PostAppearance.sans.rawValue, size: 17), 28 | onEditingChanged: onEditingChanged, 29 | onCommit: onCommit, 30 | onTextChange: onTextChange 31 | ) 32 | } else if post.appearance == "wrap" || post.appearance == "mono" || post.appearance == "code" { 33 | MacEditorTextView( 34 | text: $combinedText, 35 | isFirstResponder: combinedText.isEmpty, 36 | isEditable: true, 37 | font: NSFont(name: PostAppearance.mono.rawValue, size: 17), 38 | onEditingChanged: onEditingChanged, 39 | onCommit: onCommit, 40 | onTextChange: onTextChange 41 | ) 42 | } else { 43 | MacEditorTextView( 44 | text: $combinedText, 45 | isFirstResponder: combinedText.isEmpty, 46 | isEditable: true, 47 | font: NSFont(name: PostAppearance.serif.rawValue, size: 17), 48 | onEditingChanged: onEditingChanged, 49 | onCommit: onCommit, 50 | onTextChange: onTextChange 51 | ) 52 | } 53 | } 54 | .background(Color(NSColor.controlBackgroundColor)) 55 | .onAppear(perform: { 56 | if post.title.isEmpty { 57 | self.combinedText = post.body 58 | } else { 59 | self.combinedText = "# \(post.title)\n\n\(post.body)" 60 | } 61 | }) 62 | .onReceive(timer) { _ in 63 | if !post.body.isEmpty && hasBeenEdited { 64 | DispatchQueue.main.async { 65 | LocalStorageManager.standard.saveContext() 66 | hasBeenEdited = false 67 | } 68 | } 69 | } 70 | } 71 | 72 | private func onEditingChanged() { 73 | hasBeenEdited = true 74 | } 75 | 76 | private func onTextChange(_ text: String) { 77 | extractTitle(text) 78 | 79 | if !updatingFromServer { 80 | if post.status == PostStatus.published.rawValue { 81 | post.status = PostStatus.edited.rawValue 82 | } 83 | if post.status == PostStatus.edited.rawValue, 84 | post.title == model.editor.initialPostTitle, 85 | post.body == model.editor.initialPostBody { 86 | post.status = PostStatus.published.rawValue 87 | } 88 | } 89 | 90 | if updatingFromServer { 91 | self.updatingFromServer = false 92 | } 93 | hasBeenEdited = true 94 | } 95 | 96 | private func onCommit() { 97 | if !post.body.isEmpty && hasBeenEdited { 98 | DispatchQueue.main.async { 99 | LocalStorageManager.standard.saveContext() 100 | } 101 | } 102 | hasBeenEdited = false 103 | } 104 | 105 | private func extractTitle(_ text: String) { 106 | var detectedTitle: String 107 | 108 | if text.hasPrefix("# ") { 109 | let endOfTitleIndex = text.firstIndex(of: "\n") ?? text.endIndex 110 | detectedTitle = String(text[.. Date? { 40 | return updaterController.updater.lastUpdateCheckDate 41 | } 42 | 43 | @discardableResult 44 | func toggleAllowedChannels() -> Set { 45 | return updaterDelegate.allowedChannels(for: updaterController.updater) 46 | } 47 | 48 | } 49 | 50 | final class MacUpdatesViewModelDelegate: NSObject, SPUUpdaterDelegate { 51 | 52 | @AppStorage(WFDefaults.subscribeToBetaUpdates, store: UserDefaults.shared) 53 | var subscribeToBetaUpdates: Bool = false 54 | 55 | func allowedChannels(for updater: SPUUpdater) -> Set { 56 | let allowedChannels = Set(subscribeToBetaUpdates ? ["beta"] : []) 57 | return allowedChannels 58 | } 59 | 60 | } 61 | 62 | // This additional view is needed for the disabled state on the menu item to work properly before Monterey. 63 | // See https://stackoverflow.com/questions/68553092/menu-not-updating-swiftui-bug for more information 64 | struct CheckForUpdatesView: View { 65 | 66 | @ObservedObject var updaterViewModel: MacUpdatesViewModel 67 | 68 | var body: some View { 69 | Button("Check for Updates…", action: updaterViewModel.checkForUpdates) 70 | .disabled(!updaterViewModel.canCheckForUpdates) 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /macOS/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MacAccountView: View { 4 | @ObservedObject var preferences: PreferencesModel 5 | @ObservedObject var account: AccountModel 6 | 7 | @State var selectedView = 0 8 | 9 | var body: some View { 10 | TabView(selection: $selectedView) { 11 | Form { 12 | Section(header: Text("Login Details")) { 13 | AccountView(account: account) 14 | } 15 | } 16 | .tabItem { 17 | Image(systemName: "person.crop.circle") 18 | Text("Account") 19 | } 20 | .tag(0) 21 | VStack { 22 | PreferencesView(preferences: preferences) 23 | Spacer() 24 | } 25 | .tabItem { 26 | Image(systemName: "gear") 27 | Text("Preferences") 28 | } 29 | .tag(1) 30 | } 31 | } 32 | } 33 | 34 | struct SettingsView_AccountTabPreviews: PreviewProvider { 35 | static var previews: some View { 36 | MacAccountView(preferences: PreferencesModel(), account: AccountModel(), selectedView: 0) 37 | } 38 | } 39 | 40 | struct SettingsView_PreferencesTabPreviews: PreviewProvider { 41 | static var previews: some View { 42 | MacAccountView(preferences: PreferencesModel(), account: AccountModel(), selectedView: 1) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /macOS/macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | --------------------------------------------------------------------------------