Merge branch 'develop' into patch-1

# Conflicts:
#	Documentation/Setup.md
This commit is contained in:
CMK 2022-11-02 16:46:47 +08:00
commit e8370fa834
539 changed files with 14528 additions and 8710 deletions

17
.arkana.yml Normal file
View File

@ -0,0 +1,17 @@
import_name: 'ArkanaKeys'
namespace: 'Keys'
result_path: 'Dependencies'
flavors:
- AppStore
swift_declaration_strategy: let
should_generate_unit_tests: true
package_manager: spm
environments:
- Debug
- Release
global_secrets:
# nothing
environment_secrets:
# Will lookup for <Key>Debug and <Key>Release env vars (assuming no flavor was declared)
# Mastodon Push Notification Endpoint
- NotificationEndpoint

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
# Required
# https://<your-domain>/relay-to/development
NotificationEndpointDebug=""
# https://<your-domain>/relay-to/production
NotificationEndpointRelease=""

View File

@ -7,6 +7,6 @@ set -eo pipefail
xcodebuild -workspace Mastodon.xcworkspace \
-scheme Mastodon \
-destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \
-destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \
clean \
build | xcpretty

View File

@ -9,8 +9,7 @@ gem install bundler:2.3.11
# Install Ruby Gems
bundle install
# stub keys. DO NOT use in production
bundle exec pod keys set notification_endpoint "<endpoint>"
bundle exec pod keys set notification_endpoint_debug "<endpoint>"
# Setup notification endpoint
bundle exec arkana
bundle exec pod install

View File

@ -15,13 +15,14 @@ on:
jobs:
build:
name: CI build
runs-on: macos-11
runs-on: macos-12
steps:
- name: checkout
uses: actions/checkout@v2
- name: force Xcode 13.2.1
run: sudo xcode-select -switch /Applications/Xcode_13.2.1.app
- name: setup
env:
NotificationEndpointDebug: ${{ secrets.NotificationEndpointDebug }}
NotificationEndpointRelease: ${{ secrets.NotificationEndpointRelease }}
run: exec ./.github/scripts/setup.sh
- name: build
run: exec ./.github/scripts/build.sh

5
.gitignore vendored
View File

@ -122,4 +122,7 @@ xcuserdata
# Localization/StringsConvertor/input
Localization/StringsConvertor/output
.DS_Store
.DS_Store
env/**/**
!env/.env

View File

@ -1,18 +0,0 @@
//
// AppShared.h
// AppShared
//
// Created by MainasuK Cirno on 2021-4-27.
//
#import <Foundation/Foundation.h>
//! Project version number for AppShared.
FOUNDATION_EXPORT double AppSharedVersionNumber;
//! Project version string for AppShared.
FOUNDATION_EXPORT const unsigned char AppSharedVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <AppShared/PublicHeader.h>

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.4.5</string>
<key>CFBundleVersion</key>
<string>144</string>
</dict>
</plist>

View File

@ -1,13 +1,12 @@
# Acknowledgments
- [Alamofire](https://github.com/Alamofire/Alamofire)
- [AlamofireImage](https://github.com/Alamofire/AlamofireImage)
- [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator)
- [Alamofire](https://github.com/Alamofire/Alamofire)
- [Arkana](https://github.com/rogerluan/arkana)
- [CommonOSLog](https://github.com/mainasuk/CommonOSLog)
- [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift)
- [DateToolSwift](https://github.com/MatthewYork/DateTools)
- [DiffableDataSources](https://github.com/ra1028/DiffableDataSources)
- [DifferenceKit](https://github.com/ra1028/DifferenceKit)
- [FLAnimatedImage](https://github.com/Flipboard/FLAnimatedImage)
- [FLEX](https://github.com/FLEXTool/FLEX)
- [FPSIndicator](https://github.com/MainasuK/FPSIndicator)
@ -26,10 +25,10 @@
- [SwiftGen](https://github.com/SwiftGen/SwiftGen)
- [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect)
- [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)
- [Tabman](https://github.com/uias/Tabman)
- [TabBarPager](https://github.com/TwidereProject/TabBarPager)
- [TwidereX-iOS](https://github.com/TwidereProject/TwidereX-iOS)
- [Tabman](https://github.com/uias/Tabman)
- [ThirdPartyMailer](https://github.com/vtourraine/ThirdPartyMailer)
- [TOCropViewController](https://github.com/TimOliver/TOCropViewController)
- [TwidereX-iOS](https://github.com/TwidereProject/TwidereX-iOS)
- [TwitterProfile](https://github.com/OfTheWolf/TwitterProfile)
- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder)
- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder)

View File

@ -12,7 +12,7 @@ Install the latest version of Xcode from the App Store or Apple Developer Downlo
This guide may not suit your machine and actually setup procedure may change in the future. Please file the issue or Pull Request if there are any problems.
## CocoaPods
The app use [CocoaPods]() and [CocoaPods-Keys](https://github.com/orta/cocoapods-keys). Ruby Gems are managed through Bundler. The M1 Mac needs virtual ruby env to workaround compatibility issues.
The app use [CocoaPods]() and [Arkana](https://github.com/rogerluan/arkana). Ruby Gems are managed through Bundler. The M1 Mac needs virtual ruby env to workaround compatibility issues.
#### Intel Mac
@ -52,6 +52,13 @@ bundle install
bundle install
bundle exec pod clean
# setup arkana
# please check the `.env.example` to create your's or use the empty example directly
bundle exec arkana -e ./env/.env
# clean pods
bundle exec pod clean
# make install
bundle exec pod install --repo-update
@ -59,14 +66,14 @@ bundle exec pod install --repo-update
open Mastodon.xcworkspace
```
The CocoaPods-Keys plugin will request the push notification endpoint. You can fulfill the empty string and set it later. To setup the push notification. Please check section `Push Notification` below.
The Arkana plugin will setup the push notification endpoint. You can use the empty template from `./env/.env` or use your own `.env` file. To setup the push notification. Please check section `Push Notification` below.
The app requires the `App Group` capability. To make sure it works for your developer membership. Please check [AppSecret.swift](../AppShared/AppSecret.swift) file and set another unique `groupID` and update `App Group` settings.
The app requires the `App Group` capability. To make sure it works for your developer membership. Please check [AppSecret.swift](../MastodonSDK/Sources/MastodonCore/AppSecret.swift) file and set another unique `groupID` and update `App Group` settings.
#### Push Notification (Optional)
The app is compatible with [toot-relay](https://github.com/DagAgren/toot-relay) APNs. You can set your push notification endpoint via CocoaPods-Keys. There are two endpoints:
- notification_endpoint: for `RELEASE` usage
- notification_endpoint_debug: for `DEBUG` usage
The app is compatible with [toot-relay](https://github.com/DagAgren/toot-relay) APNs. You can set your push notification endpoint via Arkana. There are two endpoints:
- NotificationEndpointDebug: for `DEBUG` usage. e.g. `https://<your.domin>/relay-to/development`
- NotificationEndpointRelease: for `RELEASE` usage. e.g. `https://<your.domin>/relay-to/production`
Please check the [Establishing a Certificate-Based Connection to APNs
](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_certificate-based_connection_to_apns) document to generate the certificate and exports the p12 file.

View File

@ -1,6 +1,7 @@
source "https://rubygems.org"
gem 'arkana'
gem "cocoapods"
gem "cocoapods-clean"
gem "cocoapods-keys"
gem "xcpretty"

View File

@ -3,20 +3,21 @@ GEM
specs:
CFPropertyList (3.0.5)
rexml
RubyInline (3.12.5)
ZenTest (~> 4.3)
ZenTest (4.12.1)
activesupport (6.1.5.1)
activesupport (6.1.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
arkana (1.2.0)
colorize (~> 0.8)
dotenv (~> 2.7)
yaml (~> 0.2)
atomos (0.1.3)
claide (1.1.0)
cocoapods (1.11.3)
@ -50,9 +51,6 @@ GEM
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.5)
cocoapods-downloader (1.6.3)
cocoapods-keys (2.2.1)
dotenv
osx_keychain
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.1)
@ -61,8 +59,9 @@ GEM
netrc (~> 0.11)
cocoapods-try (1.2.0)
colored2 (3.1.2)
colorize (0.8.1)
concurrent-ruby (1.1.10)
dotenv (2.7.6)
dotenv (2.8.1)
escape (0.0.4)
ethon (0.15.0)
ffi (>= 1.15.0)
@ -71,39 +70,42 @@ GEM
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.10.0)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
json (2.6.1)
minitest (5.15.0)
json (2.6.2)
minitest (5.16.3)
molinillo (0.8.0)
nanaimo (0.3.0)
nap (1.1.0)
netrc (0.11.0)
osx_keychain (1.0.2)
RubyInline (~> 3)
public_suffix (4.0.7)
rexml (3.2.5)
rouge (2.0.7)
ruby-macho (2.5.1)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (2.0.4)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
xcodeproj (1.21.0)
xcodeproj (1.22.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
zeitwerk (2.5.4)
xcpretty (0.3.0)
rouge (~> 2.0.7)
yaml (0.2.0)
zeitwerk (2.6.3)
PLATFORMS
ruby
arm64-darwin-21
DEPENDENCIES
arkana
cocoapods
cocoapods-clean
cocoapods-keys
xcpretty
BUNDLED WITH
2.3.11
2.3.17

View File

@ -224,17 +224,17 @@
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>zero</key>
<string>لا إعاد تدوين</string>
<string>لَا إعادَةُ تَدوين</string>
<key>one</key>
<string>إعادةُ تدوينٍ واحِدة</string>
<string>إعادَةُ تَدوينٍ واحِدَة</string>
<key>two</key>
<string>إعادتا تدوين</string>
<string>إعادَتَا تَدوين</string>
<key>few</key>
<string>%ld إعاداتِ تدوين</string>
<string>%ld إعادَاتِ تَدوين</string>
<key>many</key>
<string>%ld إعادةٍ للتدوين</string>
<string>%ld إعادَةٍ لِلتَّدوين</string>
<key>other</key>
<string>%ld إعادة تدوين</string>
<string>%ld إعادَة تَدوين</string>
</dict>
</dict>
<key>plural.count.reply</key>

View File

@ -348,7 +348,7 @@
"Publishing": "Publication en cours ...",
"accessibility": {
"logo_label": "Bouton logo",
"logo_hint": "Tap to scroll to top and tap again to previous location"
"logo_hint": "Appuyez pour faire défiler vers le haut et appuyez à nouveau vers l'emplacement précédent"
}
}
},
@ -546,10 +546,10 @@
"show_mentions": "Afficher les mentions"
},
"follow_request": {
"accept": "Accept",
"accepted": "Accepted",
"reject": "reject",
"rejected": "Rejected"
"accept": "Accepter",
"accepted": "Accepté",
"reject": "rejeter",
"rejected": "Reje"
}
},
"thread": {

View File

@ -546,10 +546,10 @@
"show_mentions": "Qalkirinan nîşan bike"
},
"follow_request": {
"accept": "Accept",
"accepted": "Accepted",
"reject": "reject",
"rejected": "Rejected"
"accept": "Bipejirîne",
"accepted": "Pejirandî",
"reject": "nepejirîne",
"rejected": "Nepejirandî"
}
},
"thread": {

View File

@ -546,10 +546,10 @@
"show_mentions": "แสดงการกล่าวถึง"
},
"follow_request": {
"accept": "Accept",
"accepted": "Accepted",
"reject": "reject",
"rejected": "Rejected"
"accept": "ยอมรับ",
"accepted": "ยอมรับแล้ว",
"reject": "ปฏิเสธ",
"rejected": "ปฏิเสธแล้ว"
}
},
"thread": {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1330"
LastUpgradeVersion = "1400"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1330"
LastUpgradeVersion = "1400"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1330"
LastUpgradeVersion = "1400"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -4,13 +4,6 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>AppShared.xcscheme_^#shared#^_</key>
<dict>
<key>isShown</key>
<true/>
<key>orderHint</key>
<integer>9</integer>
</dict>
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
@ -19,32 +12,27 @@
<key>Mastodon - Profile.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>1</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>12</integer>
<integer>5</integer>
</dict>
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>5</integer>
<integer>2</integer>
</dict>
<key>Mastodon - Snapshot.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>7</integer>
</dict>
<key>Mastodon - ar.xcscheme</key>
<dict>
<key>orderHint</key>
<integer>8</integer>
<integer>3</integer>
</dict>
<key>Mastodon - ar.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>11</integer>
<integer>4</integer>
</dict>
<key>Mastodon - ca.xcscheme_^#shared#^_</key>
<dict>
@ -114,7 +102,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>29</integer>
<integer>20</integer>
</dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict>
@ -129,12 +117,12 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>31</integer>
<integer>24</integer>
</dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>30</integer>
<integer>25</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
@ -164,6 +152,11 @@
<key>primary</key>
<true/>
</dict>
<key>DB8FABC526AEC7B2008E5AF4</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@ -1,259 +1,257 @@
{
"object": {
"pins": [
{
"package": "Alamofire",
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
"state": {
"branch": null,
"revision": "354dda32d89fc8cd4f5c46487f64957d355f53d8",
"version": "5.6.1"
}
},
{
"package": "AlamofireImage",
"repositoryURL": "https://github.com/Alamofire/AlamofireImage.git",
"state": {
"branch": null,
"revision": "98cbb00ce0ec5fc8e52a5b50a6bfc08d3e5aee10",
"version": "4.2.0"
}
},
{
"package": "AlamofireNetworkActivityIndicator",
"repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator",
"state": {
"branch": null,
"revision": "392bed083e8d193aca16bfa684ee24e4bcff0510",
"version": "3.1.0"
}
},
{
"package": "CommonOSLog",
"repositoryURL": "https://github.com/MainasuK/CommonOSLog",
"state": {
"branch": null,
"revision": "c121624a30698e9886efe38aebb36ff51c01b6c2",
"version": "0.1.1"
}
},
{
"package": "DiffableDataSources",
"repositoryURL": "https://github.com/MainasuK/DiffableDataSources.git",
"state": {
"branch": "feature/async-display-table",
"revision": "73393a97690959d24387c95594c045c62d9c47cf",
"version": null
}
},
{
"package": "DifferenceKit",
"repositoryURL": "https://github.com/ra1028/DifferenceKit.git",
"state": {
"branch": null,
"revision": "62745d7780deef4a023a792a1f8f763ec7bf9705",
"version": "1.2.0"
}
},
{
"package": "FaviconFinder",
"repositoryURL": "https://github.com/will-lumley/FaviconFinder.git",
"state": {
"branch": null,
"revision": "1f74844f77f79b95c0bb0130b3a87d4f340e6d3a",
"version": "3.3.0"
}
},
{
"package": "FLAnimatedImage",
"repositoryURL": "https://github.com/Flipboard/FLAnimatedImage.git",
"state": {
"branch": null,
"revision": "e7f9fd4681ae41bf6f3056db08af4f401d61da52",
"version": "1.0.16"
}
},
{
"package": "FPSIndicator",
"repositoryURL": "https://github.com/MainasuK/FPSIndicator.git",
"state": {
"branch": null,
"revision": "e4a5067ccd5293b024c767f09e51056afd4a4796",
"version": "1.1.0"
}
},
{
"package": "Fuzi",
"repositoryURL": "https://github.com/cezheng/Fuzi.git",
"state": {
"branch": null,
"revision": "f08c8323da21e985f3772610753bcfc652c2103f",
"version": "3.1.3"
}
},
{
"package": "KeychainAccess",
"repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git",
"state": {
"branch": null,
"revision": "84e546727d66f1adc5439debad16270d0fdd04e7",
"version": "4.2.2"
}
},
{
"package": "MetaTextKit",
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
"state": {
"branch": null,
"revision": "dcd5255d6930c2fab408dc8562c577547e477624",
"version": "2.2.5"
}
},
{
"package": "Nuke",
"repositoryURL": "https://github.com/kean/Nuke.git",
"state": {
"branch": null,
"revision": "0ea7545b5c918285aacc044dc75048625c8257cc",
"version": "10.8.0"
}
},
{
"package": "NukeFLAnimatedImagePlugin",
"repositoryURL": "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git",
"state": {
"branch": null,
"revision": "b59c346a7d536336db3b0f12c72c6e53ee709e16",
"version": "8.0.0"
}
},
{
"package": "Pageboy",
"repositoryURL": "https://github.com/uias/Pageboy",
"state": {
"branch": null,
"revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6",
"version": "3.6.2"
}
},
{
"package": "PanModal",
"repositoryURL": "https://github.com/slackhq/PanModal.git",
"state": {
"branch": null,
"revision": "b012aecb6b67a8e46369227f893c12544846613f",
"version": "1.2.7"
}
},
{
"package": "SDWebImage",
"repositoryURL": "https://github.com/SDWebImage/SDWebImage.git",
"state": {
"branch": null,
"revision": "2e63d0061da449ad0ed130768d05dceb1496de44",
"version": "5.12.5"
}
},
{
"package": "swift-collections",
"repositoryURL": "https://github.com/apple/swift-collections.git",
"state": {
"branch": null,
"revision": "9d8719c8bebdc79740b6969c912ac706eb721d7a",
"version": "0.0.7"
}
},
{
"package": "swift-nio",
"repositoryURL": "https://github.com/apple/swift-nio.git",
"state": {
"branch": null,
"revision": "546610d52b19be3e19935e0880bb06b9c03f5cef",
"version": "1.14.4"
}
},
{
"package": "swift-nio-zlib-support",
"repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git",
"state": {
"branch": null,
"revision": "37760e9a52030bb9011972c5213c3350fa9d41fd",
"version": "1.0.0"
}
},
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "41e7c263fb8c277e980ebcb9b0b5f6031d3d4886",
"version": "2.4.2"
}
},
{
"package": "Introspect",
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
"state": {
"branch": null,
"revision": "f2616860a41f9d9932da412a8978fec79c06fe24",
"version": "0.1.4"
}
},
{
"package": "SwiftyJSON",
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",
"state": {
"branch": null,
"revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
"version": "5.0.1"
}
},
{
"package": "TabBarPager",
"repositoryURL": "https://github.com/TwidereProject/TabBarPager.git",
"state": {
"branch": null,
"revision": "488aa66d157a648901b61721212c0dec23d27ee5",
"version": "0.1.0"
}
},
{
"package": "Tabman",
"repositoryURL": "https://github.com/uias/Tabman",
"state": {
"branch": null,
"revision": "a9f10cb862a32e6a22549836af013abd6b0692d3",
"version": "2.12.0"
}
},
{
"package": "ThirdPartyMailer",
"repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git",
"state": {
"branch": null,
"revision": "779da6ce0793b461ccbbac2804755c1e29b6fa63",
"version": "1.8.0"
}
},
{
"package": "TOCropViewController",
"repositoryURL": "https://github.com/TimOliver/TOCropViewController.git",
"state": {
"branch": null,
"revision": "d0470491f56e734731bbf77991944c0dfdee3e0e",
"version": "2.6.1"
}
},
{
"package": "UITextView+Placeholder",
"repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder.git",
"state": {
"branch": null,
"revision": "20f513ded04a040cdf5467f0891849b1763ede3b",
"version": "1.4.1"
}
"pins" : [
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "354dda32d89fc8cd4f5c46487f64957d355f53d8",
"version" : "5.6.1"
}
]
},
"version": 1
},
{
"identity" : "alamofireimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/AlamofireImage.git",
"state" : {
"revision" : "98cbb00ce0ec5fc8e52a5b50a6bfc08d3e5aee10",
"version" : "4.2.0"
}
},
{
"identity" : "commonoslog",
"kind" : "remoteSourceControl",
"location" : "https://github.com/MainasuK/CommonOSLog",
"state" : {
"revision" : "c121624a30698e9886efe38aebb36ff51c01b6c2",
"version" : "0.1.1"
}
},
{
"identity" : "faviconfinder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/will-lumley/FaviconFinder.git",
"state" : {
"revision" : "1f74844f77f79b95c0bb0130b3a87d4f340e6d3a",
"version" : "3.3.0"
}
},
{
"identity" : "flanimatedimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Flipboard/FLAnimatedImage.git",
"state" : {
"revision" : "e7f9fd4681ae41bf6f3056db08af4f401d61da52",
"version" : "1.0.16"
}
},
{
"identity" : "fpsindicator",
"kind" : "remoteSourceControl",
"location" : "https://github.com/MainasuK/FPSIndicator.git",
"state" : {
"revision" : "e4a5067ccd5293b024c767f09e51056afd4a4796",
"version" : "1.1.0"
}
},
{
"identity" : "fuzi",
"kind" : "remoteSourceControl",
"location" : "https://github.com/cezheng/Fuzi.git",
"state" : {
"revision" : "f08c8323da21e985f3772610753bcfc652c2103f",
"version" : "3.1.3"
}
},
{
"identity" : "keychainaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kishikawakatsumi/KeychainAccess.git",
"state" : {
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
"version" : "4.2.2"
}
},
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "44e891bdb61426a95e31492a67c7c0dfad1f87c5",
"version" : "7.4.1"
}
},
{
"identity" : "metatextkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TwidereProject/MetaTextKit.git",
"state" : {
"revision" : "dcd5255d6930c2fab408dc8562c577547e477624",
"version" : "2.2.5"
}
},
{
"identity" : "nuke",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke.git",
"state" : {
"revision" : "0ea7545b5c918285aacc044dc75048625c8257cc",
"version" : "10.8.0"
}
},
{
"identity" : "nuke-flanimatedimage-plugin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git",
"state" : {
"revision" : "b59c346a7d536336db3b0f12c72c6e53ee709e16",
"version" : "8.0.0"
}
},
{
"identity" : "pageboy",
"kind" : "remoteSourceControl",
"location" : "https://github.com/uias/Pageboy",
"state" : {
"revision" : "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6",
"version" : "3.6.2"
}
},
{
"identity" : "panmodal",
"kind" : "remoteSourceControl",
"location" : "https://github.com/slackhq/PanModal.git",
"state" : {
"revision" : "b012aecb6b67a8e46369227f893c12544846613f",
"version" : "1.2.7"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage.git",
"state" : {
"revision" : "2e63d0061da449ad0ed130768d05dceb1496de44",
"version" : "5.12.5"
}
},
{
"identity" : "stripes",
"kind" : "remoteSourceControl",
"location" : "https://github.com/eneko/Stripes.git",
"state" : {
"revision" : "d533fd44b8043a3abbf523e733599173d6f98c11",
"version" : "0.2.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
"version" : "1.0.3"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "546610d52b19be3e19935e0880bb06b9c03f5cef",
"version" : "1.14.4"
}
},
{
"identity" : "swift-nio-zlib-support",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-zlib-support.git",
"state" : {
"revision" : "37760e9a52030bb9011972c5213c3350fa9d41fd",
"version" : "1.0.0"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "41e7c263fb8c277e980ebcb9b0b5f6031d3d4886",
"version" : "2.4.2"
}
},
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : {
"revision" : "f2616860a41f9d9932da412a8978fec79c06fe24",
"version" : "0.1.4"
}
},
{
"identity" : "swiftyjson",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SwiftyJSON/SwiftyJSON.git",
"state" : {
"revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
"version" : "5.0.1"
}
},
{
"identity" : "tabbarpager",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TwidereProject/TabBarPager.git",
"state" : {
"revision" : "488aa66d157a648901b61721212c0dec23d27ee5",
"version" : "0.1.0"
}
},
{
"identity" : "tabman",
"kind" : "remoteSourceControl",
"location" : "https://github.com/uias/Tabman",
"state" : {
"revision" : "4a4f7c755b875ffd4f9ef10d67a67883669d2465",
"version" : "2.13.0"
}
},
{
"identity" : "thirdpartymailer",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vtourraine/ThirdPartyMailer.git",
"state" : {
"revision" : "44c1cfaa6969963f22691aa67f88a69e3b6d651f",
"version" : "2.1.0"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController.git",
"state" : {
"revision" : "d0470491f56e734731bbf77991944c0dfdee3e0e",
"version" : "2.6.1"
}
},
{
"identity" : "uihostingconfigurationbackport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/woxtu/UIHostingConfigurationBackport.git",
"state" : {
"revision" : "6091f2d38faa4b24fc2ca0389c651e2f666624a3",
"version" : "0.1.0"
}
},
{
"identity" : "uitextview-placeholder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/MainasuK/UITextView-Placeholder.git",
"state" : {
"revision" : "20f513ded04a040cdf5467f0891849b1763ede3b",
"version" : "1.4.1"
}
}
],
"version" : 2
}

View File

@ -6,6 +6,7 @@
//
import UIKit
import MastodonCore
protocol NeedsDependency: AnyObject {
var context: AppContext! { get set }

View File

@ -8,8 +8,9 @@ import UIKit
import Combine
import SafariServices
import CoreDataStack
import MastodonSDK
import PanModal
import MastodonSDK
import MastodonCore
import MastodonAsset
import MastodonLocalization
@ -19,7 +20,9 @@ final public class SceneCoordinator {
private weak var scene: UIScene!
private weak var sceneDelegate: SceneDelegate!
private weak var appContext: AppContext!
private(set) weak var appContext: AppContext!
private(set) var authContext: AuthContext?
let id = UUID().uuidString
@ -29,7 +32,11 @@ final public class SceneCoordinator {
private(set) var secondaryStackHashValues = Set<Int>()
init(scene: UIScene, sceneDelegate: SceneDelegate, appContext: AppContext) {
init(
scene: UIScene,
sceneDelegate: SceneDelegate,
appContext: AppContext
) {
self.scene = scene
self.sceneDelegate = sceneDelegate
self.appContext = appContext
@ -38,100 +45,83 @@ final public class SceneCoordinator {
appContext.notificationService.requestRevealNotificationPublisher
.receive(on: DispatchQueue.main)
.compactMap { [weak self] pushNotification -> AnyPublisher<MastodonPushNotification?, Never> in
guard let self = self else { return Just(nil).eraseToAnyPublisher() }
// skip if no available account
guard let currentActiveAuthenticationBox = appContext.authenticationService.activeMastodonAuthenticationBox.value else {
return Just(nil).eraseToAnyPublisher()
}
let accessToken = pushNotification.accessToken // use raw accessToken value without normalize
if currentActiveAuthenticationBox.userAuthorization.accessToken == accessToken {
// do nothing if notification for current account
return Just(pushNotification).eraseToAnyPublisher()
} else {
// switch to notification's account
let request = MastodonAuthentication.sortedFetchRequest
request.predicate = MastodonAuthentication.predicate(userAccessToken: accessToken)
request.returnsObjectsAsFaults = false
request.fetchLimit = 1
do {
guard let authentication = try appContext.managedObjectContext.fetch(request).first else {
return Just(nil).eraseToAnyPublisher()
}
let domain = authentication.domain
let userID = authentication.userID
return appContext.authenticationService.activeMastodonUser(domain: domain, userID: userID)
.receive(on: DispatchQueue.main)
.map { [weak self] result -> MastodonPushNotification? in
guard let self = self else { return nil }
switch result {
case .success:
// reset view hierarchy
self.setup()
return pushNotification
case .failure:
return nil
}
}
.delay(for: 1, scheduler: DispatchQueue.main) // set delay to slow transition (not must)
.eraseToAnyPublisher()
} catch {
assertionFailure(error.localizedDescription)
return Just(nil).eraseToAnyPublisher()
}
}
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { [weak self] pushNotification in
.sink(receiveValue: { [weak self] pushNotification in
guard let self = self else { return }
guard let pushNotification = pushNotification else { return }
// redirect to notification tab
self.switchToTabBar(tab: .notification)
// Delay in next run loop
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Note:
// show (push) on phone and pad
let from: UIViewController? = {
if let splitViewController = self.splitViewController {
if splitViewController.compactMainTabBarViewController.topMost?.view.window != nil {
// compact
return splitViewController.compactMainTabBarViewController.topMost
} else {
// expand
return splitViewController.contentSplitViewController.mainTabBarController.topMost
Task {
guard let currentActiveAuthenticationBox = self.authContext?.mastodonAuthenticationBox else { return }
let accessToken = pushNotification.accessToken // use raw accessToken value without normalize
if currentActiveAuthenticationBox.userAuthorization.accessToken == accessToken {
// do nothing if notification for current account
return
} else {
// switch to notification's account
let request = MastodonAuthentication.sortedFetchRequest
request.predicate = MastodonAuthentication.predicate(userAccessToken: accessToken)
request.returnsObjectsAsFaults = false
request.fetchLimit = 1
do {
guard let authentication = try appContext.managedObjectContext.fetch(request).first else {
return
}
} else {
return self.tabBarController.topMost
let domain = authentication.domain
let userID = authentication.userID
let isSuccess = try await appContext.authenticationService.activeMastodonUser(domain: domain, userID: userID)
guard isSuccess else { return }
self.setup()
try await Task.sleep(nanoseconds: .second * 1)
// redirect to notification tab
self.switchToTabBar(tab: .notification)
// Delay in next run loop
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Note:
// show (push) on phone and pad
let from: UIViewController? = {
if let splitViewController = self.splitViewController {
if splitViewController.compactMainTabBarViewController.topMost?.view.window != nil {
// compact
return splitViewController.compactMainTabBarViewController.topMost
} else {
// expand
return splitViewController.contentSplitViewController.mainTabBarController.topMost
}
} else {
return self.tabBarController.topMost
}
}()
// show notification related content
guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: pushNotification.notificationType) else { return }
guard let authContext = self.authContext else { return }
let notificationID = String(pushNotification.notificationID)
switch type {
case .follow:
let profileViewModel = RemoteProfileViewModel(context: appContext, authContext: authContext, notificationID: notificationID)
_ = self.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show)
case .followRequest:
// do nothing
break
case .mention, .reblog, .favourite, .poll, .status:
let threadViewModel = RemoteThreadViewModel(context: appContext, authContext: authContext, notificationID: notificationID)
_ = self.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show)
case ._other:
assertionFailure()
break
}
} // end DispatchQueue.main.async
} catch {
assertionFailure(error.localizedDescription)
return
}
}()
// show notification related content
guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: pushNotification.notificationType) else { return }
let notificationID = String(pushNotification.notificationID)
switch type {
case .follow:
let profileViewModel = RemoteProfileViewModel(context: appContext, notificationID: notificationID)
self.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show)
case .followRequest:
// do nothing
break
case .mention, .reblog, .favourite, .poll, .status:
let threadViewModel = RemoteThreadViewModel(context: appContext, notificationID: notificationID)
self.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show)
case ._other:
assertionFailure()
break
}
} // end DispatchQueue.main.async
}
} // end Task
})
.store(in: &disposeBag)
}
}
@ -173,7 +163,7 @@ extension SceneCoordinator {
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
// profile
case accountList
case accountList(viewModel: AccountListViewModel)
case profile(viewModel: ProfileViewModel)
case favorite(viewModel: FavoriteViewModel)
case follower(viewModel: FollowerListViewModel)
@ -181,6 +171,7 @@ extension SceneCoordinator {
case familiarFollowers(viewModel: FamiliarFollowersViewModel)
case rebloggedBy(viewModel: UserListViewModel)
case favoritedBy(viewModel: UserListViewModel)
case bookmark(viewModel: BookmarkViewModel)
// setting
case settings(viewModel: SettingsViewModel)
@ -223,55 +214,61 @@ extension SceneCoordinator {
func setup() {
let rootViewController: UIViewController
switch UIDevice.current.userInterfaceIdiom {
case .phone:
let viewController = MainTabBarController(context: appContext, coordinator: self)
self.splitViewController = nil
self.tabBarController = viewController
rootViewController = viewController
default:
let splitViewController = RootSplitViewController(context: appContext, coordinator: self)
self.splitViewController = splitViewController
self.tabBarController = splitViewController.contentSplitViewController.mainTabBarController
rootViewController = splitViewController
}
let wizardViewController = WizardViewController()
if !wizardViewController.items.isEmpty,
let delegate = rootViewController as? WizardViewControllerDelegate
{
// do not add as child view controller.
// otherwise, the tab bar controller will add as a new tab
wizardViewController.delegate = delegate
wizardViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
wizardViewController.view.frame = rootViewController.view.bounds
rootViewController.view.addSubview(wizardViewController.view)
self.wizardViewController = wizardViewController
}
sceneDelegate.window?.rootViewController = rootViewController
}
func setupOnboardingIfNeeds(animated: Bool) {
// Check user authentication status and show onboarding if needs
do {
let request = MastodonAuthentication.sortedFetchRequest
if try appContext.managedObjectContext.count(for: request) == 0 {
let request = MastodonAuthentication.activeSortedFetchRequest // use active order
let _authentication = try appContext.managedObjectContext.fetch(request).first
let _authContext = _authentication.flatMap { AuthContext(authentication: $0) }
self.authContext = _authContext
switch UIDevice.current.userInterfaceIdiom {
case .phone:
let viewController = MainTabBarController(context: appContext, coordinator: self, authContext: _authContext)
self.splitViewController = nil
self.tabBarController = viewController
rootViewController = viewController
default:
let splitViewController = RootSplitViewController(context: appContext, coordinator: self, authContext: _authContext)
self.splitViewController = splitViewController
self.tabBarController = splitViewController.contentSplitViewController.mainTabBarController
rootViewController = splitViewController
}
sceneDelegate.window?.rootViewController = rootViewController // base: main
if _authContext == nil { // entry #1: welcome
DispatchQueue.main.async {
self.present(
_ = self.present(
scene: .welcome,
from: self.sceneDelegate.window?.rootViewController,
transition: .modal(animated: animated, completion: nil)
transition: .modal(animated: true, completion: nil)
)
}
} else {
let wizardViewController = WizardViewController()
if !wizardViewController.items.isEmpty,
let delegate = rootViewController as? WizardViewControllerDelegate
{
// do not add as child view controller.
// otherwise, the tab bar controller will add as a new tab
wizardViewController.delegate = delegate
wizardViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
wizardViewController.view.frame = rootViewController.view.bounds
rootViewController.view.addSubview(wizardViewController.view)
self.wizardViewController = wizardViewController
}
}
} catch {
assertionFailure(error.localizedDescription)
Task {
try? await Task.sleep(nanoseconds: .second * 2)
setup() // entry #2: retry
} // end Task
}
}
@discardableResult
@MainActor
@discardableResult
func present(scene: Scene, from sender: UIViewController?, transition: Transition) -> UIViewController? {
guard let viewController = get(scene: scene) else {
return nil
@ -430,13 +427,18 @@ private extension SceneCoordinator {
let _viewController = HashtagTimelineViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .accountList:
case .accountList(let viewModel):
let _viewController = AccountListViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .profile(let viewModel):
let _viewController = ProfileViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .bookmark(let viewModel):
let _viewController = BookmarkViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .favorite(let viewModel):
let _viewController = FavoriteViewController()
_viewController.viewModel = viewModel

View File

@ -5,11 +5,11 @@
// Created by sxiaojian on 2021/4/22.
//
import UIKit
import CoreData
import CoreDataStack
import Foundation
import MastodonCore
import MastodonSDK
import UIKit
enum SelectedAccountSection: Equatable, Hashable {
case main

View File

@ -1,62 +0,0 @@
//
// CustomEmojiPickerSection.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-24.
//
import UIKit
enum CustomEmojiPickerSection: Equatable, Hashable {
case emoji(name: String)
}
extension CustomEmojiPickerSection {
static func collectionViewDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency
) -> UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem> {
let dataSource = UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
guard let _ = dependency else { return nil }
switch item {
case .emoji(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell
let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill)
.af.imageRounded(withCornerRadius: 4)
let isAnimated = !UserDefaults.shared.preferredStaticEmoji
let url = URL(string: isAnimated ? attribute.emoji.url : attribute.emoji.staticURL)
cell.emojiImageView.sd_setImage(
with: url,
placeholderImage: placeholder,
options: [],
context: nil
)
cell.accessibilityLabel = attribute.emoji.shortcode
return cell
}
}
dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in
guard let dataSource = dataSource else { return nil }
let sections = dataSource.snapshot().sectionIdentifiers
guard indexPath.section < sections.count else { return nil }
let section = sections[indexPath.section]
switch kind {
case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self):
let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView
switch section {
case .emoji(let name):
header.titleLabel.text = name
}
return header
default:
assertionFailure()
return nil
}
}
return dataSource
}
}

View File

@ -7,6 +7,7 @@
import os.log
import UIKit
import MastodonCore
import MastodonUI
import MastodonSDK
@ -22,13 +23,16 @@ extension DiscoverySection {
static let logger = Logger(subsystem: "DiscoverySection", category: "logic")
class Configuration {
let authContext: AuthContext
weak var profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate?
let familiarFollowers: Published<[Mastodon.Entity.FamiliarFollowers]>.Publisher?
public init(
authContext: AuthContext,
profileCardTableViewCellDelegate: ProfileCardTableViewCellDelegate? = nil,
familiarFollowers: Published<[Mastodon.Entity.FamiliarFollowers]>.Publisher? = nil
) {
self.authContext = authContext
self.profileCardTableViewCellDelegate = profileCardTableViewCellDelegate
self.familiarFollowers = familiarFollowers
}
@ -72,11 +76,9 @@ extension DiscoverySection {
} else {
cell.profileCardView.viewModel.familiarFollowers = nil
}
// bind me
cell.profileCardView.viewModel.relationshipViewModel.me = configuration.authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
}
context.authenticationService.activeMastodonAuthentication
.map { $0?.user }
.assign(to: \.me, on: cell.profileCardView.viewModel.relationshipViewModel)
.store(in: &cell.disposeBag)
return cell
case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell

View File

@ -14,6 +14,8 @@ import UIKit
import MetaTextKit
import MastodonMeta
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
enum NotificationSection: Equatable, Hashable {
@ -23,6 +25,7 @@ enum NotificationSection: Equatable, Hashable {
extension NotificationSection {
struct Configuration {
let authContext: AuthContext
weak var notificationTableViewCellDelegate: NotificationTableViewCellDelegate?
let filterContext: Mastodon.Entity.Filter.Context?
let activeFilters: Published<[Mastodon.Entity.Filter]>.Publisher?
@ -73,21 +76,20 @@ extension NotificationSection {
viewModel: NotificationTableViewCell.ViewModel,
configuration: Configuration
) {
cell.notificationView.viewModel.authContext = configuration.authContext
StatusSection.setupStatusPollDataSource(
context: context,
authContext: configuration.authContext,
statusView: cell.notificationView.statusView
)
StatusSection.setupStatusPollDataSource(
context: context,
authContext: configuration.authContext,
statusView: cell.notificationView.quoteStatusView
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.notificationView.viewModel)
.store(in: &cell.disposeBag)
cell.configure(
tableView: tableView,
viewModel: viewModel,

View File

@ -8,6 +8,7 @@
import os
import UIKit
import Combine
import MastodonCore
import MastodonMeta
import MastodonLocalization

View File

@ -13,6 +13,7 @@ import UIKit
import MetaTextKit
import MastodonMeta
import Combine
import MastodonCore
enum RecommendAccountSection: Equatable, Hashable {
case main
@ -132,6 +133,7 @@ enum RecommendAccountSection: Equatable, Hashable {
extension RecommendAccountSection {
struct Configuration {
let authContext: AuthContext
weak var suggestionAccountTableViewCellDelegate: SuggestionAccountTableViewCellDelegate?
}
@ -149,10 +151,7 @@ extension RecommendAccountSection {
cell.configure(user: user)
}
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.viewModel)
.store(in: &cell.disposeBag)
cell.viewModel.userIdentifier = configuration.authContext.mastodonAuthenticationBox
cell.delegate = configuration.suggestionAccountTableViewCellDelegate
}
return cell

View File

@ -13,6 +13,8 @@ import MastodonSDK
import UIKit
import os.log
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
enum ReportSection: Equatable, Hashable {
@ -22,6 +24,7 @@ enum ReportSection: Equatable, Hashable {
extension ReportSection {
struct Configuration {
let authContext: AuthContext
}
static func diffableDataSource(
@ -100,13 +103,11 @@ extension ReportSection {
) {
StatusSection.setupStatusPollDataSource(
context: context,
authContext: configuration.authContext,
statusView: cell.statusView
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
cell.statusView.viewModel.authContext = configuration.authContext
cell.configure(
tableView: tableView,

View File

@ -7,6 +7,7 @@
import UIKit
import CoreDataStack
import MastodonCore
enum SearchHistorySection: Hashable {
case main

View File

@ -12,6 +12,7 @@ import UIKit
import CoreData
import CoreDataStack
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonUI
@ -24,6 +25,7 @@ extension SearchResultSection {
static let logger = Logger(subsystem: "SearchResultSection", category: "logic")
struct Configuration {
let authContext: AuthContext
weak var statusViewTableViewCellDelegate: StatusTableViewCellDelegate?
weak var userTableViewCellDelegate: UserTableViewCellDelegate?
}
@ -98,13 +100,11 @@ extension SearchResultSection {
) {
StatusSection.setupStatusPollDataSource(
context: context,
authContext: configuration.authContext,
statusView: cell.statusView
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
cell.statusView.viewModel.authContext = configuration.authContext
cell.configure(
tableView: tableView,
@ -119,7 +119,7 @@ extension SearchResultSection {
cell: UserTableViewCell,
viewModel: UserTableViewCell.ViewModel,
configuration: Configuration
) {
) {
cell.configure(
tableView: tableView,
viewModel: viewModel,

View File

@ -7,6 +7,7 @@
import UIKit
import MastodonSDK
import MastodonCore
import MastodonLocalization
enum SearchSection: Hashable {

View File

@ -9,6 +9,7 @@ import UIKit
import CoreData
import CoreDataStack
import MastodonAsset
import MastodonCore
import MastodonLocalization
enum SettingsSection: Hashable {
@ -124,7 +125,7 @@ extension SettingsSection {
extension SettingsSection {
static func configureSettingToggle(
public static func configureSettingToggle(
cell: SettingsToggleTableViewCell,
item: SettingsItem,
setting: Setting
@ -155,7 +156,7 @@ extension SettingsSection {
}
}
static func configureSettingToggle(
public static func configureSettingToggle(
cell: SettingsToggleTableViewCell,
switchMode: SettingsItem.NotificationSwitchMode,
subscription: NotificationSubscription

View File

@ -15,6 +15,7 @@ import AlamofireImage
import MastodonMeta
import MastodonSDK
import NaturalLanguage
import MastodonCore
import MastodonUI
enum StatusSection: Equatable, Hashable {
@ -26,6 +27,7 @@ extension StatusSection {
static let logger = Logger(subsystem: "StatusSection", category: "logic")
struct Configuration {
let authContext: AuthContext
weak var statusTableViewCellDelegate: StatusTableViewCellDelegate?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
let filterContext: Mastodon.Entity.Filter.Context?
@ -158,6 +160,7 @@ extension StatusSection {
public static func setupStatusPollDataSource(
context: AppContext,
authContext: AuthContext,
statusView: StatusView
) {
let managedObjectContext = context.managedObjectContext
@ -171,10 +174,7 @@ extension StatusSection {
return _cell ?? PollOptionTableViewCell()
}()
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.pollOptionView.viewModel)
.store(in: &cell.disposeBag)
cell.pollOptionView.viewModel.authContext = authContext
managedObjectContext.performAndWait {
guard let option = record.object(in: managedObjectContext) else {
@ -211,14 +211,13 @@ extension StatusSection {
return true
}()
if needsUpdatePoll, let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value
{
if needsUpdatePoll {
let pollRecord: ManagedObjectRecord<Poll> = .init(objectID: option.poll.objectID)
Task { [weak context] in
guard let context = context else { return }
_ = try await context.apiService.poll(
poll: pollRecord,
authenticationBox: authenticationBox
authenticationBox: authContext.mastodonAuthenticationBox
)
}
}
@ -247,13 +246,11 @@ extension StatusSection {
) {
setupStatusPollDataSource(
context: context,
authContext: configuration.authContext,
statusView: cell.statusView
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
cell.statusView.viewModel.authContext = configuration.authContext
cell.configure(
tableView: tableView,
@ -276,13 +273,11 @@ extension StatusSection {
) {
setupStatusPollDataSource(
context: context,
authContext: configuration.authContext,
statusView: cell.statusView
)
context.authenticationService.activeMastodonAuthenticationBox
.map { $0 as UserIdentifier? }
.assign(to: \.userIdentifier, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
cell.statusView.viewModel.authContext = configuration.authContext
cell.configure(
tableView: tableView,

View File

@ -9,8 +9,10 @@ import os.log
import UIKit
import CoreData
import CoreDataStack
import MetaTextKit
import MastodonCore
import MastodonUI
import MastodonMeta
import MetaTextKit
enum UserSection: Hashable {
case main

View File

@ -1,28 +0,0 @@
//
// MastodonUser.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021/2/3.
//
import Foundation
import CoreDataStack
import MastodonSDK
extension MastodonUser {
public var profileURL: URL {
if let urlString = self.url,
let url = URL(string: urlString) {
return url
} else {
return URL(string: "https://\(self.domain)/@\(username)")!
}
}
public var activityItems: [Any] {
var items: [Any] = []
items.append(profileURL)
return items
}
}

View File

@ -1,18 +0,0 @@
//
// Mastodon+Entity+Tag.swift
// Mastodon
//
// Created by xiaojian sun on 2021/4/2.
//
import MastodonSDK
//extension Mastodon.Entity.Tag: Hashable {
// public func hash(into hasher: inout Hasher) {
// hasher.combine(name)
// }
//
// public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool {
// return lhs.name == rhs.name
// }
//}

View File

@ -1,19 +0,0 @@
//
// MastodonAuthenticationBox.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-20.
//
import Foundation
import CoreDataStack
import MastodonSDK
import MastodonUI
struct MastodonAuthenticationBox: UserIdentifier {
let authenticationRecord: ManagedObjectRecord<MastodonAuthentication>
let domain: String
let userID: MastodonUser.ID
let appAuthorization: Mastodon.API.OAuth.Authorization
let userAuthorization: Mastodon.API.OAuth.Authorization
}

View File

@ -2,19 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>onion</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
@ -30,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.4.5</string>
<string>1.4.6</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -43,7 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>144</string>
<string>147</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
@ -59,6 +46,19 @@
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>onion</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>SendPostIntent</string>
@ -103,6 +103,10 @@
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>Main</string>
<key>UIMainStoryboardFile</key>

View File

@ -1,24 +0,0 @@
//
// MastodonEmojis.swift
// MastodonEmojis
//
// Created by Cirno MainasuK on 2021-9-2.
// Copyright © 2021 Twidere. All rights reserved.
//
import Foundation
import CoreDataStack
import MastodonSDK
import MastodonMeta
extension MastodonEmoji {
public convenience init(emoji: Mastodon.Entity.Emoji) {
self.init(
code: emoji.shortcode,
url: emoji.url,
staticURL: emoji.staticURL,
visibleInPicker: emoji.visibleInPicker,
category: emoji.category
)
}
}

View File

@ -1,20 +0,0 @@
//
// HomeTimelinePreference.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-21.
//
import UIKit
extension UserDefaults {
@objc dynamic var preferAsyncHomeTimeline: Bool {
get {
register(defaults: [#function: false])
return bool(forKey: #function)
}
set { self[#function] = newValue }
}
}

View File

@ -1,21 +0,0 @@
//
// NotificationPreference.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-26.
//
import UIKit
import MastodonExtension
extension UserDefaults {
@objc dynamic var notificationBadgeCount: Int {
get {
register(defaults: [#function: 0])
return integer(forKey: #function)
}
set { self[#function] = newValue }
}
}

View File

@ -1,7 +0,0 @@
//
// ThemePreference.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-7-5.
//

View File

@ -1,12 +0,0 @@
//
// NamingState.swift
// Mastodon
//
// Created by MainasuK on 2022-1-17.
//
import Foundation
protocol NamingState {
var name: String { get }
}

View File

@ -7,19 +7,19 @@
import UIKit
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
static func responseToUserBlockAction(
dependency: NeedsDependency,
user: ManagedObjectRecord<MastodonUser>,
authenticationBox: MastodonAuthenticationBox
dependency: NeedsDependency & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await dependency.context.apiService.toggleBlock(
user: user,
authenticationBox: authenticationBox
authenticationBox: dependency.authContext.mastodonAuthenticationBox
)
} // end func
}

View File

@ -0,0 +1,26 @@
//
// DataSourceFacade+Bookmark.swift
// Mastodon
//
// Created by ProtoLimit on 2022/07/29.
//
import UIKit
import CoreData
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
public static func responseToStatusBookmarkAction(
provider: UIViewController & NeedsDependency & AuthContextProvider,
status: ManagedObjectRecord<Status>
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await provider.context.apiService.bookmark(
record: status,
authenticationBox: provider.authContext.mastodonAuthenticationBox
)
}
}

View File

@ -8,19 +8,19 @@
import UIKit
import CoreData
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
static func responseToStatusFavoriteAction(
provider: DataSourceProvider,
status: ManagedObjectRecord<Status>,
authenticationBox: MastodonAuthenticationBox
public static func responseToStatusFavoriteAction(
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await provider.context.apiService.favorite(
record: status,
authenticationBox: authenticationBox
authenticationBox: provider.authContext.mastodonAuthenticationBox
)
}
}

View File

@ -8,31 +8,30 @@
import UIKit
import CoreDataStack
import class CoreDataStack.Notification
import MastodonCore
import MastodonSDK
import MastodonLocalization
extension DataSourceFacade {
static func responseToUserFollowAction(
dependency: NeedsDependency,
user: ManagedObjectRecord<MastodonUser>,
authenticationBox: MastodonAuthenticationBox
dependency: NeedsDependency & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await dependency.context.apiService.toggleFollow(
user: user,
authenticationBox: authenticationBox
authenticationBox: dependency.authContext.mastodonAuthenticationBox
)
} // end func
}
extension DataSourceFacade {
static func responseToUserFollowRequestAction(
dependency: NeedsDependency,
dependency: NeedsDependency & AuthContextProvider,
notification: ManagedObjectRecord<Notification>,
query: Mastodon.API.Account.FollowReqeustQuery,
authenticationBox: MastodonAuthenticationBox
query: Mastodon.API.Account.FollowReqeustQuery
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
@ -71,9 +70,10 @@ extension DataSourceFacade {
_ = try await dependency.context.apiService.followRequest(
userID: userID,
query: query,
authenticationBox: authenticationBox
authenticationBox: dependency.authContext.mastodonAuthenticationBox
)
} catch {
// reset state when failure
try? await managedObjectContext.performChanges {
guard let notification = notification.object(in: managedObjectContext) else { return }
notification.transientFollowRequestState = .init(state: .none)
@ -111,7 +111,8 @@ extension DataSourceFacade {
case .accept:
notification.transientFollowRequestState = .init(state: .isAccept)
case .reject:
notification.transientFollowRequestState = .init(state: .isReject)
// do nothing due to will delete notification
break
}
}
@ -122,7 +123,11 @@ extension DataSourceFacade {
case .accept:
notification.followRequestState = .init(state: .isAccept)
case .reject:
notification.followRequestState = .init(state: .isReject)
// delete notification
for feed in notification.feeds {
backgroundManagedObjectContext.delete(feed)
}
backgroundManagedObjectContext.delete(notification)
}
}
} // end func

View File

@ -7,12 +7,13 @@
import UIKit
import CoreDataStack
import MastodonCore
import MastodonSDK
extension DataSourceFacade {
@MainActor
static func coordinateToHashtagScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
tag: DataSourceItem.TagKind
) async {
switch tag {
@ -25,11 +26,12 @@ extension DataSourceFacade {
@MainActor
static func coordinateToHashtagScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
tag: Mastodon.Entity.Tag
) async {
let hashtagTimelineViewModel = HashtagTimelineViewModel(
context: provider.context,
authContext: provider.authContext,
hashtag: tag.name
)
@ -42,7 +44,7 @@ extension DataSourceFacade {
@MainActor
static func coordinateToHashtagScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
tag: ManagedObjectRecord<Tag>
) async {
let managedObjectContext = provider.context.managedObjectContext
@ -55,6 +57,7 @@ extension DataSourceFacade {
let hashtagTimelineViewModel = HashtagTimelineViewModel(
context: provider.context,
authContext: provider.authContext,
hashtag: name
)

View File

@ -5,6 +5,7 @@
// Created by MainasuK on 2022-1-26.
//
import os.log
import UIKit
import CoreDataStack
import MastodonUI
@ -153,6 +154,8 @@ extension DataSourceFacade {
user: ManagedObjectRecord<MastodonUser>,
previewContext: ImagePreviewContext
) async throws {
let logger = Logger(subsystem: "DataSourceFacade", category: "Media")
let managedObjectContext = dependency.context.managedObjectContext
var _avatarAssetURL: String?
@ -216,13 +219,18 @@ extension DataSourceFacade {
thumbnail: thumbnail
))
case .profileBanner:
return .profileAvatar(.init(
return .profileBanner(.init(
assetURL: _headerAssetURL,
thumbnail: thumbnail
))
}
}()
guard mediaPreviewItem.isAssetURLValid else {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): discard preview due to assetURL invalid")
return
}
coordinateToMediaPreviewScene(
dependency: dependency,
mediaPreviewItem: mediaPreviewItem,

View File

@ -8,11 +8,12 @@
import Foundation
import CoreDataStack
import MetaTextKit
import MastodonCore
extension DataSourceFacade {
static func responseToMetaTextAction(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
target: StatusTarget,
status: ManagedObjectRecord<Status>,
meta: Meta
@ -33,7 +34,7 @@ extension DataSourceFacade {
}
static func responseToMetaTextAction(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
meta: Meta
) async {
@ -47,19 +48,20 @@ extension DataSourceFacade {
assertionFailure()
return
}
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
let domain = provider.authContext.mastodonAuthenticationBox.domain
if url.host == domain,
url.pathComponents.count >= 4,
url.pathComponents[0] == "/",
url.pathComponents[1] == "web",
url.pathComponents[2] == "statuses" {
let statusID = url.pathComponents[3]
let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID)
let threadViewModel = RemoteThreadViewModel(context: provider.context, authContext: provider.authContext, statusID: statusID)
await provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
} else {
await provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
}
case .hashtag(_, let hashtag, _):
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag)
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, authContext: provider.authContext, hashtag: hashtag)
await provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show)
case .mention(_, let mention, let userInfo):
await coordinateToProfileScene(

View File

@ -7,19 +7,19 @@
import UIKit
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
static func responseToUserMuteAction(
dependency: NeedsDependency,
user: ManagedObjectRecord<MastodonUser>,
authenticationBox: MastodonAuthenticationBox
dependency: NeedsDependency & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await dependency.context.apiService.toggleMute(
user: user,
authenticationBox: authenticationBox
authenticationBox: dependency.authContext.mastodonAuthenticationBox
)
} // end func
}

View File

@ -7,11 +7,12 @@
import UIKit
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
static func coordinateToProfileScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
target: StatusTarget,
status: ManagedObjectRecord<Status>
) async {
@ -32,7 +33,7 @@ extension DataSourceFacade {
@MainActor
static func coordinateToProfileScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
user: ManagedObjectRecord<MastodonUser>
) async {
guard let user = user.object(in: provider.context.managedObjectContext) else {
@ -42,6 +43,7 @@ extension DataSourceFacade {
let profileViewModel = CachedProfileViewModel(
context: provider.context,
authContext: provider.authContext,
mastodonUser: user
)
@ -57,13 +59,12 @@ extension DataSourceFacade {
extension DataSourceFacade {
static func coordinateToProfileScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
mention: String, // username,
userInfo: [AnyHashable: Any]?
) async {
guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let domain = authenticationBox.domain
let domain = provider.authContext.mastodonAuthenticationBox.domain
let href = userInfo?["href"] as? String
guard let url = href.flatMap({ URL(string: $0) }) else { return }
@ -85,8 +86,8 @@ extension DataSourceFacade {
let userID = mention.id
let profileViewModel: ProfileViewModel = {
// check if self
guard userID != authenticationBox.userID else {
return MeProfileViewModel(context: provider.context)
guard userID != provider.authContext.mastodonAuthenticationBox.userID else {
return MeProfileViewModel(context: provider.context, authContext: provider.authContext)
}
let request = MastodonUser.sortedFetchRequest
@ -95,9 +96,9 @@ extension DataSourceFacade {
let _user = provider.context.managedObjectContext.safeFetch(request).first
if let user = _user {
return CachedProfileViewModel(context: provider.context, mastodonUser: user)
return CachedProfileViewModel(context: provider.context, authContext: provider.authContext, mastodonUser: user)
} else {
return RemoteProfileViewModel(context: provider.context, userID: userID)
return RemoteProfileViewModel(context: provider.context, authContext: provider.authContext, userID: userID)
}
}()

View File

@ -7,20 +7,20 @@
import UIKit
import CoreDataStack
import MastodonCore
import MastodonUI
extension DataSourceFacade {
static func responseToStatusReblogAction(
provider: DataSourceProvider,
status: ManagedObjectRecord<Status>,
authenticationBox: MastodonAuthenticationBox
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>
) async throws {
let selectionFeedbackGenerator = await UISelectionFeedbackGenerator()
await selectionFeedbackGenerator.selectionChanged()
_ = try await provider.context.apiService.reblog(
record: status,
authenticationBox: authenticationBox
authenticationBox: provider.authContext.mastodonAuthenticationBox
)
} // end func
}

View File

@ -7,22 +7,23 @@
import Foundation
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
static func responseToCreateSearchHistory(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
item: DataSourceItem
) async {
switch item {
case .status:
break // not create search history for status
case .user(let record):
let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value
let authenticationBox = provider.authContext.mastodonAuthenticationBox
let managedObjectContext = provider.context.backgroundManagedObjectContext
try? await managedObjectContext.performChanges {
guard let me = authenticationBox?.authenticationRecord.object(in: managedObjectContext)?.user else { return }
guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return }
guard let user = record.object(in: managedObjectContext) else { return }
_ = Persistence.SearchHistory.createOrMerge(
in: managedObjectContext,
@ -34,13 +35,12 @@ extension DataSourceFacade {
)
} // end try? await managedObjectContext.performChanges { }
case .hashtag(let tag):
let _authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value
let authenticationBox = provider.authContext.mastodonAuthenticationBox
let managedObjectContext = provider.context.backgroundManagedObjectContext
switch tag {
case .entity(let entity):
try? await managedObjectContext.performChanges {
guard let authenticationBox = _authenticationBox else { return }
guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return }
let now = Date()
@ -66,7 +66,7 @@ extension DataSourceFacade {
} // end try? await managedObjectContext.performChanges { }
case .record(let record):
try? await managedObjectContext.performChanges {
guard let authenticationBox = _authenticationBox else { return }
let authenticationBox = provider.authContext.mastodonAuthenticationBox
guard let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return }
guard let tag = record.object(in: managedObjectContext) else { return }
@ -92,13 +92,12 @@ extension DataSourceFacade {
extension DataSourceFacade {
static func responseToDeleteSearchHistory(
provider: DataSourceProvider
provider: DataSourceProvider & AuthContextProvider
) async throws {
let _authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value
let authenticationBox = provider.authContext.mastodonAuthenticationBox
let managedObjectContext = provider.context.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
guard let authenticationBox = _authenticationBox else { return }
guard let _ = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user else { return }
let request = SearchHistory.sortedFetchRequest
request.predicate = SearchHistory.predicate(

View File

@ -7,6 +7,7 @@
import UIKit
import CoreDataStack
import MastodonCore
import MastodonUI
import MastodonLocalization
@ -14,13 +15,12 @@ import MastodonLocalization
extension DataSourceFacade {
static func responseToDeleteStatus(
dependency: NeedsDependency,
status: ManagedObjectRecord<Status>,
authenticationBox: MastodonAuthenticationBox
dependency: NeedsDependency & AuthContextProvider,
status: ManagedObjectRecord<Status>
) async throws {
_ = try await dependency.context.apiService.deleteStatus(
status: status,
authenticationBox: authenticationBox
authenticationBox: dependency.authContext.mastodonAuthenticationBox
)
}
@ -36,7 +36,7 @@ extension DataSourceFacade {
button: UIButton
) async throws {
let activityViewController = try await createActivityViewController(
provider: provider,
dependency: provider,
status: status
)
provider.coordinator.present(
@ -51,19 +51,19 @@ extension DataSourceFacade {
}
private static func createActivityViewController(
provider: DataSourceProvider,
dependency: NeedsDependency,
status: ManagedObjectRecord<Status>
) async throws -> UIActivityViewController {
var activityItems: [Any] = try await provider.context.managedObjectContext.perform {
guard let status = status.object(in: provider.context.managedObjectContext) else { return [] }
var activityItems: [Any] = try await dependency.context.managedObjectContext.perform {
guard let status = status.object(in: dependency.context.managedObjectContext) else { return [] }
let url = status.url ?? status.uri
return [URL(string: url)].compactMap { $0 } as [Any]
}
var applicationActivities: [UIActivity] = [
SafariActivity(sceneCoordinator: provider.coordinator), // open URL
SafariActivity(sceneCoordinator: dependency.coordinator), // open URL
]
if let provider = provider as? ShareActivityProvider {
if let provider = dependency as? ShareActivityProvider {
activityItems.append(contentsOf: provider.activities)
applicationActivities.append(contentsOf: provider.applicationActivities)
}
@ -80,10 +80,9 @@ extension DataSourceFacade {
extension DataSourceFacade {
@MainActor
static func responseToActionToolbar(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
status: ManagedObjectRecord<Status>,
action: ActionToolbarContainer.Action,
authenticationBox: MastodonAuthenticationBox,
sender: UIButton
) async throws {
let managedObjectContext = provider.context.managedObjectContext
@ -99,16 +98,15 @@ extension DataSourceFacade {
switch action {
case .reply:
guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
selectionFeedbackGenerator.selectionChanged()
let composeViewModel = ComposeViewModel(
context: provider.context,
composeKind: .reply(status: status),
authenticationBox: authenticationBox
authContext: provider.authContext,
kind: .reply(status: status)
)
provider.coordinator.present(
_ = provider.coordinator.present(
scene: .compose(viewModel: composeViewModel),
from: provider,
transition: .modal(animated: true, completion: nil)
@ -116,14 +114,17 @@ extension DataSourceFacade {
case .reblog:
try await DataSourceFacade.responseToStatusReblogAction(
provider: provider,
status: status,
authenticationBox: authenticationBox
status: status
)
case .like:
try await DataSourceFacade.responseToStatusFavoriteAction(
provider: provider,
status: status,
authenticationBox: authenticationBox
status: status
)
case .bookmark:
try await DataSourceFacade.responseToStatusBookmarkAction(
provider: provider,
status: status
)
case .share:
try await DataSourceFacade.responseToStatusShareAction(
@ -148,10 +149,9 @@ extension DataSourceFacade {
@MainActor
static func responseToMenuAction(
dependency: NeedsDependency & UIViewController,
dependency: UIViewController & NeedsDependency & AuthContextProvider,
action: MastodonMenu.Action,
menuContext: MenuContext,
authenticationBox: MastodonAuthenticationBox
menuContext: MenuContext
) async throws {
switch action {
case .muteUser(let actionContext):
@ -174,8 +174,7 @@ extension DataSourceFacade {
guard let user = _user else { return }
try await DataSourceFacade.responseToUserMuteAction(
dependency: dependency,
user: user,
authenticationBox: authenticationBox
user: user
)
} // end Task
}
@ -203,8 +202,7 @@ extension DataSourceFacade {
guard let user = _user else { return }
try await DataSourceFacade.responseToUserBlockAction(
dependency: dependency,
user: user,
authenticationBox: authenticationBox
user: user
)
} // end Task
}
@ -218,11 +216,12 @@ extension DataSourceFacade {
let reportViewModel = ReportViewModel(
context: dependency.context,
authContext: dependency.authContext,
user: user,
status: menuContext.status
)
dependency.coordinator.present(
_ = dependency.coordinator.present(
scene: .report(viewModel: reportViewModel),
from: dependency,
transition: .modal(animated: true, completion: nil)
@ -239,7 +238,7 @@ extension DataSourceFacade {
user: user
)
guard let activityViewController = _activityViewController else { return }
dependency.coordinator.present(
_ = dependency.coordinator.present(
scene: .activityViewController(
activityViewController: activityViewController,
sourceView: menuContext.button,
@ -248,6 +247,37 @@ extension DataSourceFacade {
from: dependency,
transition: .activityViewControllerPresent(animated: true, completion: nil)
)
case .bookmarkStatus:
Task {
guard let status = menuContext.status else {
assertionFailure()
return
}
try await DataSourceFacade.responseToStatusBookmarkAction(
provider: dependency,
status: status
)
} // end Task
case .shareStatus:
Task {
guard let status = menuContext.status else {
assertionFailure()
return
}
let activityViewController = try await DataSourceFacade.createActivityViewController(
dependency: dependency,
status: status
)
await dependency.coordinator.present(
scene: .activityViewController(
activityViewController: activityViewController,
sourceView: menuContext.button,
barButtonItem: menuContext.barButtonItem
),
from: dependency,
transition: .activityViewControllerPresent(animated: true, completion: nil)
)
} // end Task
case .deleteStatus:
let alertController = UIAlertController(
title: "Delete Post",
@ -263,8 +293,7 @@ extension DataSourceFacade {
Task {
try await DataSourceFacade.responseToDeleteStatus(
dependency: dependency,
status: status,
authenticationBox: authenticationBox
status: status
)
} // end Task
}

View File

@ -8,10 +8,11 @@
import Foundation
import CoreData
import CoreDataStack
import MastodonCore
extension DataSourceFacade {
static func coordinateToStatusThreadScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
target: StatusTarget,
status: ManagedObjectRecord<Status>
) async {
@ -39,14 +40,15 @@ extension DataSourceFacade {
@MainActor
static func coordinateToStatusThreadScene(
provider: DataSourceProvider,
provider: DataSourceProvider & AuthContextProvider,
root: StatusItem.Thread
) async {
let threadViewModel = ThreadViewModel(
context: provider.context,
authContext: provider.authContext,
optionalRoot: root
)
provider.coordinator.present(
_ = provider.coordinator.present(
scene: .thread(viewModel: threadViewModel),
from: provider,
transition: .show

View File

@ -7,18 +7,18 @@
import UIKit
import MetaTextKit
import MastodonUI
import CoreDataStack
import MastodonCore
import MastodonUI
// MARK: - Notification AuthorMenuAction
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
notificationView: NotificationView,
menuButton button: UIButton,
didSelectAction action: MastodonMenu.Action
) {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
guard let item = await item(from: source) else {
@ -47,15 +47,14 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
status: nil,
button: button,
barButtonItem: nil
),
authenticationBox: authenticationBox
)
)
} // end Task
}
}
// MARK: - Notification Author Avatar
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
notificationView: NotificationView,
@ -88,7 +87,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - Follow Request
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
@ -106,15 +105,10 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
return
}
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
try await DataSourceFacade.responseToUserFollowRequestAction(
dependency: self,
notification: notification,
query: .accept,
authenticationBox: authenticationBox
query: .accept
)
} // end Task
}
@ -135,15 +129,10 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
return
}
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
try await DataSourceFacade.responseToUserFollowRequestAction(
dependency: self,
notification: notification,
query: .reject,
authenticationBox: authenticationBox
query: .reject
)
} // end Task
}
@ -151,7 +140,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - Status Content
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
notificationView: NotificationView,
@ -279,7 +268,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Med
}
// MARK: - Status Toolbar
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
notificationView: NotificationView,
@ -287,7 +276,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
buttonDidPressed button: UIButton,
action: ActionToolbarContainer.Action
) {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
guard let item = await item(from: source) else {
@ -311,7 +299,6 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
provider: self,
status: status,
action: action,
authenticationBox: authenticationBox,
sender: button
)
} // end Task
@ -319,7 +306,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - Status Author Avatar
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
notificationView: NotificationView,
@ -354,7 +341,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - Status Content
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
@ -530,7 +517,7 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: a11y
extension NotificationTableViewCellDelegate where Self: DataSourceProvider {
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, accessibilityActivate: Void) {
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)

View File

@ -8,10 +8,11 @@
import UIKit
import CoreDataStack
import MetaTextKit
import MastodonCore
import MastodonUI
// MARK: - header
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
@ -64,7 +65,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - avatar button
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
@ -92,7 +93,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - content
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
@ -169,7 +170,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & MediaPrev
// MARK: - poll
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
@ -177,7 +178,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
pollTableView tableView: UITableView,
didSelectRowAt indexPath: IndexPath
) {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return }
guard let pollItem = pollTableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return }
@ -226,7 +226,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
_ = try await context.apiService.vote(
poll: poll,
choices: [choice],
authenticationBox: authenticationBox
authenticationBox: authContext.mastodonAuthenticationBox
)
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll for \(choice) success")
} catch {
@ -248,7 +248,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
statusView: StatusView,
pollVoteButtonPressed button: UIButton
) {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return }
guard let firstPollItem = pollTableViewDiffableDataSource.snapshot().itemIdentifiers.first else { return }
guard case let .option(firstPollOption) = firstPollItem else { return }
@ -284,7 +283,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
_ = try await context.apiService.vote(
poll: poll,
choices: choices,
authenticationBox: authenticationBox
authenticationBox: authContext.mastodonAuthenticationBox
)
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): vote poll for \(choices) success")
} catch {
@ -303,7 +302,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - toolbar
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
statusView: StatusView,
@ -311,7 +310,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
buttonDidPressed button: UIButton,
action: ActionToolbarContainer.Action
) {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
guard let item = await item(from: source) else {
@ -327,7 +325,6 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
provider: self,
status: status,
action: action,
authenticationBox: authenticationBox,
sender: button
)
} // end Task
@ -336,14 +333,13 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - menu button
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
statusView: StatusView,
menuButton button: UIButton,
didSelectAction action: MastodonMenu.Action
) {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
guard let item = await item(from: source) else {
@ -372,8 +368,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
status: status,
button: button,
barButtonItem: nil
),
authenticationBox: authenticationBox
)
)
} // end Task
}
@ -475,7 +470,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: - StatusMetricView
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) {
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)
@ -489,6 +484,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
let userListViewModel = UserListViewModel(
context: context,
authContext: authContext,
kind: .rebloggedBy(status: status)
)
await coordinator.present(
@ -512,6 +508,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
let userListViewModel = UserListViewModel(
context: context,
authContext: authContext,
kind: .favoritedBy(status: status)
)
await coordinator.present(
@ -524,7 +521,7 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider {
}
// MARK: a11y
extension StatusTableViewCellDelegate where Self: DataSourceProvider {
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) {
Task {
let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil)

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import CoreDataStack
import MastodonCore
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & StatusTableViewControllerNavigateableRelay {
@ -30,7 +31,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
}
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider {
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & AuthContextProvider {
func statusKeyCommandHandler(_ sender: UIKeyCommand) {
guard let rawValue = sender.propertyList as? String,
@ -53,7 +54,7 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
}
// status coordinate
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider {
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & AuthContextProvider {
@MainActor
private func statusRecord() async -> ManagedObjectRecord<Status>? {
@ -93,16 +94,15 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
private func replyStatus() async {
guard let status = await statusRecord() else { return }
guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
selectionFeedbackGenerator.selectionChanged()
let composeViewModel = ComposeViewModel(
context: self.context,
composeKind: .reply(status: status),
authenticationBox: authenticationBox
authContext: authContext,
kind: .reply(status: status)
)
self.coordinator.present(
_ = self.coordinator.present(
scene: .compose(viewModel: composeViewModel),
from: self,
transition: .modal(animated: true, completion: nil)
@ -144,19 +144,16 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
}
// toggle
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider {
extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvider & AuthContextProvider {
@MainActor
private func toggleReblog() async {
guard let status = await statusRecord() else { return }
guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
do {
try await DataSourceFacade.responseToStatusReblogAction(
provider: self,
status: status,
authenticationBox: authenticationBox
status: status
)
} catch {
assertionFailure()
@ -167,13 +164,10 @@ extension StatusTableViewControllerNavigateableCore where Self: DataSourceProvid
private func toggleFavorite() async {
guard let status = await statusRecord() else { return }
guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
do {
try await DataSourceFacade.responseToStatusFavoriteAction(
provider: self,
status: status,
authenticationBox: authenticationBox
status: status
)
} catch {
assertionFailure()

View File

@ -7,6 +7,7 @@
import os.log
import UIKit
import MastodonCore
extension TableViewControllerNavigateableCore where Self: TableViewControllerNavigateableRelay {
var navigationKeyCommands: [UIKeyCommand] {
@ -124,7 +125,7 @@ extension TableViewControllerNavigateableCore {
}
extension TableViewControllerNavigateableCore where Self: DataSourceProvider {
extension TableViewControllerNavigateableCore where Self: DataSourceProvider & AuthContextProvider {
func open() {
guard let indexPathForSelectedRow = tableView.indexPathForSelectedRow else { return }
let source = DataSourceItem.Source(indexPath: indexPathForSelectedRow)

View File

@ -8,9 +8,11 @@
import os.log
import UIKit
import CoreDataStack
import MastodonCore
import MastodonUI
import MastodonLocalization
extension UITableViewDelegate where Self: DataSourceProvider {
extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvider {
func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): indexPath: \(indexPath.debugDescription)")

View File

@ -5,50 +5,59 @@
// Created by Cirno MainasuK on 2021-9-13.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonMeta
import MastodonCore
import MastodonUI
final class AccountListViewModel {
final class AccountListViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
// input
let context: AppContext
let authContext: AuthContext
let mastodonAuthenticationFetchedResultsController: NSFetchedResultsController<MastodonAuthentication>
// output
let authentications = CurrentValueSubject<[Item], Never>([])
let activeMastodonUserObjectID = CurrentValueSubject<NSManagedObjectID?, Never>(nil)
@Published var authentications: [ManagedObjectRecord<MastodonAuthentication>] = []
@Published var items: [Item] = []
let dataSourceDidUpdate = PassthroughSubject<Void, Never>()
var diffableDataSource: UITableViewDiffableDataSource<Section, Item>!
init(context: AppContext) {
init(context: AppContext, authContext: AuthContext) {
self.context = context
Publishers.CombineLatest(
context.authenticationService.mastodonAuthentications,
context.authenticationService.activeMastodonAuthentication
)
.sink { [weak self] authentications, activeAuthentication in
guard let self = self else { return }
var items: [Item] = []
var activeMastodonUserObjectID: NSManagedObjectID?
for authentication in authentications {
let item = Item.authentication(objectID: authentication.objectID)
items.append(item)
if authentication === activeAuthentication {
activeMastodonUserObjectID = authentication.user.objectID
}
}
self.authentications.value = items
self.activeMastodonUserObjectID.value = activeMastodonUserObjectID
self.authContext = authContext
self.mastodonAuthenticationFetchedResultsController = {
let fetchRequest = MastodonAuthentication.sortedFetchRequest
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.fetchBatchSize = 20
let controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: context.managedObjectContext,
sectionNameKeyPath: nil,
cacheName: nil
)
return controller
}()
super.init()
// end init
mastodonAuthenticationFetchedResultsController.delegate = self
do {
try mastodonAuthenticationFetchedResultsController.performFetch()
authentications = mastodonAuthenticationFetchedResultsController.fetchedObjects?.compactMap { $0.asRecrod } ?? []
} catch {
assertionFailure(error.localizedDescription)
}
.store(in: &disposeBag)
authentications
$authentications
.receive(on: DispatchQueue.main)
.sink { [weak self] authentications in
guard let self = self else { return }
@ -56,7 +65,10 @@ final class AccountListViewModel {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems(authentications, toSection: .main)
let authenticationItems: [Item] = authentications.map {
Item.authentication(record: $0)
}
snapshot.appendItems(authenticationItems, toSection: .main)
snapshot.appendItems([.addAccount], toSection: .main)
diffableDataSource.apply(snapshot) {
@ -74,7 +86,7 @@ extension AccountListViewModel {
}
enum Item: Hashable {
case authentication(objectID: NSManagedObjectID)
case authentication(record: ManagedObjectRecord<MastodonAuthentication>)
case addAccount
}
@ -84,14 +96,17 @@ extension AccountListViewModel {
) {
diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in
switch item {
case .authentication(let objectID):
let authentication = managedObjectContext.object(with: objectID) as! MastodonAuthentication
case .authentication(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AccountListTableViewCell.self), for: indexPath) as! AccountListTableViewCell
AccountListViewModel.configure(
cell: cell,
authentication: authentication,
activeMastodonUserObjectID: self.activeMastodonUserObjectID.eraseToAnyPublisher()
)
if let authentication = record.object(in: managedObjectContext),
let activeAuthentication = self.authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)
{
AccountListViewModel.configure(
cell: cell,
authentication: authentication,
activeAuthentication: activeAuthentication
)
}
return cell
case .addAccount:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AddAccountTableViewCell.self), for: indexPath) as! AddAccountTableViewCell
@ -107,7 +122,7 @@ extension AccountListViewModel {
static func configure(
cell: AccountListTableViewCell,
authentication: MastodonAuthentication,
activeMastodonUserObjectID: AnyPublisher<NSManagedObjectID?, Never>
activeAuthentication: MastodonAuthentication
) {
let user = authentication.user
@ -136,19 +151,14 @@ extension AccountListViewModel {
cell.badgeButton.setBadge(number: count)
// checkmark
activeMastodonUserObjectID
.receive(on: DispatchQueue.main)
.sink { objectID in
let isCurrentUser = user.objectID == objectID
cell.tintColor = .label
cell.checkmarkImageView.isHidden = !isCurrentUser
if isCurrentUser {
cell.accessibilityTraits.insert(.selected)
} else {
cell.accessibilityTraits.remove(.selected)
}
}
.store(in: &cell.disposeBag)
let isActive = activeAuthentication.userID == authentication.userID
cell.tintColor = .label
cell.checkmarkImageView.isHidden = !isActive
if isActive {
cell.accessibilityTraits.insert(.selected)
} else {
cell.accessibilityTraits.remove(.selected)
}
cell.accessibilityLabel = [
cell.nameLabel.text,
@ -159,3 +169,21 @@ extension AccountListViewModel {
.joined(separator: " ")
}
}
// MARK: - NSFetchedResultsControllerDelegate
extension AccountListViewModel: NSFetchedResultsControllerDelegate {
public func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
guard controller === mastodonAuthenticationFetchedResultsController else {
assertionFailure()
return
}
authentications = mastodonAuthenticationFetchedResultsController.fetchedObjects?.compactMap { $0.asRecrod } ?? []
}
}

View File

@ -12,6 +12,7 @@ import CoreDataStack
import PanModal
import MastodonAsset
import MastodonLocalization
import MastodonCore
final class AccountListViewController: UIViewController, NeedsDependency {
@ -21,7 +22,7 @@ final class AccountListViewController: UIViewController, NeedsDependency {
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
private(set) lazy var viewModel = AccountListViewModel(context: context)
var viewModel: AccountListViewModel!
private(set) lazy var addBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(
@ -63,7 +64,10 @@ extension AccountListViewController: PanModalPresentable {
return .contentHeight(CGFloat(height))
}
let count = viewModel.context.authenticationService.mastodonAuthentications.value.count + 1
let request = MastodonAuthentication.sortedFetchRequest
let authenticationCount = (try? context.managedObjectContext.count(for: request)) ?? 0
let count = authenticationCount + 1
let height = calculateHeight(of: count)
return .contentHeight(height)
}
@ -154,7 +158,7 @@ extension AccountListViewController {
@objc private func addBarButtonItem(_ sender: UIBarButtonItem) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
_ = coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
}
@objc private func dragIndicatorTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) {
@ -173,19 +177,17 @@ extension AccountListViewController: UITableViewDelegate {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .authentication(let objectID):
case .authentication(let record):
assert(Thread.isMainThread)
let authentication = context.managedObjectContext.object(with: objectID) as! MastodonAuthentication
context.authenticationService.activeMastodonUser(domain: authentication.domain, userID: authentication.userID)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
self.coordinator.setup()
}
.store(in: &disposeBag)
guard let authentication = record.object(in: context.managedObjectContext) else { return }
Task { @MainActor in
let isActive = try await context.authenticationService.activeMastodonUser(domain: authentication.domain, userID: authentication.userID)
guard isActive else { return }
self.coordinator.setup()
} // end Task
case .addAccount:
// TODO: add dismiss entry for welcome scene
coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
_ = coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil))
}
}
}

View File

@ -9,6 +9,7 @@ import UIKit
import Combine
import FLAnimatedImage
import MetaTextKit
import MastodonCore
import MastodonUI
final class AccountListTableViewCell: UITableViewCell {

View File

@ -10,6 +10,8 @@ import Combine
import MetaTextKit
import MastodonAsset
import MastodonLocalization
import MastodonCore
import MastodonUI
final class AddAccountTableViewCell: UITableViewCell {

View File

@ -1,22 +0,0 @@
//
// AutoCompleteViewModel+Diffable.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-5-17.
//
import UIKit
extension AutoCompleteViewModel {
func setupDiffableDataSource(
for tableView: UITableView
) {
diffableDataSource = AutoCompleteSection.tableViewDiffableDataSource(for: tableView)
var snapshot = NSDiffableDataSourceSnapshot<AutoCompleteSection, AutoCompleteItem>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
}
}

View File

@ -27,7 +27,7 @@ final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
weak var delegate: ComposeStatusAttachmentCollectionViewCellDelegate?
let attachmentContainerView = AttachmentContainerView()
// let attachmentContainerView = AttachmentContainerView()
let removeButton: UIButton = {
let button = HighlightDimmableButton()
button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10)
@ -45,11 +45,11 @@ final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell {
override func prepareForReuse() {
super.prepareForReuse()
attachmentContainerView.activityIndicatorView.startAnimating()
attachmentContainerView.previewImageView.af.cancelImageRequest()
attachmentContainerView.previewImageView.image = .placeholder(color: .systemFill)
delegate = nil
disposeBag.removeAll()
// attachmentContainerView.activityIndicatorView.startAnimating()
// attachmentContainerView.previewImageView.af.cancelImageRequest()
// attachmentContainerView.previewImageView.image = .placeholder(color: .systemFill)
// delegate = nil
// disposeBag.removeAll()
}
override init(frame: CGRect) {
@ -73,31 +73,30 @@ extension ComposeStatusAttachmentCollectionViewCell {
private func _init() {
// selectionStyle = .none
attachmentContainerView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(attachmentContainerView)
NSLayoutConstraint.activate([
attachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight),
attachmentContainerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
attachmentContainerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: attachmentContainerView.bottomAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight),
attachmentContainerView.heightAnchor.constraint(equalToConstant: 205).priority(.defaultHigh),
])
removeButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(removeButton)
NSLayoutConstraint.activate([
removeButton.centerXAnchor.constraint(equalTo: attachmentContainerView.trailingAnchor),
removeButton.centerYAnchor.constraint(equalTo: attachmentContainerView.topAnchor),
removeButton.widthAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width).priority(.defaultHigh),
removeButton.heightAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height).priority(.defaultHigh),
])
removeButton.addTarget(self, action: #selector(ComposeStatusAttachmentCollectionViewCell.removeButtonDidPressed(_:)), for: .touchUpInside)
// attachmentContainerView.translatesAutoresizingMaskIntoConstraints = false
// contentView.addSubview(attachmentContainerView)
// NSLayoutConstraint.activate([
// attachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight),
// attachmentContainerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
// attachmentContainerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
// contentView.bottomAnchor.constraint(equalTo: attachmentContainerView.bottomAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight),
// attachmentContainerView.heightAnchor.constraint(equalToConstant: 205).priority(.defaultHigh),
// ])
//
// removeButton.translatesAutoresizingMaskIntoConstraints = false
// contentView.addSubview(removeButton)
// NSLayoutConstraint.activate([
// removeButton.centerXAnchor.constraint(equalTo: attachmentContainerView.trailingAnchor),
// removeButton.centerYAnchor.constraint(equalTo: attachmentContainerView.topAnchor),
// removeButton.widthAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width).priority(.defaultHigh),
// removeButton.heightAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height).priority(.defaultHigh),
// ])
//
// removeButton.addTarget(self, action: #selector(ComposeStatusAttachmentCollectionViewCell.removeButtonDidPressed(_:)), for: .touchUpInside)
}
}
extension ComposeStatusAttachmentCollectionViewCell {
@objc private func removeButtonDidPressed(_ sender: UIButton) {

View File

@ -9,68 +9,70 @@ import os.log
import UIKit
import Combine
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject {
func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption)
}
final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell {
var disposeBag = Set<AnyCancellable>()
weak var delegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate?
let durationButton: UIButton = {
let button = HighlightDimmableButton()
button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12))
button.expandEdgeInsets = UIEdgeInsets(top: 0, left: -10, bottom: -20, right: -20)
button.setTitle(L10n.Scene.Compose.Poll.durationTime(L10n.Scene.Compose.Poll.thirtyMinutes), for: .normal)
button.setTitleColor(Asset.Colors.brand.color, for: .normal)
return button
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeStatusPollExpiresOptionCollectionViewCell {
private typealias ExpiresOption = ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption
private func _init() {
durationButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(durationButton)
NSLayoutConstraint.activate([
durationButton.topAnchor.constraint(equalTo: contentView.topAnchor),
durationButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin),
durationButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
let children = ExpiresOption.allCases.map { expiresOption -> UIAction in
UIAction(title: expiresOption.title, image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in
guard let self = self else { return }
self.expiresOptionActionHandler(action, expiresOption: expiresOption)
}
}
durationButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
durationButton.showsMenuAsPrimaryAction = true
}
}
extension ComposeStatusPollExpiresOptionCollectionViewCell {
private func expiresOptionActionHandler(_ sender: UIAction, expiresOption: ExpiresOption) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, expiresOption.title)
delegate?.composeStatusPollExpiresOptionCollectionViewCell(self, didSelectExpiresOption: expiresOption)
}
}
//protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject {
// func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption)
//}
//
//final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell {
//
// var disposeBag = Set<AnyCancellable>()
// weak var delegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate?
//
// let durationButton: UIButton = {
// let button = HighlightDimmableButton()
// button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12))
// button.expandEdgeInsets = UIEdgeInsets(top: 0, left: -10, bottom: -20, right: -20)
// button.setTitle(L10n.Scene.Compose.Poll.durationTime(L10n.Scene.Compose.Poll.thirtyMinutes), for: .normal)
// button.setTitleColor(Asset.Colors.brand.color, for: .normal)
// return button
// }()
//
// override init(frame: CGRect) {
// super.init(frame: frame)
// _init()
// }
//
// required init?(coder: NSCoder) {
// super.init(coder: coder)
// _init()
// }
//
//}
//
//extension ComposeStatusPollExpiresOptionCollectionViewCell {
//
// private typealias ExpiresOption = ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption
//
// private func _init() {
// durationButton.translatesAutoresizingMaskIntoConstraints = false
// contentView.addSubview(durationButton)
// NSLayoutConstraint.activate([
// durationButton.topAnchor.constraint(equalTo: contentView.topAnchor),
// durationButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin),
// durationButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// ])
//
// let children = ExpiresOption.allCases.map { expiresOption -> UIAction in
// UIAction(title: expiresOption.title, image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in
// guard let self = self else { return }
// self.expiresOptionActionHandler(action, expiresOption: expiresOption)
// }
// }
// durationButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
// durationButton.showsMenuAsPrimaryAction = true
// }
//
//}
//
//extension ComposeStatusPollExpiresOptionCollectionViewCell {
//
// private func expiresOptionActionHandler(_ sender: UIAction, expiresOption: ExpiresOption) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, expiresOption.title)
// delegate?.composeStatusPollExpiresOptionCollectionViewCell(self, didSelectExpiresOption: expiresOption)
// }
//
//}

View File

@ -8,6 +8,8 @@
import os.log
import UIKit
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: AnyObject {

View File

@ -9,6 +9,7 @@ import os.log
import UIKit
import Combine
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonUI

File diff suppressed because it is too large Load Diff

View File

@ -9,505 +9,482 @@ import os.log
import UIKit
import Combine
import CoreDataStack
import MastodonSDK
import MastodonMeta
import MetaTextKit
import MastodonMeta
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonSDK
extension ComposeViewModel {
func setupDataSource(
tableView: UITableView,
metaTextDelegate: MetaTextDelegate,
metaTextViewDelegate: UITextViewDelegate,
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
) {
// UI
bind()
// content
bind(cell: composeStatusContentTableViewCell, tableView: tableView)
composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate
composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate
// attachment
bind(cell: composeStatusAttachmentTableViewCell, tableView: tableView)
composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate
// poll
bind(cell: composeStatusPollTableViewCell, tableView: tableView)
composeStatusPollTableViewCell.delegate = self
composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel
composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate
composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate
composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate
// setup data source
tableView.dataSource = self
}
func setupCustomEmojiPickerDiffableDataSource(
for collectionView: UICollectionView,
dependency: NeedsDependency
) {
let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource(
for: collectionView,
dependency: dependency
)
self.customEmojiPickerDiffableDataSource = diffableDataSource
let _domain = customEmojiViewModel?.domain
customEmojiViewModel?.emojis
.receive(on: DispatchQueue.main)
.sink { [weak self, weak diffableDataSource] emojis in
guard let _ = self else { return }
guard let diffableDataSource = diffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>()
let domain = _domain?.uppercased() ?? " "
let customEmojiSection = CustomEmojiPickerSection.emoji(name: domain)
snapshot.appendSections([customEmojiSection])
let items: [CustomEmojiPickerItem] = {
var items = [CustomEmojiPickerItem]()
for emoji in emojis where emoji.visibleInPicker {
let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji)
let item = CustomEmojiPickerItem.emoji(attribute: attribute)
items.append(item)
}
return items
}()
snapshot.appendItems(items, toSection: customEmojiSection)
diffableDataSource.apply(snapshot)
}
.store(in: &disposeBag)
}
// func setupDataSource(
// tableView: UITableView,
// metaTextDelegate: MetaTextDelegate,
// metaTextViewDelegate: UITextViewDelegate,
// customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
// composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
// composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
// composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
// composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
// ) {
// // UI
// bind()
//
// // content
// bind(cell: composeStatusContentTableViewCell, tableView: tableView)
// composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate
// composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate
//
// // attachment
// bind(cell: composeStatusAttachmentTableViewCell, tableView: tableView)
// composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate
//
// // poll
// bind(cell: composeStatusPollTableViewCell, tableView: tableView)
// composeStatusPollTableViewCell.delegate = self
// composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel
// composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate
// composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate
// composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate
//
// // setup data source
// tableView.dataSource = self
// }
//
// func setupCustomEmojiPickerDiffableDataSource(
// for collectionView: UICollectionView,
// dependency: NeedsDependency
// ) {
// let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource(
// for: collectionView,
// dependency: dependency
// )
// self.customEmojiPickerDiffableDataSource = diffableDataSource
//
// let _domain = customEmojiViewModel?.domain
// customEmojiViewModel?.emojis
// .receive(on: DispatchQueue.main)
// .sink { [weak self, weak diffableDataSource] emojis in
// guard let _ = self else { return }
// guard let diffableDataSource = diffableDataSource else { return }
//
// var snapshot = NSDiffableDataSourceSnapshot<CustomEmojiPickerSection, CustomEmojiPickerItem>()
// let domain = _domain?.uppercased() ?? " "
// let customEmojiSection = CustomEmojiPickerSection.emoji(name: domain)
// snapshot.appendSections([customEmojiSection])
// let items: [CustomEmojiPickerItem] = {
// var items = [CustomEmojiPickerItem]()
// for emoji in emojis where emoji.visibleInPicker {
// let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji)
// let item = CustomEmojiPickerItem.emoji(attribute: attribute)
// items.append(item)
// }
// return items
// }()
// snapshot.appendItems(items, toSection: customEmojiSection)
//
// diffableDataSource.apply(snapshot)
// }
// .store(in: &disposeBag)
// }
}
// MARK: - UITableViewDataSource
extension ComposeViewModel: UITableViewDataSource {
//// MARK: - UITableViewDataSource
//extension ComposeViewModel: UITableViewDataSource {
enum Section: CaseIterable {
case repliedTo
case status
case attachment
case poll
}
// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// switch Section.allCases[indexPath.section] {
// case .repliedTo:
// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell
// guard case let .reply(record) = composeKind else { return cell }
//
// // bind frame publisher
// cell.framePublisher
// .receive(on: DispatchQueue.main)
// .assign(to: \.repliedToCellFrame, on: self)
// .store(in: &cell.disposeBag)
//
// // set initial width
// if cell.statusView.frame.width == .zero {
// cell.statusView.frame.size.width = tableView.frame.width
// }
//
// // configure status
// context.managedObjectContext.performAndWait {
// guard let replyTo = record.object(in: context.managedObjectContext) else { return }
// cell.statusView.configure(status: replyTo)
// }
//
// return cell
// case .status:
// return composeStatusContentTableViewCell
// case .attachment:
// return composeStatusAttachmentTableViewCell
// case .poll:
// return composeStatusPollTableViewCell
// }
// }
//}
func numberOfSections(in tableView: UITableView) -> Int {
return Section.allCases.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch Section.allCases[section] {
case .repliedTo:
switch composeKind {
case .reply: return 1
default: return 0
}
case .status: return 1
case .attachment: return 1
case .poll: return 1
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch Section.allCases[indexPath.section] {
case .repliedTo:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell
guard case let .reply(record) = composeKind else { return cell }
// bind frame publisher
cell.framePublisher
.receive(on: DispatchQueue.main)
.assign(to: \.repliedToCellFrame, on: self)
.store(in: &cell.disposeBag)
// set initial width
if cell.statusView.frame.width == .zero {
cell.statusView.frame.size.width = tableView.frame.width
}
// configure status
context.managedObjectContext.performAndWait {
guard let replyTo = record.object(in: context.managedObjectContext) else { return }
cell.statusView.configure(status: replyTo)
}
return cell
case .status:
return composeStatusContentTableViewCell
case .attachment:
return composeStatusAttachmentTableViewCell
case .poll:
return composeStatusPollTableViewCell
}
}
}
// MARK: - ComposeStatusPollTableViewCellDelegate
extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate {
func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
self.pollOptionAttributes = options
}
}
extension ComposeViewModel {
private func bind() {
$isCustomEmojiComposing
.assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
.store(in: &disposeBag)
$isContentWarningComposing
.assign(to: \.isContentWarningComposing, on: composeStatusAttribute)
.store(in: &disposeBag)
// bind compose toolbar UI state
Publishers.CombineLatest(
$isPollComposing,
$attachmentServices
)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] isPollComposing, attachmentServices in
guard let self = self else { return }
let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments
let shouldPollDisable = attachmentServices.count > 0
self.isMediaToolbarButtonEnabled = !shouldMediaDisable
self.isPollToolbarButtonEnabled = !shouldPollDisable
})
.store(in: &disposeBag)
// calculate `Idempotency-Key`
let content = Publishers.CombineLatest3(
composeStatusAttribute.$isContentWarningComposing,
composeStatusAttribute.$contentWarningContent,
composeStatusAttribute.$composeContent
)
.map { isContentWarningComposing, contentWarningContent, composeContent -> String in
if isContentWarningComposing {
return contentWarningContent + (composeContent ?? "")
} else {
return composeContent ?? ""
}
}
let attachmentIDs = $attachmentServices.map { attachments -> String in
let attachmentIDs = attachments.compactMap { $0.attachment.value?.id }
return attachmentIDs.joined(separator: ",")
}
let pollOptionsAndDuration = Publishers.CombineLatest3(
$isPollComposing,
$pollOptionAttributes,
pollExpiresOptionAttribute.expiresOption
)
.map { isPollComposing, pollOptionAttributes, expiresOption -> String in
guard isPollComposing else {
return ""
}
let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",")
return pollOptions + expiresOption.rawValue
}
Publishers.CombineLatest4(
content,
attachmentIDs,
pollOptionsAndDuration,
$selectedStatusVisibility
)
.map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in
var hasher = Hasher()
hasher.combine(content)
hasher.combine(attachmentIDs)
hasher.combine(pollOptionsAndDuration)
hasher.combine(selectedStatusVisibility.visibility.rawValue)
let hashValue = hasher.finalize()
return "\(hashValue)"
}
.assign(to: \.value, on: idempotencyKey)
.store(in: &disposeBag)
// bind modal dismiss state
composeStatusAttribute.$composeContent
.receive(on: DispatchQueue.main)
.map { [weak self] content in
let content = content ?? ""
if content.isEmpty {
return true
}
// if preInsertedContent plus a space is equal to the content, simply dismiss the modal
if let preInsertedContent = self?.preInsertedContent {
return content == preInsertedContent
}
return false
}
.assign(to: &$shouldDismiss)
// bind compose bar button item UI state
let isComposeContentEmpty = composeStatusAttribute.$composeContent
.map { ($0 ?? "").isEmpty }
let isComposeContentValid = $characterCount
.compactMap { [weak self] characterCount -> Bool in
guard let self = self else { return characterCount <= 500 }
return characterCount <= self.composeContentLimit
}
let isMediaEmpty = $attachmentServices
.map { $0.isEmpty }
let isMediaUploadAllSuccess = $attachmentServices
.map { services in
services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish }
}
let isPollAttributeAllValid = $pollOptionAttributes
.map { pollAttributes in
pollAttributes.allSatisfy { attribute -> Bool in
!attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
isComposeContentEmpty,
isComposeContentValid,
isMediaEmpty,
isMediaUploadAllSuccess
)
.map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
if isMediaEmpty {
return isComposeContentValid && !isComposeContentEmpty
} else {
return isComposeContentValid && isMediaUploadAllSuccess
}
}
.eraseToAnyPublisher()
let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4(
isComposeContentEmpty,
isComposeContentValid,
$isPollComposing,
isPollAttributeAllValid
)
.map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in
if isPollComposing {
return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid
} else {
return isComposeContentValid && !isComposeContentEmpty
}
}
.eraseToAnyPublisher()
Publishers.CombineLatest(
isPublishBarButtonItemEnabledPrecondition1,
isPublishBarButtonItemEnabledPrecondition2
)
.map { $0 && $1 }
.assign(to: &$isPublishBarButtonItemEnabled)
}
}
extension ComposeViewModel {
private func bind(
cell: ComposeStatusContentTableViewCell,
tableView: UITableView
) {
// bind status content character count
Publishers.CombineLatest3(
composeStatusAttribute.$composeContent,
composeStatusAttribute.$isContentWarningComposing,
composeStatusAttribute.$contentWarningContent
)
.map { composeContent, isContentWarningComposing, contentWarningContent -> Int in
let composeContent = composeContent ?? ""
var count = composeContent.count
if isContentWarningComposing {
count += contentWarningContent.count
}
return count
}
.assign(to: &$characterCount)
// bind content warning
composeStatusAttribute.$isContentWarningComposing
.receive(on: DispatchQueue.main)
.sink { [weak cell, weak tableView] isContentWarningComposing in
guard let cell = cell else { return }
guard let tableView = tableView else { return }
// self size input cell
cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing
cell.statusContentWarningEditorView.alpha = 0
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) {
cell.statusContentWarningEditorView.alpha = 1
tableView.beginUpdates()
tableView.endUpdates()
} completion: { _ in
// do nothing
}
}
.store(in: &disposeBag)
cell.contentWarningContent
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak tableView, weak self] text in
guard let self = self else { return }
// bind input data
self.composeStatusAttribute.contentWarningContent = text
// self size input cell
guard let tableView = tableView else { return }
UIView.performWithoutAnimation {
tableView.beginUpdates()
tableView.endUpdates()
}
}
.store(in: &cell.disposeBag)
// configure custom emoji picker
ComposeStatusSection.configureCustomEmojiPicker(
viewModel: customEmojiPickerInputViewModel,
customEmojiReplaceableTextInput: cell.metaText.textView,
disposeBag: &disposeBag
)
ComposeStatusSection.configureCustomEmojiPicker(
viewModel: customEmojiPickerInputViewModel,
customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView,
disposeBag: &disposeBag
)
}
}
extension ComposeViewModel {
private func bind(
cell: ComposeStatusPollTableViewCell,
tableView: UITableView
) {
Publishers.CombineLatest(
$isPollComposing,
$pollOptionAttributes
)
.receive(on: DispatchQueue.main)
.sink { [weak self] isPollComposing, pollOptionAttributes in
guard let self = self else { return }
guard self.isViewAppeared else { return }
let cell = self.composeStatusPollTableViewCell
guard let dataSource = cell.dataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusPollSection, ComposeStatusPollItem>()
snapshot.appendSections([.main])
var items: [ComposeStatusPollItem] = []
if isPollComposing {
for attribute in pollOptionAttributes {
items.append(.pollOption(attribute: attribute))
}
if pollOptionAttributes.count < self.maxPollOptions {
items.append(.pollOptionAppendEntry)
}
items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute))
}
snapshot.appendItems(items, toSection: .main)
tableView.performBatchUpdates {
if #available(iOS 15.0, *) {
dataSource.apply(snapshot, animatingDifferences: false)
} else {
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
.store(in: &disposeBag)
// bind delegate
$pollOptionAttributes
.sink { [weak self] pollAttributes in
guard let self = self else { return }
pollAttributes.forEach { $0.delegate = self }
}
.store(in: &disposeBag)
}
}
extension ComposeViewModel {
private func bind(
cell: ComposeStatusAttachmentTableViewCell,
tableView: UITableView
) {
cell.collectionViewHeightDidUpdate
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let _ = self else { return }
tableView.beginUpdates()
tableView.endUpdates()
}
.store(in: &disposeBag)
$attachmentServices
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] attachmentServices in
guard let self = self else { return }
guard self.isViewAppeared else { return }
let cell = self.composeStatusAttachmentTableViewCell
guard let dataSource = cell.dataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>()
snapshot.appendSections([.main])
let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) }
snapshot.appendItems(items, toSection: .main)
if #available(iOS 15.0, *) {
dataSource.applySnapshotUsingReloadData(snapshot)
} else {
dataSource.apply(snapshot, animatingDifferences: false)
}
}
.store(in: &disposeBag)
// setup attribute updater
$attachmentServices
.receive(on: DispatchQueue.main)
.debounce(for: 0.3, scheduler: DispatchQueue.main)
.sink { attachmentServices in
// drive service upload state
// make image upload in the queue
for attachmentService in attachmentServices {
// skip when prefix N task when task finish OR fail OR uploading
guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
if currentState is MastodonAttachmentService.UploadState.Fail {
continue
}
if currentState is MastodonAttachmentService.UploadState.Finish {
continue
}
if currentState is MastodonAttachmentService.UploadState.Processing {
continue
}
if currentState is MastodonAttachmentService.UploadState.Uploading {
break
}
// trigger uploading one by one
if currentState is MastodonAttachmentService.UploadState.Initial {
attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
break
}
}
}
.store(in: &disposeBag)
// bind delegate
$attachmentServices
.sink { [weak self] attachmentServices in
guard let self = self else { return }
attachmentServices.forEach { $0.delegate = self }
}
.store(in: &disposeBag)
}
}
//// MARK: - ComposeStatusPollTableViewCellDelegate
//extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate {
// func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
//
// self.pollOptionAttributes = options
// }
//}
//
//extension ComposeViewModel {
// private func bind() {
// $isCustomEmojiComposing
// .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing)
// .store(in: &disposeBag)
//
// $isContentWarningComposing
// .assign(to: \.isContentWarningComposing, on: composeStatusAttribute)
// .store(in: &disposeBag)
//
// // bind compose toolbar UI state
// Publishers.CombineLatest(
// $isPollComposing,
// $attachmentServices
// )
// .receive(on: DispatchQueue.main)
// .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in
// guard let self = self else { return }
// let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments
// let shouldPollDisable = attachmentServices.count > 0
//
// self.isMediaToolbarButtonEnabled = !shouldMediaDisable
// self.isPollToolbarButtonEnabled = !shouldPollDisable
// })
// .store(in: &disposeBag)
//
// // calculate `Idempotency-Key`
// let content = Publishers.CombineLatest3(
// composeStatusAttribute.$isContentWarningComposing,
// composeStatusAttribute.$contentWarningContent,
// composeStatusAttribute.$composeContent
// )
// .map { isContentWarningComposing, contentWarningContent, composeContent -> String in
// if isContentWarningComposing {
// return contentWarningContent + (composeContent ?? "")
// } else {
// return composeContent ?? ""
// }
// }
// let attachmentIDs = $attachmentServices.map { attachments -> String in
// let attachmentIDs = attachments.compactMap { $0.attachment.value?.id }
// return attachmentIDs.joined(separator: ",")
// }
// let pollOptionsAndDuration = Publishers.CombineLatest3(
// $isPollComposing,
// $pollOptionAttributes,
// pollExpiresOptionAttribute.expiresOption
// )
// .map { isPollComposing, pollOptionAttributes, expiresOption -> String in
// guard isPollComposing else {
// return ""
// }
//
// let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",")
// return pollOptions + expiresOption.rawValue
// }
//
// Publishers.CombineLatest4(
// content,
// attachmentIDs,
// pollOptionsAndDuration,
// $selectedStatusVisibility
// )
// .map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in
// var hasher = Hasher()
// hasher.combine(content)
// hasher.combine(attachmentIDs)
// hasher.combine(pollOptionsAndDuration)
// hasher.combine(selectedStatusVisibility.visibility.rawValue)
// let hashValue = hasher.finalize()
// return "\(hashValue)"
// }
// .assign(to: \.value, on: idempotencyKey)
// .store(in: &disposeBag)
//
// // bind modal dismiss state
// composeStatusAttribute.$composeContent
// .receive(on: DispatchQueue.main)
// .map { [weak self] content in
// let content = content ?? ""
// if content.isEmpty {
// return true
// }
// // if preInsertedContent plus a space is equal to the content, simply dismiss the modal
// if let preInsertedContent = self?.preInsertedContent {
// return content == preInsertedContent
// }
// return false
// }
// .assign(to: &$shouldDismiss)
//
// // bind compose bar button item UI state
// let isComposeContentEmpty = composeStatusAttribute.$composeContent
// .map { ($0 ?? "").isEmpty }
// let isComposeContentValid = $characterCount
// .compactMap { [weak self] characterCount -> Bool in
// guard let self = self else { return characterCount <= 500 }
// return characterCount <= self.composeContentLimit
// }
// let isMediaEmpty = $attachmentServices
// .map { $0.isEmpty }
// let isMediaUploadAllSuccess = $attachmentServices
// .map { services in
// services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish }
// }
// let isPollAttributeAllValid = $pollOptionAttributes
// .map { pollAttributes in
// pollAttributes.allSatisfy { attribute -> Bool in
// !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
// }
// }
//
// let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4(
// isComposeContentEmpty,
// isComposeContentValid,
// isMediaEmpty,
// isMediaUploadAllSuccess
// )
// .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in
// if isMediaEmpty {
// return isComposeContentValid && !isComposeContentEmpty
// } else {
// return isComposeContentValid && isMediaUploadAllSuccess
// }
// }
// .eraseToAnyPublisher()
//
// let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4(
// isComposeContentEmpty,
// isComposeContentValid,
// $isPollComposing,
// isPollAttributeAllValid
// )
// .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in
// if isPollComposing {
// return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid
// } else {
// return isComposeContentValid && !isComposeContentEmpty
// }
// }
// .eraseToAnyPublisher()
//
// Publishers.CombineLatest(
// isPublishBarButtonItemEnabledPrecondition1,
// isPublishBarButtonItemEnabledPrecondition2
// )
// .map { $0 && $1 }
// .assign(to: &$isPublishBarButtonItemEnabled)
// }
//}
//
//extension ComposeViewModel {
// private func bind(
// cell: ComposeStatusContentTableViewCell,
// tableView: UITableView
// ) {
// // bind status content character count
// Publishers.CombineLatest3(
// composeStatusAttribute.$composeContent,
// composeStatusAttribute.$isContentWarningComposing,
// composeStatusAttribute.$contentWarningContent
// )
// .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in
// let composeContent = composeContent ?? ""
// var count = composeContent.count
// if isContentWarningComposing {
// count += contentWarningContent.count
// }
// return count
// }
// .assign(to: &$characterCount)
//
// // bind content warning
// composeStatusAttribute.$isContentWarningComposing
// .receive(on: DispatchQueue.main)
// .sink { [weak cell, weak tableView] isContentWarningComposing in
// guard let cell = cell else { return }
// guard let tableView = tableView else { return }
//
// // self size input cell
// cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing
// cell.statusContentWarningEditorView.alpha = 0
// UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) {
// cell.statusContentWarningEditorView.alpha = 1
// tableView.beginUpdates()
// tableView.endUpdates()
// } completion: { _ in
// // do nothing
// }
// }
// .store(in: &disposeBag)
//
// cell.contentWarningContent
// .removeDuplicates()
// .receive(on: DispatchQueue.main)
// .sink { [weak tableView, weak self] text in
// guard let self = self else { return }
// // bind input data
// self.composeStatusAttribute.contentWarningContent = text
//
// // self size input cell
// guard let tableView = tableView else { return }
// UIView.performWithoutAnimation {
// tableView.beginUpdates()
// tableView.endUpdates()
// }
// }
// .store(in: &cell.disposeBag)
//
// // configure custom emoji picker
// ComposeStatusSection.configureCustomEmojiPicker(
// viewModel: customEmojiPickerInputViewModel,
// customEmojiReplaceableTextInput: cell.metaText.textView,
// disposeBag: &disposeBag
// )
// ComposeStatusSection.configureCustomEmojiPicker(
// viewModel: customEmojiPickerInputViewModel,
// customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView,
// disposeBag: &disposeBag
// )
// }
//}
//
//extension ComposeViewModel {
// private func bind(
// cell: ComposeStatusPollTableViewCell,
// tableView: UITableView
// ) {
// Publishers.CombineLatest(
// $isPollComposing,
// $pollOptionAttributes
// )
// .receive(on: DispatchQueue.main)
// .sink { [weak self] isPollComposing, pollOptionAttributes in
// guard let self = self else { return }
// guard self.isViewAppeared else { return }
//
// let cell = self.composeStatusPollTableViewCell
// guard let dataSource = cell.dataSource else { return }
//
// var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusPollSection, ComposeStatusPollItem>()
// snapshot.appendSections([.main])
// var items: [ComposeStatusPollItem] = []
// if isPollComposing {
// for attribute in pollOptionAttributes {
// items.append(.pollOption(attribute: attribute))
// }
// if pollOptionAttributes.count < self.maxPollOptions {
// items.append(.pollOptionAppendEntry)
// }
// items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute))
// }
// snapshot.appendItems(items, toSection: .main)
//
// tableView.performBatchUpdates {
// if #available(iOS 15.0, *) {
// dataSource.apply(snapshot, animatingDifferences: false)
// } else {
// dataSource.apply(snapshot, animatingDifferences: true)
// }
// }
// }
// .store(in: &disposeBag)
//
// // bind delegate
// $pollOptionAttributes
// .sink { [weak self] pollAttributes in
// guard let self = self else { return }
// pollAttributes.forEach { $0.delegate = self }
// }
// .store(in: &disposeBag)
// }
//}
//
//extension ComposeViewModel {
// private func bind(
// cell: ComposeStatusAttachmentTableViewCell,
// tableView: UITableView
// ) {
// cell.collectionViewHeightDidUpdate
// .receive(on: DispatchQueue.main)
// .sink { [weak self] _ in
// guard let _ = self else { return }
// tableView.beginUpdates()
// tableView.endUpdates()
// }
// .store(in: &disposeBag)
//
// $attachmentServices
// .removeDuplicates()
// .receive(on: DispatchQueue.main)
// .sink { [weak self] attachmentServices in
// guard let self = self else { return }
// guard self.isViewAppeared else { return }
//
// let cell = self.composeStatusAttachmentTableViewCell
// guard let dataSource = cell.dataSource else { return }
//
// var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>()
// snapshot.appendSections([.main])
// let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) }
// snapshot.appendItems(items, toSection: .main)
//
// if #available(iOS 15.0, *) {
// dataSource.applySnapshotUsingReloadData(snapshot)
// } else {
// dataSource.apply(snapshot, animatingDifferences: false)
// }
// }
// .store(in: &disposeBag)
//
// // setup attribute updater
// $attachmentServices
// .receive(on: DispatchQueue.main)
// .debounce(for: 0.3, scheduler: DispatchQueue.main)
// .sink { attachmentServices in
// // drive service upload state
// // make image upload in the queue
// for attachmentService in attachmentServices {
// // skip when prefix N task when task finish OR fail OR uploading
// guard let currentState = attachmentService.uploadStateMachine.currentState else { break }
// if currentState is MastodonAttachmentService.UploadState.Fail {
// continue
// }
// if currentState is MastodonAttachmentService.UploadState.Finish {
// continue
// }
// if currentState is MastodonAttachmentService.UploadState.Processing {
// continue
// }
// if currentState is MastodonAttachmentService.UploadState.Uploading {
// break
// }
// // trigger uploading one by one
// if currentState is MastodonAttachmentService.UploadState.Initial {
// attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self)
// break
// }
// }
// }
// .store(in: &disposeBag)
//
// // bind delegate
// $attachmentServices
// .sink { [weak self] attachmentServices in
// guard let self = self else { return }
// attachmentServices.forEach { $0.delegate = self }
// }
// .store(in: &disposeBag)
// }
//}

View File

@ -12,153 +12,153 @@ import CoreDataStack
import GameplayKit
import MastodonSDK
extension ComposeViewModel {
class PublishState: GKState {
weak var viewModel: ComposeViewModel?
init(viewModel: ComposeViewModel) {
self.viewModel = viewModel
}
override func didEnter(from previousState: GKState?) {
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
viewModel?.publishStateMachinePublisher.value = self
}
}
}
//extension ComposeViewModel {
// class PublishState: GKState {
// weak var viewModel: ComposeViewModel?
//
// init(viewModel: ComposeViewModel) {
// self.viewModel = viewModel
// }
//
// override func didEnter(from previousState: GKState?) {
// os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
// viewModel?.publishStateMachinePublisher.value = self
// }
// }
//}
extension ComposeViewModel.PublishState {
class Initial: ComposeViewModel.PublishState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Publishing.self
}
}
class Publishing: ComposeViewModel.PublishState {
var publishingSubscription: AnyCancellable?
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return stateClass == Fail.self || stateClass == Finish.self
}
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
viewModel.updatePublishDate()
let authenticationBox = viewModel.authenticationBox
let domain = authenticationBox.domain
let attachmentServices = viewModel.attachmentServices
let mediaIDs = attachmentServices.compactMap { attachmentService in
attachmentService.attachment.value?.id
}
let pollOptions: [String]? = {
guard viewModel.isPollComposing else { return nil }
return viewModel.pollOptionAttributes.map { attribute in attribute.option.value }
}()
let pollExpiresIn: Int? = {
guard viewModel.isPollComposing else { return nil }
return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds
}()
let inReplyToID: Mastodon.Entity.Status.ID? = {
guard case let .reply(status) = viewModel.composeKind else { return nil }
var id: Mastodon.Entity.Status.ID?
viewModel.context.managedObjectContext.performAndWait {
guard let replyTo = status.object(in: viewModel.context.managedObjectContext) else { return }
id = replyTo.id
}
return id
}()
let sensitive: Bool = viewModel.isContentWarningComposing
let spoilerText: String? = {
let text = viewModel.composeStatusAttribute.contentWarningContent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
return nil
}
return text
}()
let visibility = viewModel.selectedStatusVisibility.visibility
let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = []
for attachmentService in attachmentServices {
guard let attachmentID = attachmentService.attachment.value?.id else { continue }
let description = attachmentService.description.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !description.isEmpty else { continue }
let query = Mastodon.API.Media.UpdateMediaQuery(
file: nil,
thumbnail: nil,
description: description,
focus: nil
)
let subscription = viewModel.context.apiService.updateMedia(
domain: domain,
attachmentID: attachmentID,
query: query,
mastodonAuthenticationBox: authenticationBox
)
subscriptions.append(subscription)
}
return subscriptions
}()
let idempotencyKey = viewModel.idempotencyKey.value
publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions)
.collect()
.asyncMap { attachments -> Mastodon.Response.Content<Mastodon.Entity.Status> in
let query = Mastodon.API.Statuses.PublishStatusQuery(
status: viewModel.composeStatusAttribute.composeContent,
mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn,
inReplyToID: inReplyToID,
sensitive: sensitive,
spoilerText: spoilerText,
visibility: visibility
)
return try await viewModel.context.apiService.publishStatus(
domain: domain,
idempotencyKey: idempotencyKey,
query: query,
authenticationBox: authenticationBox
)
}
.receive(on: DispatchQueue.main)
.sink { completion in
switch completion {
case .failure(let error):
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
stateMachine.enter(Fail.self)
case .finished:
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function)
stateMachine.enter(Finish.self)
}
} receiveValue: { response in
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri)
}
}
}
class Fail: ComposeViewModel.PublishState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// allow discard publishing
return stateClass == Publishing.self || stateClass == Discard.self
}
}
class Discard: ComposeViewModel.PublishState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return false
}
}
class Finish: ComposeViewModel.PublishState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
return false
}
}
}
//extension ComposeViewModel.PublishState {
// class Initial: ComposeViewModel.PublishState {
// override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// return stateClass == Publishing.self
// }
// }
//
// class Publishing: ComposeViewModel.PublishState {
//
// var publishingSubscription: AnyCancellable?
//
// override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// return stateClass == Fail.self || stateClass == Finish.self
// }
//
// override func didEnter(from previousState: GKState?) {
// super.didEnter(from: previousState)
// guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
//
// viewModel.updatePublishDate()
//
// let authenticationBox = viewModel.authenticationBox
// let domain = authenticationBox.domain
// let attachmentServices = viewModel.attachmentServices
// let mediaIDs = attachmentServices.compactMap { attachmentService in
// attachmentService.attachment.value?.id
// }
// let pollOptions: [String]? = {
// guard viewModel.isPollComposing else { return nil }
// return viewModel.pollOptionAttributes.map { attribute in attribute.option.value }
// }()
// let pollExpiresIn: Int? = {
// guard viewModel.isPollComposing else { return nil }
// return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds
// }()
// let inReplyToID: Mastodon.Entity.Status.ID? = {
// guard case let .reply(status) = viewModel.composeKind else { return nil }
// var id: Mastodon.Entity.Status.ID?
// viewModel.context.managedObjectContext.performAndWait {
// guard let replyTo = status.object(in: viewModel.context.managedObjectContext) else { return }
// id = replyTo.id
// }
// return id
// }()
// let sensitive: Bool = viewModel.isContentWarningComposing
// let spoilerText: String? = {
// let text = viewModel.composeStatusAttribute.contentWarningContent.trimmingCharacters(in: .whitespacesAndNewlines)
// guard !text.isEmpty else {
// return nil
// }
// return text
// }()
// let visibility = viewModel.selectedStatusVisibility.visibility
//
// let updateMediaQuerySubscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = {
// var subscriptions: [AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error>] = []
// for attachmentService in attachmentServices {
// guard let attachmentID = attachmentService.attachment.value?.id else { continue }
// let description = attachmentService.description.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// guard !description.isEmpty else { continue }
// let query = Mastodon.API.Media.UpdateMediaQuery(
// file: nil,
// thumbnail: nil,
// description: description,
// focus: nil
// )
// let subscription = viewModel.context.apiService.updateMedia(
// domain: domain,
// attachmentID: attachmentID,
// query: query,
// mastodonAuthenticationBox: authenticationBox
// )
// subscriptions.append(subscription)
// }
// return subscriptions
// }()
//
// let idempotencyKey = viewModel.idempotencyKey.value
//
// publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions)
// .collect()
// .asyncMap { attachments -> Mastodon.Response.Content<Mastodon.Entity.Status> in
// let query = Mastodon.API.Statuses.PublishStatusQuery(
// status: viewModel.composeStatusAttribute.composeContent,
// mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs,
// pollOptions: pollOptions,
// pollExpiresIn: pollExpiresIn,
// inReplyToID: inReplyToID,
// sensitive: sensitive,
// spoilerText: spoilerText,
// visibility: visibility
// )
// return try await viewModel.context.apiService.publishStatus(
// domain: domain,
// idempotencyKey: idempotencyKey,
// query: query,
// authenticationBox: authenticationBox
// )
// }
// .receive(on: DispatchQueue.main)
// .sink { completion in
// switch completion {
// case .failure(let error):
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
// stateMachine.enter(Fail.self)
// case .finished:
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function)
// stateMachine.enter(Finish.self)
// }
// } receiveValue: { response in
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri)
// }
// }
// }
//
// class Fail: ComposeViewModel.PublishState {
// override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// // allow discard publishing
// return stateClass == Publishing.self || stateClass == Discard.self
// }
// }
//
// class Discard: ComposeViewModel.PublishState {
// override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// return false
// }
// }
//
// class Finish: ComposeViewModel.PublishState {
// override func isValidNextState(_ stateClass: AnyClass) -> Bool {
// return false
// }
// }
//
//}

View File

@ -13,6 +13,7 @@ import CoreDataStack
import GameplayKit
import MastodonSDK
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonMeta
import MastodonUI
@ -27,159 +28,159 @@ final class ComposeViewModel: NSObject {
// input
let context: AppContext
let composeKind: ComposeStatusSection.ComposeKind
let authenticationBox: MastodonAuthenticationBox
let authContext: AuthContext
let kind: ComposeContentViewModel.Kind
@Published var isPollComposing = false
@Published var isCustomEmojiComposing = false
@Published var isContentWarningComposing = false
@Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType
@Published var repliedToCellFrame: CGRect = .zero
@Published var autoCompleteRetryLayoutTimes = 0
@Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
// var authenticationBox: MastodonAuthenticationBox {
// authContext.mastodonAuthenticationBox
// }
//
// @Published var isPollComposing = false
// @Published var isCustomEmojiComposing = false
// @Published var isContentWarningComposing = false
//
// @Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType
// @Published var repliedToCellFrame: CGRect = .zero
// @Published var autoCompleteRetryLayoutTimes = 0
// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
var isViewAppeared = false
// var isViewAppeared = false
// output
let instanceConfiguration: Mastodon.Entity.Instance.Configuration?
var composeContentLimit: Int {
guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 }
return max(1, maxCharacters)
}
var maxMediaAttachments: Int {
guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else {
return 4
}
// FIXME: update timeline media preview UI
return min(4, max(1, maxMediaAttachments))
// return max(1, maxMediaAttachments)
}
var maxPollOptions: Int {
guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 }
return max(2, maxOptions)
}
let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell()
let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell()
// var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>?
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
private(set) lazy var publishStateMachine: GKStateMachine = {
// exclude timeline middle fetcher state
let stateMachine = GKStateMachine(states: [
PublishState.Initial(viewModel: self),
PublishState.Publishing(viewModel: self),
PublishState.Fail(viewModel: self),
PublishState.Discard(viewModel: self),
PublishState.Finish(viewModel: self),
])
stateMachine.enter(PublishState.Initial.self)
return stateMachine
}()
private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
private(set) var publishDate = Date() // update it when enter Publishing state
// TODO: group post material into Hashable class
var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString)
// UI & UX
@Published var title: String
@Published var shouldDismiss = true
@Published var isPublishBarButtonItemEnabled = false
@Published var isMediaToolbarButtonEnabled = true
@Published var isPollToolbarButtonEnabled = true
@Published var characterCount = 0
@Published var collectionViewState: CollectionViewState = .fold
// for hashtag: "#<hashtag> "
// for mention: "@<mention> "
var preInsertedContent: String?
// custom emojis
let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
@Published var isLoadingCustomEmoji = false
// attachment
@Published var attachmentServices: [MastodonAttachmentService] = []
// polls
@Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = []
let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute()
// let instanceConfiguration: Mastodon.Entity.Instance.Configuration?
// var composeContentLimit: Int {
// guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 }
// return max(1, maxCharacters)
// }
// var maxMediaAttachments: Int {
// guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else {
// return 4
// }
// // FIXME: update timeline media preview UI
// return min(4, max(1, maxMediaAttachments))
// // return max(1, maxMediaAttachments)
// }
// var maxPollOptions: Int {
// guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 }
// return max(2, maxOptions)
// }
//
// let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute()
// let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
// let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell()
// let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell()
//
// // var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>?
// var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>?
// private(set) lazy var publishStateMachine: GKStateMachine = {
// // exclude timeline middle fetcher state
// let stateMachine = GKStateMachine(states: [
// PublishState.Initial(viewModel: self),
// PublishState.Publishing(viewModel: self),
// PublishState.Fail(viewModel: self),
// PublishState.Discard(viewModel: self),
// PublishState.Finish(viewModel: self),
// ])
// stateMachine.enter(PublishState.Initial.self)
// return stateMachine
// }()
// private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
// private(set) var publishDate = Date() // update it when enter Publishing state
//
// // TODO: group post material into Hashable class
// var idempotencyKey = CurrentValueSubject<String, Never>(UUID().uuidString)
//
// // UI & UX
// @Published var title: String
// @Published var shouldDismiss = true
// @Published var isPublishBarButtonItemEnabled = false
// @Published var isMediaToolbarButtonEnabled = true
// @Published var isPollToolbarButtonEnabled = true
// @Published var characterCount = 0
// @Published var collectionViewState: CollectionViewState = .fold
//
// // for hashtag: "#<hashtag> "
// // for mention: "@<mention> "
// var preInsertedContent: String?
//
// // custom emojis
// let customEmojiViewModel: EmojiService.CustomEmojiViewModel?
// let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel()
// @Published var isLoadingCustomEmoji = false
//
// // attachment
// @Published var attachmentServices: [MastodonAttachmentService] = []
//
// // polls
// @Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = []
// let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute()
init(
context: AppContext,
composeKind: ComposeStatusSection.ComposeKind,
authenticationBox: MastodonAuthenticationBox
authContext: AuthContext,
kind: ComposeContentViewModel.Kind
) {
self.context = context
self.composeKind = composeKind
self.authenticationBox = authenticationBox
self.title = {
switch composeKind {
case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
case .reply: return L10n.Scene.Compose.Title.newReply
}
}()
self.selectedStatusVisibility = {
// default private when user locked
var visibility: ComposeToolbarView.VisibilitySelectionType = {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value,
let author = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
else {
return .public
}
return author.locked ? .private : .public
}()
// set visibility for reply post
switch composeKind {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
assertionFailure()
return
}
let repliedStatusVisibility = status.visibility
switch repliedStatusVisibility {
case .public, .unlisted:
// keep default
break
case .private:
visibility = .private
case .direct:
visibility = .direct
case ._other:
assertionFailure()
break
}
}
default:
break
}
return visibility
}()
// set limit
self.instanceConfiguration = {
var configuration: Mastodon.Entity.Instance.Configuration? = nil
context.managedObjectContext.performAndWait {
guard let authentication = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)
else {
return
}
configuration = authentication.instance?.configuration
}
return configuration
}()
self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authenticationBox.domain)
super.init()
// end init
self.authContext = authContext
self.kind = kind
setup(cell: composeStatusContentTableViewCell)
// self.title = {
// switch composeKind {
// case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost
// case .reply: return L10n.Scene.Compose.Title.newReply
// }
// }()
// self.selectedStatusVisibility = {
// // default private when user locked
// var visibility: ComposeToolbarView.VisibilitySelectionType = {
// guard let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
// else {
// return .public
// }
// return author.locked ? .private : .public
// }()
// // set visibility for reply post
// switch composeKind {
// case .reply(let record):
// context.managedObjectContext.performAndWait {
// guard let status = record.object(in: context.managedObjectContext) else {
// assertionFailure()
// return
// }
// let repliedStatusVisibility = status.visibility
// switch repliedStatusVisibility {
// case .public, .unlisted:
// // keep default
// break
// case .private:
// visibility = .private
// case .direct:
// visibility = .direct
// case ._other:
// assertionFailure()
// break
// }
// }
// default:
// break
// }
// return visibility
// }()
// // set limit
// self.instanceConfiguration = {
// var configuration: Mastodon.Entity.Instance.Configuration? = nil
// context.managedObjectContext.performAndWait {
// guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) else { return }
// configuration = authentication.instance?.configuration
// }
// return configuration
// }()
// self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authContext.mastodonAuthenticationBox.domain)
// super.init()
// // end init
//
// setup(cell: composeStatusContentTableViewCell)
}
deinit {
@ -189,199 +190,192 @@ final class ComposeViewModel: NSObject {
}
extension ComposeViewModel {
enum CollectionViewState {
case fold // snap to input
case expand // snap to reply
}
// func createNewPollOptionIfPossible() {
// guard pollOptionAttributes.count < maxPollOptions else { return }
//
// let attribute = ComposeStatusPollItem.PollOptionAttribute()
// pollOptionAttributes = pollOptionAttributes + [attribute]
// }
//
// func updatePublishDate() {
// publishDate = Date()
// }
}
extension ComposeViewModel {
func createNewPollOptionIfPossible() {
guard pollOptionAttributes.count < maxPollOptions else { return }
let attribute = ComposeStatusPollItem.PollOptionAttribute()
pollOptionAttributes = pollOptionAttributes + [attribute]
}
func updatePublishDate() {
publishDate = Date()
}
}
extension ComposeViewModel {
enum AttachmentPrecondition: Error, LocalizedError {
case videoAttachWithPhoto
case moreThanOneVideo
var errorDescription: String? {
return L10n.Common.Alerts.PublishPostFailure.title
}
var failureReason: String? {
switch self {
case .videoAttachWithPhoto:
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
case .moreThanOneVideo:
return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
}
}
}
// check exclusive limit:
// - up to 1 video
// - up to N photos
func checkAttachmentPrecondition() throws {
let attachmentServices = self.attachmentServices
guard !attachmentServices.isEmpty else { return }
var photoAttachmentServices: [MastodonAttachmentService] = []
var videoAttachmentServices: [MastodonAttachmentService] = []
attachmentServices.forEach { service in
guard let file = service.file.value else {
assertionFailure()
return
}
switch file {
case .jpeg, .png, .gif:
photoAttachmentServices.append(service)
case .other:
videoAttachmentServices.append(service)
}
}
if !videoAttachmentServices.isEmpty {
guard videoAttachmentServices.count == 1 else {
throw AttachmentPrecondition.moreThanOneVideo
}
guard photoAttachmentServices.isEmpty else {
throw AttachmentPrecondition.videoAttachWithPhoto
}
}
}
}
// MARK: - MastodonAttachmentServiceDelegate
extension ComposeViewModel: MastodonAttachmentServiceDelegate {
func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
// trigger new output event
attachmentServices = attachmentServices
}
}
// MARK: - ComposePollAttributeDelegate
extension ComposeViewModel: ComposePollAttributeDelegate {
func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) {
// trigger update
pollOptionAttributes = pollOptionAttributes
}
}
extension ComposeViewModel {
private func setup(
cell: ComposeStatusContentTableViewCell
) {
setupStatusHeader(cell: cell)
setupStatusAuthor(cell: cell)
setupStatusContent(cell: cell)
}
private func setupStatusHeader(
cell: ComposeStatusContentTableViewCell
) {
// configure header
let managedObjectContext = context.managedObjectContext
managedObjectContext.performAndWait {
guard case let .reply(record) = self.composeKind,
let replyTo = record.object(in: managedObjectContext)
else {
cell.statusView.viewModel.header = .none
return
}
let info: StatusView.ViewModel.Header.ReplyInfo
do {
let content = MastodonContent(
content: replyTo.author.displayNameWithFallback,
emojis: replyTo.author.emojis.asDictionary
)
let metaContent = try MastodonMetaContent.convert(document: content)
info = .init(header: metaContent)
} catch {
let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback)
info = .init(header: metaContent)
}
cell.statusView.viewModel.header = .reply(info: info)
}
}
private func setupStatusAuthor(
cell: ComposeStatusContentTableViewCell
) {
self.context.managedObjectContext.performAndWait {
guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return }
cell.statusView.configureAuthor(author: author)
}
}
private func setupStatusContent(
cell: ComposeStatusContentTableViewCell
) {
switch composeKind {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else { return }
let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
var mentionAccts: [String] = []
if author?.id != status.author.id {
mentionAccts.append("@" + status.author.acct)
}
let mentions = status.mentions
.filter { author?.id != $0.id }
for mention in mentions {
let acct = "@" + mention.acct
guard !mentionAccts.contains(acct) else { continue }
mentionAccts.append(acct)
}
for acct in mentionAccts {
UITextChecker.learnWord(acct)
}
if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
self.isContentWarningComposing = true
self.composeStatusAttribute.contentWarningContent = spoilerText
}
let initialComposeContent = mentionAccts.joined(separator: " ")
let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent = preInsertedContent
}
case .hashtag(let hashtag):
let initialComposeContent = "#" + hashtag
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent = preInsertedContent
case .mention(let record):
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
let initialComposeContent = "@" + user.acct
UITextChecker.learnWord(initialComposeContent)
let preInsertedContent = initialComposeContent + " "
self.preInsertedContent = preInsertedContent
self.composeStatusAttribute.composeContent = preInsertedContent
}
case .post:
self.preInsertedContent = nil
}
// configure content warning
if let composeContent = composeStatusAttribute.composeContent {
cell.metaText.textView.text = composeContent
}
// configure content warning
cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent
}
}
//extension ComposeViewModel {
//
// enum AttachmentPrecondition: Error, LocalizedError {
// case videoAttachWithPhoto
// case moreThanOneVideo
//
// var errorDescription: String? {
// return L10n.Common.Alerts.PublishPostFailure.title
// }
//
// var failureReason: String? {
// switch self {
// case .videoAttachWithPhoto:
// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto
// case .moreThanOneVideo:
// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo
// }
// }
// }
//
// // check exclusive limit:
// // - up to 1 video
// // - up to N photos
// func checkAttachmentPrecondition() throws {
// let attachmentServices = self.attachmentServices
// guard !attachmentServices.isEmpty else { return }
// var photoAttachmentServices: [MastodonAttachmentService] = []
// var videoAttachmentServices: [MastodonAttachmentService] = []
// attachmentServices.forEach { service in
// guard let file = service.file.value else {
// assertionFailure()
// return
// }
// switch file {
// case .jpeg, .png, .gif:
// photoAttachmentServices.append(service)
// case .other:
// videoAttachmentServices.append(service)
// }
// }
//
// if !videoAttachmentServices.isEmpty {
// guard videoAttachmentServices.count == 1 else {
// throw AttachmentPrecondition.moreThanOneVideo
// }
// guard photoAttachmentServices.isEmpty else {
// throw AttachmentPrecondition.videoAttachWithPhoto
// }
// }
// }
//
//}
//
//// MARK: - MastodonAttachmentServiceDelegate
//extension ComposeViewModel: MastodonAttachmentServiceDelegate {
// func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) {
// // trigger new output event
// attachmentServices = attachmentServices
// }
//}
//
//// MARK: - ComposePollAttributeDelegate
//extension ComposeViewModel: ComposePollAttributeDelegate {
// func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) {
// // trigger update
// pollOptionAttributes = pollOptionAttributes
// }
//}
//
//extension ComposeViewModel {
// private func setup(
// cell: ComposeStatusContentTableViewCell
// ) {
// setupStatusHeader(cell: cell)
// setupStatusAuthor(cell: cell)
// setupStatusContent(cell: cell)
// }
//
// private func setupStatusHeader(
// cell: ComposeStatusContentTableViewCell
// ) {
// // configure header
// let managedObjectContext = context.managedObjectContext
// managedObjectContext.performAndWait {
// guard case let .reply(record) = self.composeKind,
// let replyTo = record.object(in: managedObjectContext)
// else {
// cell.statusView.viewModel.header = .none
// return
// }
//
// let info: StatusView.ViewModel.Header.ReplyInfo
// do {
// let content = MastodonContent(
// content: replyTo.author.displayNameWithFallback,
// emojis: replyTo.author.emojis.asDictionary
// )
// let metaContent = try MastodonMetaContent.convert(document: content)
// info = .init(header: metaContent)
// } catch {
// let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback)
// info = .init(header: metaContent)
// }
// cell.statusView.viewModel.header = .reply(info: info)
// }
// }
//
// private func setupStatusAuthor(
// cell: ComposeStatusContentTableViewCell
// ) {
// self.context.managedObjectContext.performAndWait {
// guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return }
// cell.statusView.configureAuthor(author: author)
// }
// }
//
// private func setupStatusContent(
// cell: ComposeStatusContentTableViewCell
// ) {
// switch composeKind {
// case .reply(let record):
// context.managedObjectContext.performAndWait {
// guard let status = record.object(in: context.managedObjectContext) else { return }
// let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user
//
// var mentionAccts: [String] = []
// if author?.id != status.author.id {
// mentionAccts.append("@" + status.author.acct)
// }
// let mentions = status.mentions
// .filter { author?.id != $0.id }
// for mention in mentions {
// let acct = "@" + mention.acct
// guard !mentionAccts.contains(acct) else { continue }
// mentionAccts.append(acct)
// }
// for acct in mentionAccts {
// UITextChecker.learnWord(acct)
// }
// if let spoilerText = status.spoilerText, !spoilerText.isEmpty {
// self.isContentWarningComposing = true
// self.composeStatusAttribute.contentWarningContent = spoilerText
// }
//
// let initialComposeContent = mentionAccts.joined(separator: " ")
// let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " "
// self.preInsertedContent = preInsertedContent
// self.composeStatusAttribute.composeContent = preInsertedContent
// }
// case .hashtag(let hashtag):
// let initialComposeContent = "#" + hashtag
// UITextChecker.learnWord(initialComposeContent)
// let preInsertedContent = initialComposeContent + " "
// self.preInsertedContent = preInsertedContent
// self.composeStatusAttribute.composeContent = preInsertedContent
// case .mention(let record):
// context.managedObjectContext.performAndWait {
// guard let user = record.object(in: context.managedObjectContext) else { return }
// let initialComposeContent = "@" + user.acct
// UITextChecker.learnWord(initialComposeContent)
// let preInsertedContent = initialComposeContent + " "
// self.preInsertedContent = preInsertedContent
// self.composeStatusAttribute.composeContent = preInsertedContent
// }
// case .post:
// self.preInsertedContent = nil
// }
//
// // configure content warning
// if let composeContent = composeStatusAttribute.composeContent {
// cell.metaText.textView.text = composeContent
// }
//
// // configure content warning
// cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent
// }
//}

View File

@ -1,163 +0,0 @@
//
// ComposeStatusAttachmentTableViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-29.
//
import UIKit
import Combine
import AlamofireImage
import MastodonAsset
import MastodonLocalization
final class ComposeStatusAttachmentTableViewCell: UITableViewCell {
private(set) var dataSource: UICollectionViewDiffableDataSource<ComposeStatusAttachmentSection, ComposeStatusAttachmentItem>!
weak var composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate?
var observations = Set<NSKeyValueObservation>()
private static func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = .readableContent
return UICollectionViewCompositionalLayout(section: section)
}
private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint!
let collectionView: UICollectionView = {
let collectionViewLayout = ComposeStatusAttachmentTableViewCell.createLayout()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self))
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.isScrollEnabled = false
return collectionView
}()
let collectionViewHeightDidUpdate = PassthroughSubject<Void, Never>()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeStatusAttachmentTableViewCell {
private func _init() {
backgroundColor = .clear
contentView.backgroundColor = .clear
collectionView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(collectionView)
collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 200).priority(.defaultHigh)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: contentView.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
collectionViewHeightLayoutConstraint,
])
collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in
guard let self = self else { return }
self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height
self.collectionViewHeightDidUpdate.send()
}
.store(in: &observations)
self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [
weak self
] collectionView, indexPath, item -> UICollectionViewCell? in
guard let self = self else { return UICollectionViewCell() }
switch item {
case .attachment(let attachmentService):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell
cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value
cell.delegate = self.composeStatusAttachmentCollectionViewCellDelegate
attachmentService.thumbnailImage
.receive(on: DispatchQueue.main)
.sink { [weak cell] thumbnailImage in
guard let cell = cell else { return }
let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1)
guard let image = thumbnailImage else {
let placeholder = UIImage.placeholder(
size: size,
color: ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor
)
.af.imageRounded(
withCornerRadius: AttachmentContainerView.containerViewCornerRadius
)
cell.attachmentContainerView.previewImageView.image = placeholder
return
}
// cannot get correct size. set corner radius on layer
cell.attachmentContainerView.previewImageView.image = image
}
.store(in: &cell.disposeBag)
Publishers.CombineLatest(
attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(),
attachmentService.error.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink { [weak cell, weak attachmentService] uploadState, error in
guard let cell = cell else { return }
guard let attachmentService = attachmentService else { return }
cell.attachmentContainerView.emptyStateView.isHidden = error == nil
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
if let error = error {
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
cell.attachmentContainerView.emptyStateView.label.text = error.localizedDescription
} else {
guard let uploadState = uploadState else { return }
switch uploadState {
case is MastodonAttachmentService.UploadState.Finish:
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
case is MastodonAttachmentService.UploadState.Fail:
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
// FIXME: not display
cell.attachmentContainerView.emptyStateView.label.text = {
if let file = attachmentService.file.value {
switch file {
case .jpeg, .png, .gif:
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
case .other:
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
}
} else {
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
}
}()
default:
break
}
}
}
.store(in: &cell.disposeBag)
NotificationCenter.default.publisher(
for: UITextView.textDidChangeNotification,
object: cell.attachmentContainerView.descriptionTextView
)
.receive(on: DispatchQueue.main)
.sink { notification in
guard let textField = notification.object as? UITextView else { return }
let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines)
attachmentService.description.value = text
}
.store(in: &cell.disposeBag)
return cell
}
}
}
}

View File

@ -1,172 +0,0 @@
//
// ComposeStatusContentTableViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-28.
//
import os.log
import UIKit
import Combine
import MetaTextKit
import UITextView_Placeholder
import MastodonAsset
import MastodonLocalization
import MastodonUI
protocol ComposeStatusContentTableViewCellDelegate: AnyObject {
func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool
}
final class ComposeStatusContentTableViewCell: UITableViewCell {
let logger = Logger(subsystem: "ComposeStatusContentTableViewCell", category: "View")
var disposeBag = Set<AnyCancellable>()
weak var delegate: ComposeStatusContentTableViewCellDelegate?
let statusView = StatusView()
let statusContentWarningEditorView = StatusContentWarningEditorView()
let textEditorViewContainerView = UIView()
static let metaTextViewTag: Int = 333
let metaText: MetaText = {
let metaText = MetaText()
metaText.textView.backgroundColor = .clear
metaText.textView.isScrollEnabled = false
metaText.textView.keyboardType = .twitter
metaText.textView.textDragInteraction?.isEnabled = false // disable drag for link and attachment
metaText.textView.textContainer.lineFragmentPadding = 10 // leading inset
metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
metaText.textView.attributedPlaceholder = {
var attributes = metaText.textAttributes
attributes[.foregroundColor] = Asset.Colors.Label.secondary.color
return NSAttributedString(
string: L10n.Scene.Compose.contentInputPlaceholder,
attributes: attributes
)
}()
metaText.paragraphStyle = {
let style = NSMutableParagraphStyle()
style.lineSpacing = 5
style.paragraphSpacing = 0
return style
}()
metaText.textAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)),
.foregroundColor: Asset.Colors.Label.primary.color,
]
metaText.linkAttributes = [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)),
.foregroundColor: Asset.Colors.brand.color,
]
return metaText
}()
// output
let contentWarningContent = PassthroughSubject<String, Never>()
override func prepareForReuse() {
super.prepareForReuse()
metaText.delegate = nil
metaText.textView.delegate = nil
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeStatusContentTableViewCell {
private func _init() {
selectionStyle = .none
layer.zPosition = 999
backgroundColor = .clear
preservesSuperviewLayoutMargins = true
let containerStackView = UIStackView()
containerStackView.axis = .vertical
containerStackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containerStackView)
NSLayoutConstraint.activate([
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
containerStackView.preservesSuperviewLayoutMargins = true
containerStackView.addArrangedSubview(statusContentWarningEditorView)
statusContentWarningEditorView.setContentHuggingPriority(.required - 1, for: .vertical)
let statusContainerView = UIView()
statusContainerView.preservesSuperviewLayoutMargins = true
containerStackView.addArrangedSubview(statusContainerView)
statusView.translatesAutoresizingMaskIntoConstraints = false
statusContainerView.addSubview(statusView)
NSLayoutConstraint.activate([
statusView.topAnchor.constraint(equalTo: statusContainerView.topAnchor, constant: 20),
statusView.leadingAnchor.constraint(equalTo: statusContainerView.leadingAnchor),
statusView.trailingAnchor.constraint(equalTo: statusContainerView.trailingAnchor),
statusView.bottomAnchor.constraint(equalTo: statusContainerView.bottomAnchor),
])
statusView.setup(style: .composeStatusAuthor)
containerStackView.addArrangedSubview(textEditorViewContainerView)
metaText.textView.translatesAutoresizingMaskIntoConstraints = false
textEditorViewContainerView.addSubview(metaText.textView)
NSLayoutConstraint.activate([
metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.leadingAnchor),
metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.trailingAnchor),
metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).priority(.defaultHigh),
])
statusContentWarningEditorView.textView.delegate = self
}
}
// MARK: - UITextViewDelegate
extension ComposeStatusContentTableViewCell: UITextViewDelegate {
func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
return delegate?.composeStatusContentTableViewCell(self, textViewShouldBeginEditing: textView) ?? true
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
switch textView {
case statusContentWarningEditorView.textView:
// disable input line break
guard text != "\n" else { return false }
return true
default:
assertionFailure()
return true
}
}
func textViewDidChange(_ textView: UITextView) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): text: \(textView.text ?? "<nil>")")
guard textView === statusContentWarningEditorView.textView else { return }
// replace line break with space
// needs check input state to prevent break the IME
if textView.markedTextRange == nil {
textView.text = textView.text.replacingOccurrences(of: "\n", with: " ")
}
contentWarningContent.send(textView.text)
}
}

View File

@ -1,209 +0,0 @@
//
// ComposeStatusPollTableViewCell.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-6-29.
//
import os.log
import UIKit
import Combine
import MastodonAsset
import MastodonLocalization
protocol ComposeStatusPollTableViewCellDelegate: AnyObject {
func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute])
}
final class ComposeStatusPollTableViewCell: UITableViewCell {
let logger = Logger(subsystem: "ComposeStatusPollTableViewCell", category: "UI")
private(set) var dataSource: UICollectionViewDiffableDataSource<ComposeStatusPollSection, ComposeStatusPollItem>!
var observations = Set<NSKeyValueObservation>()
weak var customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel?
weak var delegate: ComposeStatusPollTableViewCellDelegate?
weak var composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate?
weak var composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate?
weak var composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate?
private static func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = .readableContent
return UICollectionViewCompositionalLayout(section: section)
}
private(set) var collectionViewHeightLayoutConstraint: NSLayoutConstraint!
let collectionView: UICollectionView = {
let collectionViewLayout = ComposeStatusPollTableViewCell.createLayout()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self))
collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self))
collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self))
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.isScrollEnabled = false
collectionView.dragInteractionEnabled = true
return collectionView
}()
let collectionViewHeightDidUpdate = PassthroughSubject<Void, Never>()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
extension ComposeStatusPollTableViewCell {
private func _init() {
backgroundColor = .clear
contentView.backgroundColor = .clear
collectionView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(collectionView)
collectionViewHeightLayoutConstraint = collectionView.heightAnchor.constraint(equalToConstant: 300).priority(.defaultHigh)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: contentView.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
collectionViewHeightLayoutConstraint,
])
collectionView.observe(\.contentSize, options: [.initial, .new]) { [weak self] collectionView, _ in
guard let self = self else { return }
self.collectionViewHeightLayoutConstraint.constant = collectionView.contentSize.height
self.collectionViewHeightDidUpdate.send()
}
.store(in: &observations)
self.dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [
weak self
] collectionView, indexPath, item -> UICollectionViewCell? in
guard let self = self else { return UICollectionViewCell() }
switch item {
case .pollOption(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell
cell.pollOptionView.optionTextField.text = attribute.option.value
cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1)
cell.pollOption
.receive(on: DispatchQueue.main)
.assign(to: \.value, on: attribute.option)
.store(in: &cell.disposeBag)
cell.delegate = self.composeStatusPollOptionCollectionViewCellDelegate
if let customEmojiPickerInputViewModel = self.customEmojiPickerInputViewModel {
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag)
}
return cell
case .pollOptionAppendEntry:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell
cell.delegate = self.composeStatusPollOptionAppendEntryCollectionViewCellDelegate
return cell
case .pollExpiresOption(let attribute):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal)
attribute.expiresOption
.receive(on: DispatchQueue.main)
.sink { [weak cell] expiresOption in
guard let cell = cell else { return }
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
}
.store(in: &cell.disposeBag)
cell.delegate = self.composeStatusPollExpiresOptionCollectionViewCellDelegate
return cell
}
}
collectionView.dragDelegate = self
collectionView.dropDelegate = self
}
}
// MARK: - UICollectionViewDragDelegate
extension ComposeStatusPollTableViewCell: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return [] }
switch item {
case .pollOption:
let itemProvider = NSItemProvider(object: String(item.hashValue) as NSString)
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = item
return [dragItem]
default:
return []
}
}
func collectionView(_ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool {
// drag to app should be the same app
return true
}
}
// MARK: - UICollectionViewDropDelegate
extension ComposeStatusPollTableViewCell: UICollectionViewDropDelegate {
// didUpdate
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
guard collectionView.hasActiveDrag,
let destinationIndexPath = destinationIndexPath,
let item = dataSource.itemIdentifier(for: destinationIndexPath)
else {
return UICollectionViewDropProposal(operation: .forbidden)
}
switch item {
case .pollOption:
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
default:
return UICollectionViewDropProposal(operation: .cancel)
}
}
// performDrop
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
guard let dropItem = coordinator.items.first,
let item = dropItem.dragItem.localObject as? ComposeStatusPollItem,
case .pollOption = item
else { return }
guard coordinator.proposal.operation == .move else { return }
guard let destinationIndexPath = coordinator.destinationIndexPath,
let _ = collectionView.cellForItem(at: destinationIndexPath) as? ComposeStatusPollOptionCollectionViewCell
else { return }
var snapshot = dataSource.snapshot()
guard destinationIndexPath.row < snapshot.itemIdentifiers.count else { return }
let anchorItem = snapshot.itemIdentifiers[destinationIndexPath.row]
snapshot.moveItem(item, afterItem: anchorItem)
dataSource.apply(snapshot)
coordinator.drop(dropItem.dragItem, toItemAt: destinationIndexPath)
}
}
extension ComposeStatusPollTableViewCell: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(originalIndexPath.debugDescription) -> \(proposedIndexPath.debugDescription)")
guard let _ = collectionView.cellForItem(at: proposedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else {
return originalIndexPath
}
return proposedIndexPath
}
}

View File

@ -6,132 +6,132 @@
//
import UIKit
import MastodonUI
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
extension AttachmentContainerView {
final class EmptyStateView: UIView {
static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate)
static let videoSplashImage: UIImage = {
let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64))
return image
}()
let imageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Label.secondary.color
imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage
return imageView
}()
let label: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .body)
label.textColor = Asset.Colors.Label.secondary.color
label.textAlignment = .center
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
label.numberOfLines = 2
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.3
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
}
//extension AttachmentContainerView {
// final class EmptyStateView: UIView {
//
// static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate)
// static let videoSplashImage: UIImage = {
// let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64))
// return image
// }()
//
// let imageView: UIImageView = {
// let imageView = UIImageView()
// imageView.tintColor = Asset.Colors.Label.secondary.color
// imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage
// return imageView
// }()
// let label: UILabel = {
// let label = UILabel()
// label.font = .preferredFont(forTextStyle: .body)
// label.textColor = Asset.Colors.Label.secondary.color
// label.textAlignment = .center
// label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
// label.numberOfLines = 2
// label.adjustsFontSizeToFitWidth = true
// label.minimumScaleFactor = 0.3
// return label
// }()
//
// override init(frame: CGRect) {
// super.init(frame: frame)
// _init()
// }
//
// required init?(coder: NSCoder) {
// super.init(coder: coder)
// _init()
// }
//
// }
//}
extension AttachmentContainerView.EmptyStateView {
private func _init() {
layer.masksToBounds = true
layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
layer.cornerCurve = .continuous
backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor
let stackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .center
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let topPaddingView = UIView()
let middlePaddingView = UIView()
let bottomPaddingView = UIView()
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(topPaddingView)
imageView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(imageView)
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh),
imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh),
])
imageView.setContentHuggingPriority(.required - 1, for: .vertical)
middlePaddingView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(middlePaddingView)
stackView.addArrangedSubview(label)
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(bottomPaddingView)
NSLayoutConstraint.activate([
topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
])
}
}
#if canImport(SwiftUI) && DEBUG
import SwiftUI
struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 375) {
let emptyStateView = AttachmentContainerView.EmptyStateView()
NSLayoutConstraint.activate([
emptyStateView.heightAnchor.constraint(equalToConstant: 205)
])
return emptyStateView
}
.previewLayout(.fixed(width: 375, height: 205))
UIViewPreview(width: 375) {
let emptyStateView = AttachmentContainerView.EmptyStateView()
NSLayoutConstraint.activate([
emptyStateView.heightAnchor.constraint(equalToConstant: 205)
])
return emptyStateView
}
.preferredColorScheme(.dark)
.previewLayout(.fixed(width: 375, height: 205))
UIViewPreview(width: 375) {
let emptyStateView = AttachmentContainerView.EmptyStateView()
emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage
emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
NSLayoutConstraint.activate([
emptyStateView.heightAnchor.constraint(equalToConstant: 205)
])
return emptyStateView
}
.previewLayout(.fixed(width: 375, height: 205))
}
}
}
#endif
//extension AttachmentContainerView.EmptyStateView {
// private func _init() {
// layer.masksToBounds = true
// layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
// layer.cornerCurve = .continuous
// backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor
//
// let stackView = UIStackView()
// stackView.axis = .vertical
// stackView.alignment = .center
// stackView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(stackView)
// NSLayoutConstraint.activate([
// stackView.topAnchor.constraint(equalTo: topAnchor),
// stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
// stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
// stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
// ])
// let topPaddingView = UIView()
// let middlePaddingView = UIView()
// let bottomPaddingView = UIView()
//
// topPaddingView.translatesAutoresizingMaskIntoConstraints = false
// stackView.addArrangedSubview(topPaddingView)
// imageView.translatesAutoresizingMaskIntoConstraints = false
// stackView.addArrangedSubview(imageView)
// NSLayoutConstraint.activate([
// imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh),
// imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh),
// ])
// imageView.setContentHuggingPriority(.required - 1, for: .vertical)
// middlePaddingView.translatesAutoresizingMaskIntoConstraints = false
// stackView.addArrangedSubview(middlePaddingView)
// stackView.addArrangedSubview(label)
// bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
// stackView.addArrangedSubview(bottomPaddingView)
// NSLayoutConstraint.activate([
// topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
// bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
// ])
// }
//}
//#if canImport(SwiftUI) && DEBUG
//import SwiftUI
//
//struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider {
//
// static var previews: some View {
// Group {
// UIViewPreview(width: 375) {
// let emptyStateView = AttachmentContainerView.EmptyStateView()
// NSLayoutConstraint.activate([
// emptyStateView.heightAnchor.constraint(equalToConstant: 205)
// ])
// return emptyStateView
// }
// .previewLayout(.fixed(width: 375, height: 205))
// UIViewPreview(width: 375) {
// let emptyStateView = AttachmentContainerView.EmptyStateView()
// NSLayoutConstraint.activate([
// emptyStateView.heightAnchor.constraint(equalToConstant: 205)
// ])
// return emptyStateView
// }
// .preferredColorScheme(.dark)
// .previewLayout(.fixed(width: 375, height: 205))
// UIViewPreview(width: 375) {
// let emptyStateView = AttachmentContainerView.EmptyStateView()
// emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage
// emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
//
// NSLayoutConstraint.activate([
// emptyStateView.heightAnchor.constraint(equalToConstant: 205)
// ])
// return emptyStateView
// }
// .previewLayout(.fixed(width: 375, height: 205))
// }
// }
//
//}
//
//#endif

View File

@ -6,160 +6,172 @@
//
import UIKit
import UITextView_Placeholder
import MastodonAsset
import MastodonLocalization
import SwiftUI
import MastodonUI
final class AttachmentContainerView: UIView {
static let containerViewCornerRadius: CGFloat = 4
var descriptionBackgroundViewFrameObservation: NSKeyValueObservation?
let activityIndicatorView: UIActivityIndicatorView = {
let activityIndicatorView = UIActivityIndicatorView(style: .large)
activityIndicatorView.color = UIColor.white.withAlphaComponent(0.8)
return activityIndicatorView
}()
let previewImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
imageView.layer.cornerCurve = .continuous
imageView.layer.masksToBounds = true
return imageView
}()
let emptyStateView = AttachmentContainerView.EmptyStateView()
let descriptionBackgroundView: UIView = {
let view = UIView()
view.layer.masksToBounds = true
view.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
view.layer.cornerCurve = .continuous
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
view.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 5, right: 8)
return view
}()
let descriptionBackgroundGradientLayer: CAGradientLayer = {
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.69).cgColor]
gradientLayer.locations = [0.0, 1.0]
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
gradientLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
return gradientLayer
}()
let descriptionTextView: UITextView = {
let textView = UITextView()
textView.showsVerticalScrollIndicator = false
textView.backgroundColor = .clear
textView.textColor = .white
textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15), maximumPointSize: 20)
textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto
textView.placeholderColor = UIColor.white.withAlphaComponent(0.6) // force white with alpha for Light/Dark mode
textView.returnKeyType = .done
return textView
}()
override init(frame: CGRect) {
super.init(frame: frame)
_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
_init()
}
}
//final class AttachmentContainerView: UIView {
//
// static let containerViewCornerRadius: CGFloat = 4
//
// var descriptionBackgroundViewFrameObservation: NSKeyValueObservation?
//
// let activityIndicatorView: UIActivityIndicatorView = {
// let activityIndicatorView = UIActivityIndicatorView(style: .large)
// activityIndicatorView.color = UIColor.white.withAlphaComponent(0.8)
// return activityIndicatorView
// }()
//
// let previewImageView: UIImageView = {
// let imageView = UIImageView()
// imageView.contentMode = .scaleAspectFill
// imageView.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
// imageView.layer.cornerCurve = .continuous
// imageView.layer.masksToBounds = true
// return imageView
// }()
//
// let emptyStateView = AttachmentContainerView.EmptyStateView()
// let descriptionBackgroundView: UIView = {
// let view = UIView()
// view.layer.masksToBounds = true
// view.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
// view.layer.cornerCurve = .continuous
// view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
// view.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 5, right: 8)
// return view
// }()
// let descriptionBackgroundGradientLayer: CAGradientLayer = {
// let gradientLayer = CAGradientLayer()
// gradientLayer.colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.69).cgColor]
// gradientLayer.locations = [0.0, 1.0]
// gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
// gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
// gradientLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
// return gradientLayer
// }()
// let descriptionTextView: UITextView = {
// let textView = UITextView()
// textView.showsVerticalScrollIndicator = false
// textView.backgroundColor = .clear
// textView.textColor = .white
// textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15), maximumPointSize: 20)
// textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto
// textView.placeholderColor = UIColor.white.withAlphaComponent(0.6) // force white with alpha for Light/Dark mode
// textView.returnKeyType = .done
// return textView
// }()
//
// private(set) lazy var contentView = AttachmentView(viewModel: viewModel)
// public var viewModel: AttachmentView.ViewModel!
//
// override init(frame: CGRect) {
// super.init(frame: frame)
// _init()
// }
//
// required init?(coder: NSCoder) {
// super.init(coder: coder)
// _init()
// }
//
//}
extension AttachmentContainerView {
private func _init() {
previewImageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(previewImageView)
NSLayoutConstraint.activate([
previewImageView.topAnchor.constraint(equalTo: topAnchor),
previewImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
previewImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
previewImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false
addSubview(descriptionBackgroundView)
NSLayoutConstraint.activate([
descriptionBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
descriptionBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
descriptionBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
descriptionBackgroundView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.3),
])
descriptionBackgroundView.layer.addSublayer(descriptionBackgroundGradientLayer)
descriptionBackgroundViewFrameObservation = descriptionBackgroundView.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in
guard let self = self else { return }
self.descriptionBackgroundGradientLayer.frame = self.descriptionBackgroundView.bounds
}
descriptionTextView.translatesAutoresizingMaskIntoConstraints = false
descriptionBackgroundView.addSubview(descriptionTextView)
NSLayoutConstraint.activate([
descriptionTextView.leadingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor),
descriptionTextView.trailingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor),
descriptionBackgroundView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: descriptionTextView.bottomAnchor),
descriptionTextView.heightAnchor.constraint(lessThanOrEqualToConstant: 36),
])
emptyStateView.translatesAutoresizingMaskIntoConstraints = false
addSubview(emptyStateView)
NSLayoutConstraint.activate([
emptyStateView.topAnchor.constraint(equalTo: topAnchor),
emptyStateView.leadingAnchor.constraint(equalTo: leadingAnchor),
emptyStateView.trailingAnchor.constraint(equalTo: trailingAnchor),
emptyStateView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
addSubview(activityIndicatorView)
NSLayoutConstraint.activate([
activityIndicatorView.centerXAnchor.constraint(equalTo: previewImageView.centerXAnchor),
activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor),
])
//extension AttachmentContainerView {
//
// private func _init() {
// let hostingViewController = UIHostingController(rootView: contentView)
// hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
// addSubview(hostingViewController.view)
// NSLayoutConstraint.activate([
// hostingViewController.view.topAnchor.constraint(equalTo: topAnchor),
// hostingViewController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
// hostingViewController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
// hostingViewController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
// ])
//
// previewImageView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(previewImageView)
// NSLayoutConstraint.activate([
// previewImageView.topAnchor.constraint(equalTo: topAnchor),
// previewImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
// previewImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
// previewImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
// ])
//
// descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(descriptionBackgroundView)
// NSLayoutConstraint.activate([
// descriptionBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
// descriptionBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
// descriptionBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
// descriptionBackgroundView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.3),
// ])
// descriptionBackgroundView.layer.addSublayer(descriptionBackgroundGradientLayer)
// descriptionBackgroundViewFrameObservation = descriptionBackgroundView.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in
// guard let self = self else { return }
// self.descriptionBackgroundGradientLayer.frame = self.descriptionBackgroundView.bounds
// }
//
// descriptionTextView.translatesAutoresizingMaskIntoConstraints = false
// descriptionBackgroundView.addSubview(descriptionTextView)
// NSLayoutConstraint.activate([
// descriptionTextView.leadingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor),
// descriptionTextView.trailingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor),
// descriptionBackgroundView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: descriptionTextView.bottomAnchor),
// descriptionTextView.heightAnchor.constraint(lessThanOrEqualToConstant: 36),
// ])
//
// emptyStateView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(emptyStateView)
// NSLayoutConstraint.activate([
// emptyStateView.topAnchor.constraint(equalTo: topAnchor),
// emptyStateView.leadingAnchor.constraint(equalTo: leadingAnchor),
// emptyStateView.trailingAnchor.constraint(equalTo: trailingAnchor),
// emptyStateView.bottomAnchor.constraint(equalTo: bottomAnchor),
// ])
//
// activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(activityIndicatorView)
// NSLayoutConstraint.activate([
// activityIndicatorView.centerXAnchor.constraint(equalTo: previewImageView.centerXAnchor),
// activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor),
// ])
//
// setupBroader()
//
// emptyStateView.isHidden = true
// activityIndicatorView.hidesWhenStopped = true
// activityIndicatorView.startAnimating()
//
// descriptionTextView.delegate = self
// }
//
//// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
// super.traitCollectionDidChange(previousTraitCollection)
//
// setupBroader()
// }
//
//}
//
//extension AttachmentContainerView {
//
// private func setupBroader() {
// emptyStateView.layer.borderWidth = 1
// emptyStateView.layer.borderColor = traitCollection.userInterfaceStyle == .dark ? ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor.cgColor : nil
// }
//
//}
setupBroader()
emptyStateView.isHidden = true
activityIndicatorView.hidesWhenStopped = true
activityIndicatorView.startAnimating()
descriptionTextView.delegate = self
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setupBroader()
}
}
extension AttachmentContainerView {
private func setupBroader() {
emptyStateView.layer.borderWidth = 1
emptyStateView.layer.borderColor = traitCollection.userInterfaceStyle == .dark ? ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor.cgColor : nil
}
}
// MARK: - UITextViewDelegate
extension AttachmentContainerView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// let keyboard dismiss when input description with "done" type return key
if textView === descriptionTextView, text == "\n" {
textView.resignFirstResponder()
return false
}
return true
}
}
//// MARK: - UITextViewDelegate
//extension AttachmentContainerView: UITextViewDelegate {
// func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// // let keyboard dismiss when input description with "done" type return key
// if textView === descriptionTextView, text == "\n" {
// textView.resignFirstResponder()
// return false
// }
//
// return true
// }
//}

View File

@ -10,6 +10,8 @@ import UIKit
import Combine
import MastodonSDK
import MastodonAsset
import MastodonCore
import MastodonUI
import MastodonLocalization
protocol ComposeToolbarViewDelegate: AnyObject {

View File

@ -8,6 +8,8 @@
import UIKit
import Combine
import MetaTextKit
import MastodonCore
import MastodonUI
final class CustomEmojiPickerInputViewModel {

View File

@ -8,6 +8,7 @@
import UIKit
import MastodonUI
import MastodonAsset
import MastodonCore
import MastodonLocalization
final class StatusContentWarningEditorView: UIView {

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import Combine
import MastodonCore
import MastodonUI
// Local Timeline
@ -115,6 +116,11 @@ extension DiscoveryCommunityViewController {
}
// MARK: - AuthContextProvider
extension DiscoveryCommunityViewController: AuthContextProvider {
var authContext: AuthContext { viewModel.authContext }
}
// MARK: - UITableViewDelegate
extension DiscoveryCommunityViewController: UITableViewDelegate, AutoGenerateTableViewDelegate {
// sourcery:inline:CommunityViewController.AutoGenerateTableViewDelegate

View File

@ -18,6 +18,7 @@ extension DiscoveryCommunityViewModel {
tableView: tableView,
context: context,
configuration: StatusSection.Configuration(
authContext: authContext,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,

View File

@ -11,15 +11,11 @@ import GameplayKit
import MastodonSDK
extension DiscoveryCommunityViewModel {
class State: GKState, NamingState {
class State: GKState {
let logger = Logger(subsystem: "DiscoveryCommunityViewModel.State", category: "StateMachine")
let id = UUID()
var name: String {
String(describing: Self.self)
}
weak var viewModel: DiscoveryCommunityViewModel?
@ -29,8 +25,10 @@ extension DiscoveryCommunityViewModel {
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
let previousState = previousState as? DiscoveryCommunityViewModel.State
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
let from = previousState.flatMap { String(describing: $0) } ?? "nil"
let to = String(describing: self)
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(from) -> \(to)")
}
@MainActor
@ -39,7 +37,7 @@ extension DiscoveryCommunityViewModel {
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)")
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(String(describing: self))")
}
}
}
@ -136,11 +134,6 @@ extension DiscoveryCommunityViewModel.State {
break
}
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let maxID = self.maxID
let isReloading = maxID == nil
@ -156,7 +149,7 @@ extension DiscoveryCommunityViewModel.State {
minID: nil,
limit: 20
),
authenticationBox: authenticationBox
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
let newMaxID = response.link?.maxID
@ -164,7 +157,7 @@ extension DiscoveryCommunityViewModel.State {
self.maxID = newMaxID
var hasNewStatusesAppend = false
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs.value
var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs
for status in response.value {
guard !statusIDs.contains(status.id) else { continue }
statusIDs.append(status.id)
@ -177,7 +170,7 @@ extension DiscoveryCommunityViewModel.State {
} else {
await enter(state: NoMore.self)
}
viewModel.statusFetchedResultsController.statusIDs.value = statusIDs
viewModel.statusFetchedResultsController.statusIDs = statusIDs
viewModel.didLoadLatest.send()
} catch {

View File

@ -12,6 +12,7 @@ import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonCore
final class DiscoveryCommunityViewModel {
@ -21,6 +22,7 @@ final class DiscoveryCommunityViewModel {
// input
let context: AppContext
let authContext: AuthContext
let viewDidAppeared = PassthroughSubject<Void, Never>()
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
@ -42,20 +44,15 @@ final class DiscoveryCommunityViewModel {
let didLoadLatest = PassthroughSubject<Void, Never>()
init(context: AppContext) {
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
domain: authContext.mastodonAuthenticationBox.domain,
additionalTweetPredicate: nil
)
// end init
context.authenticationService.activeMastodonAuthentication
.map { $0?.domain }
.assign(to: \.value, on: statusFetchedResultsController.domain)
.store(in: &disposeBag)
}
deinit {

View File

@ -11,6 +11,7 @@ import Combine
import Tabman
import Pageboy
import MastodonAsset
import MastodonCore
import MastodonUI
public class DiscoveryViewController: TabmanViewController, NeedsDependency {
@ -24,11 +25,8 @@ public class DiscoveryViewController: TabmanViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
private(set) lazy var viewModel = DiscoveryViewModel(
context: context,
coordinator: coordinator
)
var viewModel: DiscoveryViewModel!
private(set) lazy var buttonBar: TMBar.ButtonBar = {
let buttonBar = TMBar.ButtonBar()

View File

@ -9,6 +9,7 @@ import UIKit
import Combine
import Tabman
import Pageboy
import MastodonCore
import MastodonLocalization
final class DiscoveryViewModel {
@ -17,6 +18,7 @@ final class DiscoveryViewModel {
// input
let context: AppContext
let authContext: AuthContext
let discoveryPostsViewController: DiscoveryPostsViewController
let discoveryHashtagsViewController: DiscoveryHashtagsViewController
let discoveryNewsViewController: DiscoveryNewsViewController
@ -25,41 +27,43 @@ final class DiscoveryViewModel {
@Published var viewControllers: [ScrollViewContainer & PageViewController]
init(context: AppContext, coordinator: SceneCoordinator) {
init(context: AppContext, coordinator: SceneCoordinator, authContext: AuthContext) {
self.context = context
self.authContext = authContext
func setupDependency(_ needsDependency: NeedsDependency) {
needsDependency.context = context
needsDependency.coordinator = coordinator
}
self.context = context
discoveryPostsViewController = {
let viewController = DiscoveryPostsViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryPostsViewModel(context: context)
viewController.viewModel = DiscoveryPostsViewModel(context: context, authContext: authContext)
return viewController
}()
discoveryHashtagsViewController = {
let viewController = DiscoveryHashtagsViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryHashtagsViewModel(context: context)
viewController.viewModel = DiscoveryHashtagsViewModel(context: context, authContext: authContext)
return viewController
}()
discoveryNewsViewController = {
let viewController = DiscoveryNewsViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryNewsViewModel(context: context)
viewController.viewModel = DiscoveryNewsViewModel(context: context, authContext: authContext)
return viewController
}()
discoveryCommunityViewController = {
let viewController = DiscoveryCommunityViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryCommunityViewModel(context: context)
viewController.viewModel = DiscoveryCommunityViewModel(context: context, authContext: authContext)
return viewController
}()
discoveryForYouViewController = {
let viewController = DiscoveryForYouViewController()
setupDependency(viewController)
viewController.viewModel = DiscoveryForYouViewModel(context: context)
viewController.viewModel = DiscoveryForYouViewModel(context: context, authContext: authContext)
return viewController
}()
self.viewControllers = [

View File

@ -9,6 +9,7 @@ import os.log
import UIKit
import Combine
import MastodonUI
import MastodonCore
final class DiscoveryForYouViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
@ -100,6 +101,11 @@ extension DiscoveryForYouViewController {
}
// MARK: - AuthContextProvider
extension DiscoveryForYouViewController: AuthContextProvider {
var authContext: AuthContext { viewModel.authContext }
}
// MARK: - UITableViewDelegate
extension DiscoveryForYouViewController: UITableViewDelegate {
@ -109,9 +115,10 @@ extension DiscoveryForYouViewController: UITableViewDelegate {
guard let user = record.object(in: context.managedObjectContext) else { return }
let profileViewModel = CachedProfileViewModel(
context: context,
authContext: viewModel.authContext,
mastodonUser: user
)
coordinator.present(
_ = coordinator.present(
scene: .profile(viewModel: profileViewModel),
from: self,
transition: .show
@ -127,15 +134,13 @@ extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate {
profileCardView: ProfileCardView,
relationshipButtonDidPressed button: ProfileRelationshipActionButton
) {
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
guard case let .user(record) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
Task {
try await DataSourceFacade.responseToUserFollowAction(
dependency: self,
user: record,
authenticationBox: authenticationBox
user: record
)
} // end Task
}
@ -156,9 +161,9 @@ extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate {
return
}
let familiarFollowersViewModel = FamiliarFollowersViewModel(context: context)
let familiarFollowersViewModel = FamiliarFollowersViewModel(context: context, authContext: authContext)
familiarFollowersViewModel.familiarFollowers = familiarFollowers
coordinator.present(
_ = coordinator.present(
scene: .familiarFollowers(viewModel: familiarFollowersViewModel),
from: self,
transition: .show

View File

@ -19,6 +19,7 @@ extension DiscoveryForYouViewModel {
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration(
authContext: authContext,
profileCardTableViewCellDelegate: profileCardTableViewCellDelegate,
familiarFollowers: $familiarFollowers
)

View File

@ -12,6 +12,7 @@ import GameplayKit
import CoreData
import CoreDataStack
import MastodonSDK
import MastodonCore
final class DiscoveryForYouViewModel {
@ -19,6 +20,7 @@ final class DiscoveryForYouViewModel {
// input
let context: AppContext
let authContext: AuthContext
let userFetchedResultsController: UserFetchedResultsController
@MainActor
@ -29,19 +31,15 @@ final class DiscoveryForYouViewModel {
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
let didLoadLatest = PassthroughSubject<Void, Never>()
init(context: AppContext) {
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.userFetchedResultsController = UserFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: nil,
domain: authContext.mastodonAuthenticationBox.domain,
additionalPredicate: nil
)
// end init
context.authenticationService.activeMastodonAuthenticationBox
.map { $0?.domain }
.assign(to: \.domain, on: userFetchedResultsController)
.store(in: &disposeBag)
}
deinit {
@ -58,16 +56,12 @@ extension DiscoveryForYouViewModel {
isFetching = true
defer { isFetching = false }
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
throw APIService.APIError.implicit(.badRequest)
}
do {
let userIDs = try await fetchSuggestionAccounts()
let _familiarFollowersResponse = try? await context.apiService.familiarFollowers(
query: .init(ids: userIDs),
authenticationBox: authenticationBox
authenticationBox: authContext.mastodonAuthenticationBox
)
familiarFollowers = _familiarFollowersResponse?.value ?? []
userFetchedResultsController.userIDs = userIDs
@ -77,14 +71,10 @@ extension DiscoveryForYouViewModel {
}
private func fetchSuggestionAccounts() async throws -> [Mastodon.Entity.Account.ID] {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else {
throw APIService.APIError.implicit(.badRequest)
}
do {
let response = try await context.apiService.suggestionAccountV2(
query: nil,
authenticationBox: authenticationBox
authenticationBox: authContext.mastodonAuthenticationBox
)
let userIDs = response.value.map { $0.account.id }
return userIDs
@ -92,7 +82,7 @@ extension DiscoveryForYouViewModel {
// fallback V1
let response = try await context.apiService.suggestionAccount(
query: nil,
authenticationBox: authenticationBox
authenticationBox: authContext.mastodonAuthenticationBox
)
let userIDs = response.value.map { $0.id }
return userIDs

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import Combine
import MastodonCore
import MastodonUI
final class DiscoveryHashtagsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
@ -106,7 +107,7 @@ extension DiscoveryHashtagsViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(indexPath)")
guard case let .hashtag(tag) = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return }
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: tag.name)
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, authContext: viewModel.authContext, hashtag: tag.name)
coordinator.present(
scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel),
from: self,
@ -216,7 +217,7 @@ extension DiscoveryHashtagsViewController: TableViewControllerNavigateable {
guard let item = diffableDataSource.itemIdentifier(for: indexPathForSelectedRow) else { return }
guard case let .hashtag(tag) = item else { return }
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: tag.name)
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, authContext: viewModel.authContext, hashtag: tag.name)
coordinator.present(
scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel),
from: self,

View File

@ -15,7 +15,7 @@ extension DiscoveryHashtagsViewModel {
diffableDataSource = DiscoverySection.diffableDataSource(
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration()
configuration: DiscoverySection.Configuration(authContext: authContext)
)
var snapshot = NSDiffableDataSourceSnapshot<DiscoverySection, DiscoveryItem>()

View File

@ -11,6 +11,7 @@ import Combine
import GameplayKit
import CoreData
import CoreDataStack
import MastodonCore
import MastodonSDK
final class DiscoveryHashtagsViewModel {
@ -21,41 +22,37 @@ final class DiscoveryHashtagsViewModel {
// input
let context: AppContext
let authContext: AuthContext
let viewDidAppeared = PassthroughSubject<Void, Never>()
// output
var diffableDataSource: UITableViewDiffableDataSource<DiscoverySection, DiscoveryItem>?
@Published var hashtags: [Mastodon.Entity.Tag] = []
init(context: AppContext) {
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
// end init
Publishers.CombineLatest(
context.authenticationService.activeMastodonAuthenticationBox,
viewDidAppeared
)
.compactMap { authenticationBox, _ -> MastodonAuthenticationBox? in
return authenticationBox
}
.throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
.asyncMap { authenticationBox in
try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
}
.retry(3)
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
self.hashtags = response.value.filter { !$0.name.isEmpty }
case .failure:
break
viewDidAppeared
.throttle(for: 3, scheduler: DispatchQueue.main, latest: true)
.asyncMap { authenticationBox in
try await context.apiService.trendHashtags(domain: authContext.mastodonAuthenticationBox.domain, query: nil)
}
}
.store(in: &disposeBag)
.retry(3)
.map { response in Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
.catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { throw error }) }
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
self.hashtags = response.value.filter { !$0.name.isEmpty }
case .failure:
break
}
}
.store(in: &disposeBag)
}
deinit {
@ -68,8 +65,7 @@ extension DiscoveryHashtagsViewModel {
@MainActor
func fetch() async throws {
guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return }
let response = try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil)
let response = try await context.apiService.trendHashtags(domain: authContext.mastodonAuthenticationBox.domain, query: nil)
hashtags = response.value.filter { !$0.name.isEmpty }
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch tags: \(response.value.count)")
}

View File

@ -8,6 +8,7 @@
import os.log
import UIKit
import Combine
import MastodonCore
import MastodonUI
final class DiscoveryNewsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {

View File

@ -16,7 +16,7 @@ extension DiscoveryNewsViewModel {
diffableDataSource = DiscoverySection.diffableDataSource(
tableView: tableView,
context: context,
configuration: DiscoverySection.Configuration()
configuration: DiscoverySection.Configuration(authContext: authContext)
)
stateMachine.enter(State.Reloading.self)

View File

@ -11,16 +11,12 @@ import GameplayKit
import MastodonSDK
extension DiscoveryNewsViewModel {
class State: GKState, NamingState {
class State: GKState {
let logger = Logger(subsystem: "DiscoveryNewsViewModel.State", category: "StateMachine")
let id = UUID()
var name: String {
String(describing: Self.self)
}
weak var viewModel: DiscoveryNewsViewModel?
init(viewModel: DiscoveryNewsViewModel) {
@ -29,8 +25,10 @@ extension DiscoveryNewsViewModel {
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
let previousState = previousState as? DiscoveryNewsViewModel.State
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "<nil>")")
let from = previousState.flatMap { String(describing: $0) } ?? "nil"
let to = String(describing: self)
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(from) -> \(to)")
}
@MainActor
@ -39,7 +37,7 @@ extension DiscoveryNewsViewModel {
}
deinit {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)")
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(String(describing: self))")
}
}
}
@ -136,11 +134,6 @@ extension DiscoveryNewsViewModel.State {
default:
break
}
guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else {
stateMachine.enter(Fail.self)
return
}
let offset = self.offset
let isReloading = offset == nil
@ -148,7 +141,7 @@ extension DiscoveryNewsViewModel.State {
Task {
do {
let response = try await viewModel.context.apiService.trendLinks(
domain: authenticationBox.domain,
domain: viewModel.authContext.mastodonAuthenticationBox.domain,
query: Mastodon.API.Trends.StatusQuery(
offset: offset,
limit: nil

Some files were not shown because too many files have changed in this diff Show More