├── .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 |
--------------------------------------------------------------------------------