forked from zelo72/mastodon-ios
Merge branch 'release/0.4.0'
This commit is contained in:
commit
f3a534f0f1
|
@ -0,0 +1,13 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
# build with SwiftPM:
|
||||
# https://developer.apple.com/documentation/swift_packages/building_swift_packages_or_apps_that_use_them_in_continuous_integration_workflows
|
||||
|
||||
xcodebuild -workspace Mastodon.xcworkspace \
|
||||
-scheme Mastodon \
|
||||
-disableAutomaticPackageResolution \
|
||||
-destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \
|
||||
clean \
|
||||
build | xcpretty
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
sudo gem install cocoapods-keys
|
||||
|
||||
# stub keys. DO NOT use in production
|
||||
pod keys set notification_endpoint "<endpoint>"
|
||||
pod keys set notification_endpoint_debug "<endpoint>"
|
||||
|
||||
pod install
|
|
@ -0,0 +1,27 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- feature/*
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# macOS environments: https://github.com/actions/virtual-environments/tree/main/images/macos
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: CI build
|
||||
runs-on: macos-10.15
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: force Xcode 12.2
|
||||
run: sudo xcode-select -switch /Applications/Xcode_12.2.app
|
||||
- name: setup
|
||||
run: exec ./.github/scripts/setup.sh
|
||||
- name: build
|
||||
run: exec ./.github/scripts/build.sh
|
|
@ -120,4 +120,6 @@ xcuserdata
|
|||
# End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods
|
||||
|
||||
Localization/StringsConvertor/input
|
||||
Localization/StringsConvertor/output
|
||||
Localization/StringsConvertor/output
|
||||
.DS_Store
|
||||
/Mastodon.xcworkspace/xcshareddata/swiftpm
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// AppName.swift
|
||||
// AppShared
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AppName {
|
||||
public static let groupID = "group.org.joinmastodon.mastodon-temp"
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
//
|
||||
// AppSecret.swift
|
||||
// AppShared
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-27.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import KeychainAccess
|
||||
import Keys
|
||||
|
||||
public final class AppSecret {
|
||||
|
||||
public static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID)
|
||||
|
||||
static let notificationPrivateKeyName = "notification-private-key-base64"
|
||||
static let notificationAuthName = "notification-auth-base64"
|
||||
|
||||
public let notificationEndpoint: String
|
||||
|
||||
public var notificationPrivateKey: P256.KeyAgreement.PrivateKey {
|
||||
AppSecret.createOrFetchNotificationPrivateKey()
|
||||
}
|
||||
public var notificationPublicKey: P256.KeyAgreement.PublicKey {
|
||||
notificationPrivateKey.publicKey
|
||||
}
|
||||
public var notificationAuth: Data {
|
||||
AppSecret.createOrFetchNotificationAuth()
|
||||
}
|
||||
|
||||
public static let `default`: AppSecret = {
|
||||
return AppSecret()
|
||||
}()
|
||||
|
||||
init() {
|
||||
let keys = MastodonKeys()
|
||||
|
||||
#if DEBUG
|
||||
self.notificationEndpoint = keys.notification_endpoint_debug
|
||||
#else
|
||||
self.notificationEndpoint = keys.notification_endpoint
|
||||
#endif
|
||||
}
|
||||
|
||||
public func register() {
|
||||
_ = AppSecret.createOrFetchNotificationPrivateKey()
|
||||
_ = AppSecret.createOrFetchNotificationAuth()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AppSecret {
|
||||
|
||||
private static func createOrFetchNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey {
|
||||
if let encoded = AppSecret.keychain[AppSecret.notificationPrivateKeyName],
|
||||
let data = Data(base64Encoded: encoded) {
|
||||
do {
|
||||
let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: data)
|
||||
return privateKey
|
||||
} catch {
|
||||
assertionFailure()
|
||||
return AppSecret.resetNotificationPrivateKey()
|
||||
}
|
||||
} else {
|
||||
return AppSecret.resetNotificationPrivateKey()
|
||||
}
|
||||
}
|
||||
|
||||
private static func resetNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey {
|
||||
let privateKey = P256.KeyAgreement.PrivateKey()
|
||||
keychain[AppSecret.notificationPrivateKeyName] = privateKey.rawRepresentation.base64EncodedString()
|
||||
return privateKey
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AppSecret {
|
||||
|
||||
private static func createOrFetchNotificationAuth() -> Data {
|
||||
if let encoded = keychain[AppSecret.notificationAuthName],
|
||||
let data = Data(base64Encoded: encoded) {
|
||||
return data
|
||||
} else {
|
||||
return AppSecret.resetNotificationAuth()
|
||||
}
|
||||
}
|
||||
|
||||
private static func resetNotificationAuth() -> Data {
|
||||
let auth = AppSecret.createRandomAuthBytes()
|
||||
keychain[AppSecret.notificationAuthName] = auth.base64EncodedString()
|
||||
return auth
|
||||
}
|
||||
|
||||
private static func createRandomAuthBytes() -> Data {
|
||||
let byteCount = 16
|
||||
var bytes = Data(count: byteCount)
|
||||
_ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) }
|
||||
return bytes
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// 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>
|
||||
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?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.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// UserDefaults.swift
|
||||
// AppShared
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-27.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UserDefaults {
|
||||
public static let shared = UserDefaults(suiteName: AppName.groupID)!
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20E232" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="vapidKey" optional="YES" attributeType="String"/>
|
||||
<attribute name="website" optional="YES" attributeType="String"/>
|
||||
<relationship name="toots" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="application" inverseEntity="Toot"/>
|
||||
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="application" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<entity name="Attachment" representedClassName=".Attachment" syncable="YES">
|
||||
<attribute name="blurhash" optional="YES" attributeType="String"/>
|
||||
|
@ -16,13 +16,26 @@
|
|||
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="metaData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="previewRemoteURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="previewURL" attributeType="String"/>
|
||||
<attribute name="previewURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="remoteURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="textURL" optional="YES" attributeType="String"/>
|
||||
<attribute name="typeRaw" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" optional="YES" attributeType="String"/>
|
||||
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="mediaAttachments" inverseEntity="Toot"/>
|
||||
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="mediaAttachments" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<entity name="DomainBlock" representedClassName=".DomainBlock" syncable="YES">
|
||||
<attribute name="blockedDomain" attributeType="String"/>
|
||||
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="userID"/>
|
||||
<constraint value="domain"/>
|
||||
<constraint value="blockedDomain"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Emoji" representedClassName=".Emoji" syncable="YES">
|
||||
<attribute name="category" optional="YES" attributeType="String"/>
|
||||
|
@ -32,14 +45,13 @@
|
|||
<attribute name="staticURL" attributeType="String"/>
|
||||
<attribute name="url" attributeType="String"/>
|
||||
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="emojis" inverseEntity="Toot"/>
|
||||
</entity>
|
||||
<entity name="History" representedClassName=".History" syncable="YES">
|
||||
<attribute name="accounts" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="accounts" optional="YES" attributeType="String"/>
|
||||
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
|
||||
<attribute name="day" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="uses" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="uses" optional="YES" attributeType="String"/>
|
||||
<relationship name="tag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag" inverseName="histories" inverseEntity="Tag"/>
|
||||
</entity>
|
||||
<entity name="HomeTimelineIndex" representedClassName=".HomeTimelineIndex" syncable="YES">
|
||||
|
@ -49,7 +61,7 @@
|
|||
<attribute name="hasMore" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="homeTimelineIndexes" inverseEntity="Toot"/>
|
||||
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="homeTimelineIndexes" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<entity name="MastodonAuthentication" representedClassName=".MastodonAuthentication" syncable="YES">
|
||||
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
@ -65,47 +77,127 @@
|
|||
<attribute name="username" attributeType="String"/>
|
||||
<relationship name="user" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mastodonAuthentication" inverseEntity="MastodonUser"/>
|
||||
</entity>
|
||||
<entity name="MastodonNotification" representedClassName=".MastodonNotification" syncable="YES">
|
||||
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="typeRaw" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
|
||||
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="inNotifications" inverseEntity="Status"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="MastodonUser" representedClassName=".MastodonUser" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="avatar" attributeType="String"/>
|
||||
<attribute name="avatarStatic" optional="YES" attributeType="String"/>
|
||||
<attribute name="bot" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="displayName" attributeType="String"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="emojisData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="followersCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="header" attributeType="String"/>
|
||||
<attribute name="headerStatic" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
<attribute name="locked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="note" optional="YES" attributeType="String"/>
|
||||
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="suspended" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="bookmarkedBy" inverseEntity="Toot"/>
|
||||
<relationship name="favourite" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="favouritedBy" inverseEntity="Toot"/>
|
||||
<relationship name="blocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blockingBy" inverseEntity="MastodonUser"/>
|
||||
<relationship name="blockingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="blocking" inverseEntity="MastodonUser"/>
|
||||
<relationship name="bookmarked" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="bookmarkedBy" inverseEntity="Status"/>
|
||||
<relationship name="domainBlocking" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlockingBy" inverseEntity="MastodonUser"/>
|
||||
<relationship name="domainBlockingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="domainBlocking" inverseEntity="MastodonUser"/>
|
||||
<relationship name="endorsed" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsedBy" inverseEntity="MastodonUser"/>
|
||||
<relationship name="endorsedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="endorsed" inverseEntity="MastodonUser"/>
|
||||
<relationship name="favourite" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="favouritedBy" inverseEntity="Status"/>
|
||||
<relationship name="following" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followingBy" inverseEntity="MastodonUser"/>
|
||||
<relationship name="followingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="following" inverseEntity="MastodonUser"/>
|
||||
<relationship name="followRequested" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequestedBy" inverseEntity="MastodonUser"/>
|
||||
<relationship name="followRequestedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="followRequested" inverseEntity="MastodonUser"/>
|
||||
<relationship name="mastodonAuthentication" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonAuthentication" inverseName="user" inverseEntity="MastodonAuthentication"/>
|
||||
<relationship name="muted" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="mutedBy" inverseEntity="Toot"/>
|
||||
<relationship name="pinnedToot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="pinnedBy" inverseEntity="Toot"/>
|
||||
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="rebloggedBy" inverseEntity="Toot"/>
|
||||
<relationship name="toots" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="author" inverseEntity="Toot"/>
|
||||
<relationship name="muted" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="mutedBy" inverseEntity="Status"/>
|
||||
<relationship name="muting" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="mutingBy" inverseEntity="MastodonUser"/>
|
||||
<relationship name="mutingBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muting" inverseEntity="MastodonUser"/>
|
||||
<relationship name="pinnedStatus" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="pinnedBy" inverseEntity="Status"/>
|
||||
<relationship name="privateNotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="to" inverseEntity="PrivateNote"/>
|
||||
<relationship name="privateNotesTo" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PrivateNote" inverseName="from" inverseEntity="PrivateNote"/>
|
||||
<relationship name="reblogged" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="rebloggedBy" inverseEntity="Status"/>
|
||||
<relationship name="statuses" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="author" inverseEntity="Status"/>
|
||||
<relationship name="votePollOptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollOption" inverseName="votedBy" inverseEntity="PollOption"/>
|
||||
<relationship name="votePolls" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Poll" inverseName="votedBy" inverseEntity="Poll"/>
|
||||
</entity>
|
||||
<entity name="Mention" representedClassName=".Mention" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="url" attributeType="String"/>
|
||||
<attribute name="username" attributeType="String"/>
|
||||
<relationship name="toot" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="mentions" inverseEntity="Toot"/>
|
||||
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="mentions" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<entity name="Tag" representedClassName=".Tag" syncable="YES">
|
||||
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
|
||||
<entity name="Poll" representedClassName=".Poll" syncable="YES">
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="expired" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="votersCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="votesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="options" toMany="YES" deletionRule="Cascade" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
|
||||
<relationship name="status" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="poll" inverseEntity="Status"/>
|
||||
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePolls" inverseEntity="MastodonUser"/>
|
||||
</entity>
|
||||
<entity name="PollOption" representedClassName=".PollOption" syncable="YES">
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="index" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="votesCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
|
||||
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
|
||||
</entity>
|
||||
<entity name="PrivateNote" representedClassName=".PrivateNote" syncable="YES">
|
||||
<attribute name="note" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
|
||||
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
|
||||
</entity>
|
||||
<entity name="SearchHistory" representedClassName=".SearchHistory" syncable="YES">
|
||||
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="url" attributeType="String"/>
|
||||
<relationship name="histories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="History" inverseName="tag" inverseEntity="History"/>
|
||||
<relationship name="toot" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="tags" inverseEntity="Toot"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
|
||||
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
|
||||
</entity>
|
||||
<entity name="Toot" representedClassName=".Toot" syncable="YES">
|
||||
<entity name="Setting" representedClassName=".Setting" syncable="YES">
|
||||
<attribute name="appearanceRaw" attributeType="String"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="userID" attributeType="String"/>
|
||||
<relationship name="subscriptions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
|
||||
</entity>
|
||||
<entity name="Status" representedClassName=".Status" syncable="YES">
|
||||
<attribute name="content" attributeType="String"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="domain" attributeType="String"/>
|
||||
<attribute name="emojisData" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="favouritesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="identifier" attributeType="String"/>
|
||||
|
@ -114,6 +206,7 @@
|
|||
<attribute name="language" optional="YES" attributeType="String"/>
|
||||
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="repliesCount" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
|
||||
<attribute name="revealedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="spoilerText" optional="YES" attributeType="String"/>
|
||||
<attribute name="text" optional="YES" attributeType="String"/>
|
||||
|
@ -121,31 +214,75 @@
|
|||
<attribute name="uri" attributeType="String"/>
|
||||
<attribute name="url" attributeType="String"/>
|
||||
<attribute name="visibility" optional="YES" attributeType="String"/>
|
||||
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="toots" inverseEntity="Application"/>
|
||||
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="toots" inverseEntity="MastodonUser"/>
|
||||
<relationship name="application" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Application" inverseName="status" inverseEntity="Application"/>
|
||||
<relationship name="author" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="statuses" inverseEntity="MastodonUser"/>
|
||||
<relationship name="bookmarkedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="bookmarked" inverseEntity="MastodonUser"/>
|
||||
<relationship name="emojis" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Emoji" inverseName="toot" inverseEntity="Emoji"/>
|
||||
<relationship name="favouritedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="favourite" inverseEntity="MastodonUser"/>
|
||||
<relationship name="homeTimelineIndexes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="HomeTimelineIndex" inverseName="toot" inverseEntity="HomeTimelineIndex"/>
|
||||
<relationship name="mediaAttachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="toot" inverseEntity="Attachment"/>
|
||||
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Mention" inverseName="toot" inverseEntity="Mention"/>
|
||||
<relationship name="homeTimelineIndexes" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="HomeTimelineIndex" inverseName="status" inverseEntity="HomeTimelineIndex"/>
|
||||
<relationship name="inNotifications" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="MastodonNotification" inverseName="status" inverseEntity="MastodonNotification"/>
|
||||
<relationship name="mediaAttachments" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Attachment" inverseName="status" inverseEntity="Attachment"/>
|
||||
<relationship name="mentions" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Mention" inverseName="status" inverseEntity="Mention"/>
|
||||
<relationship name="mutedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="muted" inverseEntity="MastodonUser"/>
|
||||
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedToot" inverseEntity="MastodonUser"/>
|
||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblogFrom" inverseEntity="Toot"/>
|
||||
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Toot" inverseName="reblog" inverseEntity="Toot"/>
|
||||
<relationship name="pinnedBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="pinnedStatus" inverseEntity="MastodonUser"/>
|
||||
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="status" inverseEntity="Poll"/>
|
||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogFrom" inverseEntity="Status"/>
|
||||
<relationship name="reblogFrom" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
|
||||
<relationship name="rebloggedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="reblogged" inverseEntity="MastodonUser"/>
|
||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="toot" inverseEntity="Tag"/>
|
||||
<relationship name="replyFrom" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="replyTo" inverseEntity="Status"/>
|
||||
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
|
||||
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
|
||||
</entity>
|
||||
<entity name="Subscription" representedClassName=".Subscription" syncable="YES">
|
||||
<attribute name="activedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="policyRaw" attributeType="String"/>
|
||||
<attribute name="serverKey" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="userToken" optional="YES" attributeType="String"/>
|
||||
<relationship name="alert" maxCount="1" deletionRule="Cascade" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
|
||||
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscriptions" inverseEntity="Setting"/>
|
||||
</entity>
|
||||
<entity name="SubscriptionAlerts" representedClassName=".SubscriptionAlerts" syncable="YES">
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="favouriteRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="followRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="followRequestRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="mentionRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="pollRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="reblogRaw" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="subscription" maxCount="1" deletionRule="Nullify" destinationEntity="Subscription" inverseName="alert" inverseEntity="Subscription"/>
|
||||
</entity>
|
||||
<entity name="Tag" representedClassName=".Tag" syncable="YES">
|
||||
<attribute name="createAt" attributeType="Date" defaultDateTimeInterval="631123200" usesScalarValueType="NO"/>
|
||||
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="name" attributeType="String"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="url" attributeType="String"/>
|
||||
<relationship name="histories" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="History" inverseName="tag" inverseEntity="History"/>
|
||||
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="tags" inverseEntity="Status"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Application" positionX="160" positionY="192" width="128" height="104"/>
|
||||
<element name="Emoji" positionX="45" positionY="135" width="128" height="149"/>
|
||||
<element name="History" positionX="27" positionY="126" width="128" height="119"/>
|
||||
<element name="Application" positionX="0" positionY="0" width="128" height="104"/>
|
||||
<element name="Attachment" positionX="0" positionY="0" width="128" height="254"/>
|
||||
<element name="DomainBlock" positionX="45" positionY="162" width="128" height="89"/>
|
||||
<element name="Emoji" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="History" positionX="0" positionY="0" width="128" height="119"/>
|
||||
<element name="HomeTimelineIndex" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="MastodonAuthentication" positionX="18" positionY="162" width="128" height="209"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="284"/>
|
||||
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
|
||||
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
||||
<element name="Toot" positionX="0" positionY="0" width="128" height="524"/>
|
||||
<element name="Attachment" positionX="72" positionY="162" width="128" height="14"/>
|
||||
<element name="MastodonAuthentication" positionX="0" positionY="0" width="128" height="209"/>
|
||||
<element name="MastodonNotification" positionX="9" positionY="162" width="128" height="164"/>
|
||||
<element name="MastodonUser" positionX="0" positionY="0" width="128" height="689"/>
|
||||
<element name="Mention" positionX="0" positionY="0" width="128" height="149"/>
|
||||
<element name="Poll" positionX="0" positionY="0" width="128" height="194"/>
|
||||
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
|
||||
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
|
||||
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
|
||||
<element name="Setting" positionX="72" positionY="162" width="128" height="119"/>
|
||||
<element name="Status" positionX="0" positionY="0" width="128" height="599"/>
|
||||
<element name="Subscription" positionX="81" positionY="171" width="128" height="179"/>
|
||||
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="14"/>
|
||||
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -8,6 +8,7 @@
|
|||
import os
|
||||
import Foundation
|
||||
import CoreData
|
||||
import AppShared
|
||||
|
||||
public final class CoreDataStack {
|
||||
|
||||
|
@ -18,7 +19,7 @@ public final class CoreDataStack {
|
|||
}
|
||||
|
||||
public convenience init(databaseName: String = "shared") {
|
||||
let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName)
|
||||
let storeURL = URL.storeURL(for: AppName.groupID, databaseName: databaseName)
|
||||
let storeDescription = NSPersistentStoreDescription(url: storeURL)
|
||||
self.init(persistentStoreDescriptions: [storeDescription])
|
||||
}
|
||||
|
@ -38,7 +39,7 @@ public final class CoreDataStack {
|
|||
}()
|
||||
|
||||
static func persistentContainer() -> NSPersistentContainer {
|
||||
let bundles = [Bundle(for: Toot.self)]
|
||||
let bundles = [Bundle(for: Status.self)]
|
||||
guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else {
|
||||
fatalError("cannot locate bundles")
|
||||
}
|
||||
|
|
|
@ -17,14 +17,14 @@ public final class Application: NSManagedObject {
|
|||
@NSManaged public private(set) var website: String?
|
||||
@NSManaged public private(set) var vapidKey: String?
|
||||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var toots: Set<Toot>
|
||||
// one-to-one relationship
|
||||
@NSManaged public private(set) var status: Status
|
||||
}
|
||||
|
||||
public extension Application {
|
||||
override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
identifier = UUID()
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(Application.identifier))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
|
@ -15,7 +15,7 @@ public final class Attachment: NSManagedObject {
|
|||
@NSManaged public private(set) var domain: String
|
||||
@NSManaged public private(set) var typeRaw: String
|
||||
@NSManaged public private(set) var url: String
|
||||
@NSManaged public private(set) var previewURL: String
|
||||
@NSManaged public private(set) var previewURL: String?
|
||||
|
||||
@NSManaged public private(set) var remoteURL: String?
|
||||
@NSManaged public private(set) var metaData: Data?
|
||||
|
@ -28,7 +28,7 @@ public final class Attachment: NSManagedObject {
|
|||
@NSManaged public private(set) var index: NSNumber
|
||||
|
||||
// many-to-one relastionship
|
||||
@NSManaged public private(set) var toot: Toot?
|
||||
@NSManaged public private(set) var status: Status?
|
||||
|
||||
}
|
||||
|
||||
|
@ -36,7 +36,7 @@ public extension Attachment {
|
|||
|
||||
override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
createdAt = Date()
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(Attachment.createdAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
@ -80,7 +80,7 @@ public extension Attachment {
|
|||
public let typeRaw: String
|
||||
public let url: String
|
||||
|
||||
public let previewURL: String
|
||||
public let previewURL: String?
|
||||
public let remoteURL: String?
|
||||
public let metaData: Data?
|
||||
public let textURL: String?
|
||||
|
@ -95,7 +95,7 @@ public extension Attachment {
|
|||
id: Attachment.ID,
|
||||
typeRaw: String,
|
||||
url: String,
|
||||
previewURL: String,
|
||||
previewURL: String?,
|
||||
remoteURL: String?,
|
||||
metaData: Data?,
|
||||
textURL: String?,
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
//
|
||||
// DomainBlock.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/29.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public final class DomainBlock: NSManagedObject {
|
||||
@NSManaged public private(set) var blockedDomain: String
|
||||
@NSManaged public private(set) var createAt: Date
|
||||
|
||||
@NSManaged public private(set) var domain: String
|
||||
@NSManaged public private(set) var userID: String
|
||||
|
||||
override public func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(DomainBlock.createAt))
|
||||
}
|
||||
}
|
||||
|
||||
extension DomainBlock {
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
blockedDomain: String,
|
||||
domain: String,
|
||||
userID: String
|
||||
) -> DomainBlock {
|
||||
let domainBlock: DomainBlock = context.insertObject()
|
||||
domainBlock.domain = domain
|
||||
domainBlock.blockedDomain = blockedDomain
|
||||
domainBlock.userID = userID
|
||||
return domainBlock
|
||||
}
|
||||
}
|
||||
|
||||
extension DomainBlock: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
[NSSortDescriptor(keyPath: \DomainBlock.createAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
||||
extension DomainBlock {
|
||||
static func predicate(domain: String) -> NSPredicate {
|
||||
NSPredicate(format: "%K == %@", #keyPath(DomainBlock.domain), domain)
|
||||
}
|
||||
|
||||
static func predicate(userID: String) -> NSPredicate {
|
||||
NSPredicate(format: "%K == %@", #keyPath(DomainBlock.userID), userID)
|
||||
}
|
||||
|
||||
static func predicate(blockedDomain: String) -> NSPredicate {
|
||||
NSPredicate(format: "%K == %@", #keyPath(DomainBlock.blockedDomain), blockedDomain)
|
||||
}
|
||||
|
||||
public static func predicate(domain: String, userID: String) -> NSPredicate {
|
||||
NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
DomainBlock.predicate(domain: domain),
|
||||
DomainBlock.predicate(userID: userID)
|
||||
])
|
||||
}
|
||||
|
||||
public static func predicate(domain: String, userID: String, blockedDomain: String) -> NSPredicate {
|
||||
NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
DomainBlock.predicate(domain: domain),
|
||||
DomainBlock.predicate(userID: userID),
|
||||
DomainBlock.predicate(blockedDomain: blockedDomain)
|
||||
])
|
||||
}
|
||||
}
|
|
@ -20,13 +20,13 @@ public final class Emoji: NSManagedObject {
|
|||
@NSManaged public private(set) var category: String?
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var toot: Toot?
|
||||
@NSManaged public private(set) var status: Status?
|
||||
}
|
||||
|
||||
public extension Emoji {
|
||||
override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
identifier = UUID()
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(Emoji.identifier))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
|
@ -14,8 +14,8 @@ public final class History: NSManagedObject {
|
|||
@NSManaged public private(set) var createAt: Date
|
||||
|
||||
@NSManaged public private(set) var day: Date
|
||||
@NSManaged public private(set) var uses: Int
|
||||
@NSManaged public private(set) var accounts: Int
|
||||
@NSManaged public private(set) var uses: String
|
||||
@NSManaged public private(set) var accounts: String
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var tag: Tag
|
||||
|
@ -24,7 +24,7 @@ public final class History: NSManagedObject {
|
|||
public extension History {
|
||||
override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
identifier = UUID()
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(History.identifier))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
@ -40,13 +40,33 @@ public extension History {
|
|||
}
|
||||
}
|
||||
|
||||
public extension History {
|
||||
func update(day: Date) {
|
||||
if self.day != day {
|
||||
self.day = day
|
||||
}
|
||||
}
|
||||
|
||||
func update(uses: String) {
|
||||
if self.uses != uses {
|
||||
self.uses = uses
|
||||
}
|
||||
}
|
||||
|
||||
func update(accounts: String) {
|
||||
if self.accounts != accounts {
|
||||
self.accounts = accounts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension History {
|
||||
struct Property {
|
||||
public let day: Date
|
||||
public let uses: Int
|
||||
public let accounts: Int
|
||||
public let uses: String
|
||||
public let accounts: String
|
||||
|
||||
public init(day: Date, uses: Int, accounts: Int) {
|
||||
public init(day: Date, uses: String, accounts: String) {
|
||||
self.day = day
|
||||
self.uses = uses
|
||||
self.accounts = accounts
|
||||
|
|
|
@ -22,7 +22,7 @@ final public class HomeTimelineIndex: NSManagedObject {
|
|||
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var toot: Toot
|
||||
@NSManaged public private(set) var status: Status
|
||||
|
||||
}
|
||||
|
||||
|
@ -32,16 +32,16 @@ extension HomeTimelineIndex {
|
|||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
toot: Toot
|
||||
status: Status
|
||||
) -> HomeTimelineIndex {
|
||||
let index: HomeTimelineIndex = context.insertObject()
|
||||
|
||||
index.identifier = property.identifier
|
||||
index.domain = property.domain
|
||||
index.userID = property.userID
|
||||
index.createdAt = toot.createdAt
|
||||
index.createdAt = status.createdAt
|
||||
|
||||
index.toot = toot
|
||||
index.status = status
|
||||
|
||||
return index
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ extension HomeTimelineIndex {
|
|||
}
|
||||
}
|
||||
|
||||
// internal method for Toot call
|
||||
// internal method for status call
|
||||
func softDelete() {
|
||||
deletedAt = Date()
|
||||
}
|
||||
|
|
|
@ -36,12 +36,12 @@ extension MastodonAuthentication {
|
|||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
identifier = UUID()
|
||||
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(MastodonAuthentication.identifier))
|
||||
let now = Date()
|
||||
createdAt = now
|
||||
updatedAt = now
|
||||
activedAt = now
|
||||
setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.createdAt))
|
||||
setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.updatedAt))
|
||||
setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.activedAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
|
@ -21,22 +21,51 @@ final public class MastodonUser: NSManagedObject {
|
|||
@NSManaged public private(set) var displayName: String
|
||||
@NSManaged public private(set) var avatar: String
|
||||
@NSManaged public private(set) var avatarStatic: String?
|
||||
@NSManaged public private(set) var header: String
|
||||
@NSManaged public private(set) var headerStatic: String?
|
||||
@NSManaged public private(set) var note: String?
|
||||
@NSManaged public private(set) var url: String?
|
||||
|
||||
@NSManaged public private(set) var emojisData: Data?
|
||||
|
||||
@NSManaged public private(set) var statusesCount: NSNumber
|
||||
@NSManaged public private(set) var followingCount: NSNumber
|
||||
@NSManaged public private(set) var followersCount: NSNumber
|
||||
|
||||
@NSManaged public private(set) var locked: Bool
|
||||
@NSManaged public private(set) var bot: Bool
|
||||
@NSManaged public private(set) var suspended: Bool
|
||||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
// one-to-one relationship
|
||||
@NSManaged public private(set) var pinnedToot: Toot?
|
||||
@NSManaged public private(set) var pinnedStatus: Status?
|
||||
@NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication?
|
||||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var toots: Set<Toot>?
|
||||
@NSManaged public private(set) var statuses: Set<Status>?
|
||||
|
||||
// many-to-many relationship
|
||||
@NSManaged public private(set) var favourite: Set<Toot>?
|
||||
@NSManaged public private(set) var reblogged: Set<Toot>?
|
||||
@NSManaged public private(set) var muted: Set<Toot>?
|
||||
@NSManaged public private(set) var bookmarked: Set<Toot>?
|
||||
@NSManaged public private(set) var favourite: Set<Status>?
|
||||
@NSManaged public private(set) var reblogged: Set<Status>?
|
||||
@NSManaged public private(set) var muted: Set<Status>?
|
||||
@NSManaged public private(set) var bookmarked: Set<Status>?
|
||||
@NSManaged public private(set) var votePollOptions: Set<PollOption>?
|
||||
@NSManaged public private(set) var votePolls: Set<Poll>?
|
||||
// relationships
|
||||
@NSManaged public private(set) var following: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var followingBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var followRequested: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var followRequestedBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var muting: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var mutingBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var blocking: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var blockingBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var endorsed: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var endorsedBy: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var domainBlocking: Set<MastodonUser>?
|
||||
@NSManaged public private(set) var domainBlockingBy: Set<MastodonUser>?
|
||||
|
||||
}
|
||||
|
||||
|
@ -58,6 +87,22 @@ extension MastodonUser {
|
|||
user.displayName = property.displayName
|
||||
user.avatar = property.avatar
|
||||
user.avatarStatic = property.avatarStatic
|
||||
user.header = property.header
|
||||
user.headerStatic = property.headerStatic
|
||||
user.note = property.note
|
||||
user.url = property.url
|
||||
user.emojisData = property.emojisData
|
||||
|
||||
user.statusesCount = NSNumber(value: property.statusesCount)
|
||||
user.followingCount = NSNumber(value: property.followingCount)
|
||||
user.followersCount = NSNumber(value: property.followersCount)
|
||||
|
||||
user.locked = property.locked
|
||||
user.bot = property.bot ?? false
|
||||
user.suspended = property.suspended ?? false
|
||||
|
||||
// Mastodon do not provide relationship on the `Account`
|
||||
// Update relationship via attribute updating interface
|
||||
|
||||
user.createdAt = property.createdAt
|
||||
user.updatedAt = property.networkDate
|
||||
|
@ -91,6 +136,128 @@ extension MastodonUser {
|
|||
self.avatarStatic = avatarStatic
|
||||
}
|
||||
}
|
||||
public func update(header: String) {
|
||||
if self.header != header {
|
||||
self.header = header
|
||||
}
|
||||
}
|
||||
public func update(headerStatic: String?) {
|
||||
if self.headerStatic != headerStatic {
|
||||
self.headerStatic = headerStatic
|
||||
}
|
||||
}
|
||||
public func update(note: String?) {
|
||||
if self.note != note {
|
||||
self.note = note
|
||||
}
|
||||
}
|
||||
public func update(url: String?) {
|
||||
if self.url != url {
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
public func update(emojisData: Data?) {
|
||||
if self.emojisData != emojisData {
|
||||
self.emojisData = emojisData
|
||||
}
|
||||
}
|
||||
public func update(statusesCount: Int) {
|
||||
if self.statusesCount.intValue != statusesCount {
|
||||
self.statusesCount = NSNumber(value: statusesCount)
|
||||
}
|
||||
}
|
||||
public func update(followingCount: Int) {
|
||||
if self.followingCount.intValue != followingCount {
|
||||
self.followingCount = NSNumber(value: followingCount)
|
||||
}
|
||||
}
|
||||
public func update(followersCount: Int) {
|
||||
if self.followersCount.intValue != followersCount {
|
||||
self.followersCount = NSNumber(value: followersCount)
|
||||
}
|
||||
}
|
||||
public func update(locked: Bool) {
|
||||
if self.locked != locked {
|
||||
self.locked = locked
|
||||
}
|
||||
}
|
||||
public func update(bot: Bool) {
|
||||
if self.bot != bot {
|
||||
self.bot = bot
|
||||
}
|
||||
}
|
||||
public func update(suspended: Bool) {
|
||||
if self.suspended != suspended {
|
||||
self.suspended = suspended
|
||||
}
|
||||
}
|
||||
|
||||
public func update(isFollowing: Bool, by mastodonUser: MastodonUser) {
|
||||
if isFollowing {
|
||||
if !(self.followingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.followingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) {
|
||||
if isFollowRequested {
|
||||
if !(self.followRequestedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.followRequestedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func update(isMuting: Bool, by mastodonUser: MastodonUser) {
|
||||
if isMuting {
|
||||
if !(self.mutingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.mutingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func update(isBlocking: Bool, by mastodonUser: MastodonUser) {
|
||||
if isBlocking {
|
||||
if !(self.blockingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.blockingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) {
|
||||
if isEndorsed {
|
||||
if !(self.endorsedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.endorsedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) {
|
||||
if isDomainBlocking {
|
||||
if !(self.domainBlockingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.domainBlockingBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
|
@ -98,8 +265,8 @@ extension MastodonUser {
|
|||
|
||||
}
|
||||
|
||||
public extension MastodonUser {
|
||||
struct Property {
|
||||
extension MastodonUser {
|
||||
public struct Property {
|
||||
public let identifier: String
|
||||
public let domain: String
|
||||
|
||||
|
@ -109,6 +276,17 @@ public extension MastodonUser {
|
|||
public let displayName: String
|
||||
public let avatar: String
|
||||
public let avatarStatic: String?
|
||||
public let header: String
|
||||
public let headerStatic: String?
|
||||
public let note: String?
|
||||
public let url: String?
|
||||
public let emojisData: Data?
|
||||
public let statusesCount: Int
|
||||
public let followingCount: Int
|
||||
public let followersCount: Int
|
||||
public let locked: Bool
|
||||
public let bot: Bool?
|
||||
public let suspended: Bool?
|
||||
|
||||
public let createdAt: Date
|
||||
public let networkDate: Date
|
||||
|
@ -121,6 +299,17 @@ public extension MastodonUser {
|
|||
displayName: String,
|
||||
avatar: String,
|
||||
avatarStatic: String?,
|
||||
header: String,
|
||||
headerStatic: String?,
|
||||
note: String?,
|
||||
url: String?,
|
||||
emojisData: Data?,
|
||||
statusesCount: Int,
|
||||
followingCount: Int,
|
||||
followersCount: Int,
|
||||
locked: Bool,
|
||||
bot: Bool?,
|
||||
suspended: Bool?,
|
||||
createdAt: Date,
|
||||
networkDate: Date
|
||||
) {
|
||||
|
@ -132,6 +321,17 @@ public extension MastodonUser {
|
|||
self.displayName = displayName
|
||||
self.avatar = avatar
|
||||
self.avatarStatic = avatarStatic
|
||||
self.header = header
|
||||
self.headerStatic = headerStatic
|
||||
self.note = note
|
||||
self.url = url
|
||||
self.emojisData = emojisData
|
||||
self.statusesCount = statusesCount
|
||||
self.followingCount = followingCount
|
||||
self.followersCount = followersCount
|
||||
self.locked = locked
|
||||
self.bot = bot
|
||||
self.suspended = suspended
|
||||
self.createdAt = createdAt
|
||||
self.networkDate = networkDate
|
||||
}
|
||||
|
|
|
@ -10,6 +10,9 @@ import Foundation
|
|||
|
||||
public final class Mention: NSManagedObject {
|
||||
public typealias ID = UUID
|
||||
|
||||
@NSManaged public private(set) var index: NSNumber
|
||||
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
@NSManaged public private(set) var id: String
|
||||
@NSManaged public private(set) var createAt: Date
|
||||
|
@ -19,21 +22,24 @@ public final class Mention: NSManagedObject {
|
|||
@NSManaged public private(set) var url: String
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var toot: Toot
|
||||
@NSManaged public private(set) var status: Status
|
||||
}
|
||||
|
||||
public extension Mention {
|
||||
override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
identifier = UUID()
|
||||
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(Mention.identifier))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property
|
||||
property: Property,
|
||||
index: Int
|
||||
) -> Mention {
|
||||
let mention: Mention = context.insertObject()
|
||||
mention.index = NSNumber(value: index)
|
||||
mention.id = property.id
|
||||
mention.username = property.username
|
||||
mention.acct = property.acct
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
//
|
||||
// MastodonNotification.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public final class MastodonNotification: NSManagedObject {
|
||||
public typealias ID = UUID
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
@NSManaged public private(set) var id: String
|
||||
@NSManaged public private(set) var createAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
@NSManaged public private(set) var typeRaw: String
|
||||
@NSManaged public private(set) var account: MastodonUser
|
||||
@NSManaged public private(set) var status: Status?
|
||||
|
||||
@NSManaged public private(set) var domain: String
|
||||
@NSManaged public private(set) var userID: String
|
||||
}
|
||||
|
||||
extension MastodonNotification {
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier))
|
||||
}
|
||||
}
|
||||
|
||||
public extension MastodonNotification {
|
||||
@discardableResult
|
||||
static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
domain: String,
|
||||
userID: String,
|
||||
networkDate: Date,
|
||||
property: Property
|
||||
) -> MastodonNotification {
|
||||
let notification: MastodonNotification = context.insertObject()
|
||||
notification.id = property.id
|
||||
notification.createAt = property.createdAt
|
||||
notification.updatedAt = networkDate
|
||||
notification.typeRaw = property.typeRaw
|
||||
notification.account = property.account
|
||||
notification.status = property.status
|
||||
notification.domain = domain
|
||||
notification.userID = userID
|
||||
return notification
|
||||
}
|
||||
}
|
||||
|
||||
public extension MastodonNotification {
|
||||
struct Property {
|
||||
public init(id: String,
|
||||
typeRaw: String,
|
||||
account: MastodonUser,
|
||||
status: Status?,
|
||||
createdAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.typeRaw = typeRaw
|
||||
self.account = account
|
||||
self.status = status
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public let id: String
|
||||
public let typeRaw: String
|
||||
public let account: MastodonUser
|
||||
public let status: Status?
|
||||
public let createdAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonNotification {
|
||||
static func predicate(domain: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain)
|
||||
}
|
||||
|
||||
static func predicate(userID: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.userID), userID)
|
||||
}
|
||||
|
||||
static func predicate(typeRaw: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.typeRaw), typeRaw)
|
||||
}
|
||||
|
||||
public static func predicate(domain: String, userID: String, typeRaw: String? = nil) -> NSPredicate {
|
||||
if let typeRaw = typeRaw {
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
MastodonNotification.predicate(domain: domain),
|
||||
MastodonNotification.predicate(typeRaw: typeRaw),
|
||||
MastodonNotification.predicate(userID: userID),
|
||||
])
|
||||
} else {
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
MastodonNotification.predicate(domain: domain),
|
||||
MastodonNotification.predicate(userID: userID)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonNotification: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \MastodonNotification.createAt, ascending: false)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
//
|
||||
// Poll.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public final class Poll: NSManagedObject {
|
||||
public typealias ID = String
|
||||
|
||||
@NSManaged public private(set) var id: ID
|
||||
@NSManaged public private(set) var expiresAt: Date?
|
||||
@NSManaged public private(set) var expired: Bool
|
||||
@NSManaged public private(set) var multiple: Bool
|
||||
@NSManaged public private(set) var votesCount: NSNumber
|
||||
@NSManaged public private(set) var votersCount: NSNumber?
|
||||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
// one-to-one relationship
|
||||
@NSManaged public private(set) var status: Status
|
||||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var options: Set<PollOption>
|
||||
|
||||
// many-to-many relationship
|
||||
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
|
||||
}
|
||||
|
||||
extension Poll {
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(Poll.createdAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
votedBy: MastodonUser?,
|
||||
options: [PollOption]
|
||||
) -> Poll {
|
||||
let poll: Poll = context.insertObject()
|
||||
|
||||
poll.id = property.id
|
||||
poll.expiresAt = property.expiresAt
|
||||
poll.expired = property.expired
|
||||
poll.multiple = property.multiple
|
||||
poll.votesCount = property.votesCount
|
||||
poll.votersCount = property.votersCount
|
||||
|
||||
|
||||
poll.updatedAt = property.networkDate
|
||||
|
||||
if let votedBy = votedBy {
|
||||
poll.mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(votedBy)
|
||||
}
|
||||
poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options)
|
||||
|
||||
return poll
|
||||
}
|
||||
|
||||
public func update(expiresAt: Date?) {
|
||||
if self.expiresAt != expiresAt {
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
public func update(expired: Bool) {
|
||||
if self.expired != expired {
|
||||
self.expired = expired
|
||||
}
|
||||
}
|
||||
|
||||
public func update(votesCount: Int) {
|
||||
if self.votesCount.intValue != votesCount {
|
||||
self.votesCount = NSNumber(value: votesCount)
|
||||
}
|
||||
}
|
||||
|
||||
public func update(votersCount: Int?) {
|
||||
if self.votersCount?.intValue != votersCount {
|
||||
self.votersCount = votersCount.flatMap { NSNumber(value: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
public func update(voted: Bool, by: MastodonUser) {
|
||||
if voted {
|
||||
if !(votedBy ?? Set()).contains(by) {
|
||||
mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by)
|
||||
}
|
||||
} else {
|
||||
if (votedBy ?? Set()).contains(by) {
|
||||
mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Poll {
|
||||
public struct Property {
|
||||
public let id: ID
|
||||
public let expiresAt: Date?
|
||||
public let expired: Bool
|
||||
public let multiple: Bool
|
||||
public let votesCount: NSNumber
|
||||
public let votersCount: NSNumber?
|
||||
|
||||
public let networkDate: Date
|
||||
|
||||
public init(
|
||||
id: Poll.ID,
|
||||
expiresAt: Date?,
|
||||
expired: Bool,
|
||||
multiple: Bool,
|
||||
votesCount: Int,
|
||||
votersCount: Int?,
|
||||
networkDate: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.expiresAt = expiresAt
|
||||
self.expired = expired
|
||||
self.multiple = multiple
|
||||
self.votesCount = NSNumber(value: votesCount)
|
||||
self.votersCount = votersCount.flatMap { NSNumber(value: $0) }
|
||||
self.networkDate = networkDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Poll: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// PollOption.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public final class PollOption: NSManagedObject {
|
||||
@NSManaged public private(set) var index: NSNumber
|
||||
@NSManaged public private(set) var title: String
|
||||
@NSManaged public private(set) var votesCount: NSNumber?
|
||||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var poll: Poll
|
||||
|
||||
// many-to-many relationship
|
||||
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
|
||||
}
|
||||
|
||||
extension PollOption {
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(PollOption.createdAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
votedBy: MastodonUser?
|
||||
) -> PollOption {
|
||||
let option: PollOption = context.insertObject()
|
||||
|
||||
option.index = property.index
|
||||
option.title = property.title
|
||||
option.votesCount = property.votesCount
|
||||
option.updatedAt = property.networkDate
|
||||
|
||||
if let votedBy = votedBy {
|
||||
option.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy)
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
|
||||
public func update(votesCount: Int?) {
|
||||
if self.votesCount?.intValue != votesCount {
|
||||
self.votesCount = votesCount.flatMap { NSNumber(value: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
public func update(voted: Bool, by: MastodonUser) {
|
||||
if voted {
|
||||
if !(self.votedBy ?? Set()).contains(by) {
|
||||
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by)
|
||||
}
|
||||
} else {
|
||||
if (self.votedBy ?? Set()).contains(by) {
|
||||
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PollOption {
|
||||
public struct Property {
|
||||
public let index: NSNumber
|
||||
public let title: String
|
||||
public let votesCount: NSNumber?
|
||||
|
||||
public let networkDate: Date
|
||||
|
||||
public init(index: Int, title: String, votesCount: Int?, networkDate: Date) {
|
||||
self.index = NSNumber(value: index)
|
||||
self.title = title
|
||||
self.votesCount = votesCount.flatMap { NSNumber(value: $0) }
|
||||
self.networkDate = networkDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PollOption: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
// PrivateNote.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-1.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
final public class PrivateNote: NSManagedObject {
|
||||
|
||||
@NSManaged public private(set) var note: String?
|
||||
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
// many-to-one relationship
|
||||
@NSManaged public private(set) var to: MastodonUser?
|
||||
@NSManaged public private(set) var from: MastodonUser
|
||||
|
||||
}
|
||||
|
||||
extension PrivateNote {
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(PrivateNote.updatedAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property
|
||||
) -> PrivateNote {
|
||||
let privateNode: PrivateNote = context.insertObject()
|
||||
privateNode.note = property.note
|
||||
return privateNode
|
||||
}
|
||||
}
|
||||
|
||||
extension PrivateNote {
|
||||
public struct Property {
|
||||
public let note: String?
|
||||
|
||||
init(note: String) {
|
||||
self.note = note
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PrivateNote: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \PrivateNote.updatedAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
//
|
||||
// SearchHistory.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/7.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public final class SearchHistory: NSManagedObject {
|
||||
public typealias ID = UUID
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
@NSManaged public private(set) var createAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
@NSManaged public private(set) var account: MastodonUser?
|
||||
@NSManaged public private(set) var hashtag: Tag?
|
||||
|
||||
}
|
||||
|
||||
extension SearchHistory {
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier))
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.createAt))
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt))
|
||||
}
|
||||
|
||||
public override func willSave() {
|
||||
super.willSave()
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
account: MastodonUser
|
||||
) -> SearchHistory {
|
||||
let searchHistory: SearchHistory = context.insertObject()
|
||||
searchHistory.account = account
|
||||
return searchHistory
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
hashtag: Tag
|
||||
) -> SearchHistory {
|
||||
let searchHistory: SearchHistory = context.insertObject()
|
||||
searchHistory.hashtag = hashtag
|
||||
return searchHistory
|
||||
}
|
||||
}
|
||||
|
||||
public extension SearchHistory {
|
||||
func update(updatedAt: Date) {
|
||||
setValue(updatedAt, forKey: #keyPath(SearchHistory.updatedAt))
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchHistory: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
//
|
||||
// Setting.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public final class Setting: NSManagedObject {
|
||||
|
||||
@NSManaged public var appearanceRaw: String
|
||||
@NSManaged public var domain: String
|
||||
@NSManaged public var userID: String
|
||||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
// one-to-many relationships
|
||||
@NSManaged public var subscriptions: Set<Subscription>?
|
||||
}
|
||||
|
||||
extension Setting {
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
let now = Date()
|
||||
setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt))
|
||||
setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property
|
||||
) -> Setting {
|
||||
let setting: Setting = context.insertObject()
|
||||
setting.appearanceRaw = property.appearanceRaw
|
||||
setting.domain = property.domain
|
||||
setting.userID = property.userID
|
||||
return setting
|
||||
}
|
||||
|
||||
public func update(appearanceRaw: String) {
|
||||
guard appearanceRaw != self.appearanceRaw else { return }
|
||||
self.appearanceRaw = appearanceRaw
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
public func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Setting {
|
||||
public struct Property {
|
||||
public let domain: String
|
||||
public let userID: String
|
||||
public let appearanceRaw: String
|
||||
|
||||
public init(domain: String, userID: String, appearanceRaw: String) {
|
||||
self.domain = domain
|
||||
self.userID = userID
|
||||
self.appearanceRaw = appearanceRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Setting: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
||||
extension Setting {
|
||||
public static func predicate(domain: String, userID: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@ AND %K == %@",
|
||||
#keyPath(Setting.domain), domain,
|
||||
#keyPath(Setting.userID), userID
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Toot.swift
|
||||
// Status.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021/1/27.
|
||||
|
@ -8,7 +8,7 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
|
||||
public final class Toot: NSManagedObject {
|
||||
public final class Status: NSManagedObject {
|
||||
public typealias ID = String
|
||||
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
|
@ -24,13 +24,15 @@ public final class Toot: NSManagedObject {
|
|||
@NSManaged public private(set) var spoilerText: String?
|
||||
@NSManaged public private(set) var application: Application?
|
||||
|
||||
@NSManaged public private(set) var emojisData: Data?
|
||||
|
||||
// Informational
|
||||
@NSManaged public private(set) var reblogsCount: NSNumber
|
||||
@NSManaged public private(set) var favouritesCount: NSNumber
|
||||
@NSManaged public private(set) var repliesCount: NSNumber?
|
||||
|
||||
@NSManaged public private(set) var url: String?
|
||||
@NSManaged public private(set) var inReplyToID: Toot.ID?
|
||||
@NSManaged public private(set) var inReplyToID: Status.ID?
|
||||
@NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID?
|
||||
|
||||
@NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code)
|
||||
|
@ -38,7 +40,8 @@ public final class Toot: NSManagedObject {
|
|||
|
||||
// many-to-one relastionship
|
||||
@NSManaged public private(set) var author: MastodonUser
|
||||
@NSManaged public private(set) var reblog: Toot?
|
||||
@NSManaged public private(set) var reblog: Status?
|
||||
@NSManaged public private(set) var replyTo: Status?
|
||||
|
||||
// many-to-many relastionship
|
||||
@NSManaged public private(set) var favouritedBy: Set<MastodonUser>?
|
||||
|
@ -48,29 +51,35 @@ public final class Toot: NSManagedObject {
|
|||
|
||||
// one-to-one relastionship
|
||||
@NSManaged public private(set) var pinnedBy: MastodonUser?
|
||||
@NSManaged public private(set) var poll: Poll?
|
||||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var reblogFrom: Set<Toot>?
|
||||
@NSManaged public private(set) var reblogFrom: Set<Status>?
|
||||
@NSManaged public private(set) var mentions: Set<Mention>?
|
||||
@NSManaged public private(set) var emojis: Set<Emoji>?
|
||||
@NSManaged public private(set) var tags: Set<Tag>?
|
||||
@NSManaged public private(set) var homeTimelineIndexes: Set<HomeTimelineIndex>?
|
||||
@NSManaged public private(set) var mediaAttachments: Set<Attachment>?
|
||||
@NSManaged public private(set) var replyFrom: Set<Status>?
|
||||
|
||||
@NSManaged public private(set) var inNotifications: Set<MastodonNotification>?
|
||||
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
@NSManaged public private(set) var deletedAt: Date?
|
||||
@NSManaged public private(set) var revealedAt: Date?
|
||||
}
|
||||
|
||||
public extension Toot {
|
||||
extension Status {
|
||||
|
||||
@discardableResult
|
||||
static func insert(
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
author: MastodonUser,
|
||||
reblog: Toot?,
|
||||
reblog: Status?,
|
||||
application: Application?,
|
||||
replyTo: Status?,
|
||||
poll: Poll?,
|
||||
mentions: [Mention]?,
|
||||
emojis: [Emoji]?,
|
||||
tags: [Tag]?,
|
||||
mediaAttachments: [Attachment]?,
|
||||
favouritedBy: MastodonUser?,
|
||||
|
@ -78,78 +87,87 @@ public extension Toot {
|
|||
mutedBy: MastodonUser?,
|
||||
bookmarkedBy: MastodonUser?,
|
||||
pinnedBy: MastodonUser?
|
||||
) -> Toot {
|
||||
let toot: Toot = context.insertObject()
|
||||
) -> Status {
|
||||
let status: Status = context.insertObject()
|
||||
|
||||
toot.identifier = property.identifier
|
||||
toot.domain = property.domain
|
||||
status.identifier = property.identifier
|
||||
status.domain = property.domain
|
||||
|
||||
toot.id = property.id
|
||||
toot.uri = property.uri
|
||||
toot.createdAt = property.createdAt
|
||||
toot.content = property.content
|
||||
status.id = property.id
|
||||
status.uri = property.uri
|
||||
status.createdAt = property.createdAt
|
||||
status.content = property.content
|
||||
|
||||
toot.visibility = property.visibility
|
||||
toot.sensitive = property.sensitive
|
||||
toot.spoilerText = property.spoilerText
|
||||
toot.application = application
|
||||
status.visibility = property.visibility
|
||||
status.sensitive = property.sensitive
|
||||
status.spoilerText = property.spoilerText
|
||||
status.application = application
|
||||
|
||||
status.emojisData = property.emojisData
|
||||
|
||||
toot.reblogsCount = property.reblogsCount
|
||||
toot.favouritesCount = property.favouritesCount
|
||||
toot.repliesCount = property.repliesCount
|
||||
status.reblogsCount = property.reblogsCount
|
||||
status.favouritesCount = property.favouritesCount
|
||||
status.repliesCount = property.repliesCount
|
||||
|
||||
toot.url = property.url
|
||||
toot.inReplyToID = property.inReplyToID
|
||||
toot.inReplyToAccountID = property.inReplyToAccountID
|
||||
status.url = property.url
|
||||
status.inReplyToID = property.inReplyToID
|
||||
status.inReplyToAccountID = property.inReplyToAccountID
|
||||
|
||||
toot.language = property.language
|
||||
toot.text = property.text
|
||||
status.language = property.language
|
||||
status.text = property.text
|
||||
|
||||
toot.author = author
|
||||
toot.reblog = reblog
|
||||
status.author = author
|
||||
status.reblog = reblog
|
||||
|
||||
toot.pinnedBy = pinnedBy
|
||||
status.pinnedBy = pinnedBy
|
||||
status.poll = poll
|
||||
|
||||
if let mentions = mentions {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions)
|
||||
}
|
||||
if let emojis = emojis {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis)
|
||||
status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions)
|
||||
}
|
||||
if let tags = tags {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags)
|
||||
status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags)
|
||||
}
|
||||
if let mediaAttachments = mediaAttachments {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.mediaAttachments)).addObjects(from: mediaAttachments)
|
||||
status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments)
|
||||
}
|
||||
if let favouritedBy = favouritedBy {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy)
|
||||
status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy)
|
||||
}
|
||||
if let rebloggedBy = rebloggedBy {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy)
|
||||
status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy)
|
||||
}
|
||||
if let mutedBy = mutedBy {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy)
|
||||
status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy)
|
||||
}
|
||||
if let bookmarkedBy = bookmarkedBy {
|
||||
toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy)
|
||||
status.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy)
|
||||
}
|
||||
|
||||
toot.updatedAt = property.networkDate
|
||||
status.updatedAt = property.networkDate
|
||||
|
||||
return toot
|
||||
return status
|
||||
}
|
||||
func update(reblogsCount: NSNumber) {
|
||||
|
||||
public func update(emojisData: Data?) {
|
||||
if self.emojisData != emojisData {
|
||||
self.emojisData = emojisData
|
||||
}
|
||||
}
|
||||
|
||||
public func update(reblogsCount: NSNumber) {
|
||||
if self.reblogsCount.intValue != reblogsCount.intValue {
|
||||
self.reblogsCount = reblogsCount
|
||||
}
|
||||
}
|
||||
func update(favouritesCount: NSNumber) {
|
||||
|
||||
public func update(favouritesCount: NSNumber) {
|
||||
if self.favouritesCount.intValue != favouritesCount.intValue {
|
||||
self.favouritesCount = favouritesCount
|
||||
}
|
||||
}
|
||||
func update(repliesCount: NSNumber?) {
|
||||
|
||||
public func update(repliesCount: NSNumber?) {
|
||||
guard let count = repliesCount else {
|
||||
return
|
||||
}
|
||||
|
@ -157,61 +175,73 @@ public extension Toot {
|
|||
self.repliesCount = repliesCount
|
||||
}
|
||||
}
|
||||
func update(liked: Bool, mastodonUser: MastodonUser) {
|
||||
|
||||
public func update(replyTo: Status?) {
|
||||
if self.replyTo != replyTo {
|
||||
self.replyTo = replyTo
|
||||
}
|
||||
}
|
||||
|
||||
public func update(liked: Bool, by mastodonUser: MastodonUser) {
|
||||
if liked {
|
||||
if !(self.favouritedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).addObjects(from: [mastodonUser])
|
||||
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.favouritedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).remove(mastodonUser)
|
||||
self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
func update(reblogged: Bool, mastodonUser: MastodonUser) {
|
||||
|
||||
public func update(reblogged: Bool, by mastodonUser: MastodonUser) {
|
||||
if reblogged {
|
||||
if !(self.rebloggedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).addObjects(from: [mastodonUser])
|
||||
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.rebloggedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).remove(mastodonUser)
|
||||
self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(muted: Bool, mastodonUser: MastodonUser) {
|
||||
public func update(muted: Bool, by mastodonUser: MastodonUser) {
|
||||
if muted {
|
||||
if !(self.mutedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).addObjects(from: [mastodonUser])
|
||||
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.mutedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).remove(mastodonUser)
|
||||
self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(bookmarked: Bool, mastodonUser: MastodonUser) {
|
||||
public func update(bookmarked: Bool, by mastodonUser: MastodonUser) {
|
||||
if bookmarked {
|
||||
if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).addObjects(from: [mastodonUser])
|
||||
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser)
|
||||
}
|
||||
} else {
|
||||
if (self.bookmarkedBy ?? Set()).contains(mastodonUser) {
|
||||
self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).remove(mastodonUser)
|
||||
self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didUpdate(at networkDate: Date) {
|
||||
public func update(isReveal: Bool) {
|
||||
revealedAt = isReveal ? Date() : nil
|
||||
}
|
||||
|
||||
public func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension Toot {
|
||||
struct Property {
|
||||
extension Status {
|
||||
public struct Property {
|
||||
|
||||
public let identifier: ID
|
||||
public let domain: String
|
||||
|
@ -225,12 +255,14 @@ public extension Toot {
|
|||
public let sensitive: Bool
|
||||
public let spoilerText: String?
|
||||
|
||||
public let emojisData: Data?
|
||||
|
||||
public let reblogsCount: NSNumber
|
||||
public let favouritesCount: NSNumber
|
||||
public let repliesCount: NSNumber?
|
||||
|
||||
public let url: String?
|
||||
public let inReplyToID: Toot.ID?
|
||||
public let inReplyToID: Status.ID?
|
||||
public let inReplyToAccountID: MastodonUser.ID?
|
||||
public let language: String? // (ISO 639 Part @1 two-letter language code)
|
||||
public let text: String?
|
||||
|
@ -246,11 +278,12 @@ public extension Toot {
|
|||
visibility: String?,
|
||||
sensitive: Bool,
|
||||
spoilerText: String?,
|
||||
emojisData: Data?,
|
||||
reblogsCount: NSNumber,
|
||||
favouritesCount: NSNumber,
|
||||
repliesCount: NSNumber?,
|
||||
url: String?,
|
||||
inReplyToID: Toot.ID?,
|
||||
inReplyToID: Status.ID?,
|
||||
inReplyToAccountID: MastodonUser.ID?,
|
||||
language: String?,
|
||||
text: String?,
|
||||
|
@ -265,6 +298,7 @@ public extension Toot {
|
|||
self.visibility = visibility
|
||||
self.sensitive = sensitive
|
||||
self.spoilerText = spoilerText
|
||||
self.emojisData = emojisData
|
||||
self.reblogsCount = reblogsCount
|
||||
self.favouritesCount = favouritesCount
|
||||
self.repliesCount = repliesCount
|
||||
|
@ -279,20 +313,20 @@ public extension Toot {
|
|||
}
|
||||
}
|
||||
|
||||
extension Toot: Managed {
|
||||
extension Status: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \Toot.createdAt, ascending: false)]
|
||||
return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
||||
extension Toot {
|
||||
extension Status {
|
||||
|
||||
static func predicate(domain: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain)
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain)
|
||||
}
|
||||
|
||||
static func predicate(id: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id)
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Status.id), id)
|
||||
}
|
||||
|
||||
public static func predicate(domain: String, id: String) -> NSPredicate {
|
||||
|
@ -303,7 +337,7 @@ extension Toot {
|
|||
}
|
||||
|
||||
static func predicate(ids: [String]) -> NSPredicate {
|
||||
return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids)
|
||||
return NSPredicate(format: "%K IN %@", #keyPath(Status.id), ids)
|
||||
}
|
||||
|
||||
public static func predicate(domain: String, ids: [String]) -> NSPredicate {
|
||||
|
@ -314,10 +348,11 @@ extension Toot {
|
|||
}
|
||||
|
||||
public static func notDeleted() -> NSPredicate {
|
||||
return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt))
|
||||
return NSPredicate(format: "%K == nil", #keyPath(Status.deletedAt))
|
||||
}
|
||||
|
||||
public static func deleted() -> NSPredicate {
|
||||
return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt))
|
||||
return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
// SettingNotification+CoreDataClass.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public final class Subscription: NSManagedObject {
|
||||
|
||||
@NSManaged public var id: String?
|
||||
@NSManaged public var endpoint: String?
|
||||
@NSManaged public var policyRaw: String
|
||||
@NSManaged public var serverKey: String?
|
||||
@NSManaged public var userToken: String?
|
||||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
@NSManaged public private(set) var activedAt: Date
|
||||
|
||||
// MARK: one-to-one relationships
|
||||
@NSManaged public var alert: SubscriptionAlerts
|
||||
|
||||
// MARK: many-to-one relationships
|
||||
@NSManaged public var setting: Setting?
|
||||
}
|
||||
|
||||
public extension Subscription {
|
||||
override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
let now = Date()
|
||||
setPrimitiveValue(now, forKey: #keyPath(Subscription.createdAt))
|
||||
setPrimitiveValue(now, forKey: #keyPath(Subscription.updatedAt))
|
||||
setPrimitiveValue(now, forKey: #keyPath(Subscription.activedAt))
|
||||
}
|
||||
|
||||
func update(activedAt: Date) {
|
||||
self.activedAt = activedAt
|
||||
}
|
||||
|
||||
func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
setting: Setting
|
||||
) -> Subscription {
|
||||
let subscription: Subscription = context.insertObject()
|
||||
subscription.policyRaw = property.policyRaw
|
||||
subscription.setting = setting
|
||||
return subscription
|
||||
}
|
||||
}
|
||||
|
||||
public extension Subscription {
|
||||
struct Property {
|
||||
public let policyRaw: String
|
||||
|
||||
public init(policyRaw: String) {
|
||||
self.policyRaw = policyRaw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Subscription: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \Subscription.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
||||
extension Subscription {
|
||||
|
||||
public static func predicate(policyRaw: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw)
|
||||
}
|
||||
|
||||
public static func predicate(userToken: String) -> NSPredicate {
|
||||
return NSPredicate(format: "%K == %@", #keyPath(Subscription.userToken), userToken)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,178 @@
|
|||
//
|
||||
// PushSubscriptionAlerts+CoreDataClass.swift
|
||||
// CoreDataStack
|
||||
//
|
||||
// Created by ihugo on 2021/4/9.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public final class SubscriptionAlerts: NSManagedObject {
|
||||
@NSManaged public var favouriteRaw: NSNumber?
|
||||
@NSManaged public var followRaw: NSNumber?
|
||||
@NSManaged public var followRequestRaw: NSNumber?
|
||||
@NSManaged public var mentionRaw: NSNumber?
|
||||
@NSManaged public var pollRaw: NSNumber?
|
||||
@NSManaged public var reblogRaw: NSNumber?
|
||||
|
||||
@NSManaged public private(set) var createdAt: Date
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
// MARK: one-to-one relationships
|
||||
@NSManaged public var subscription: Subscription
|
||||
}
|
||||
|
||||
extension SubscriptionAlerts {
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
let now = Date()
|
||||
setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt))
|
||||
setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
property: Property,
|
||||
subscription: Subscription
|
||||
) -> SubscriptionAlerts {
|
||||
let alerts: SubscriptionAlerts = context.insertObject()
|
||||
|
||||
alerts.favouriteRaw = property.favouriteRaw
|
||||
alerts.followRaw = property.followRaw
|
||||
alerts.followRequestRaw = property.followRequestRaw
|
||||
alerts.mentionRaw = property.mentionRaw
|
||||
alerts.pollRaw = property.pollRaw
|
||||
alerts.reblogRaw = property.reblogRaw
|
||||
|
||||
alerts.subscription = subscription
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
public func update(favourite: Bool?) {
|
||||
guard self.favourite != favourite else { return }
|
||||
self.favourite = favourite
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
public func update(follow: Bool?) {
|
||||
guard self.follow != follow else { return }
|
||||
self.follow = follow
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
public func update(followRequest: Bool?) {
|
||||
guard self.followRequest != followRequest else { return }
|
||||
self.followRequest = followRequest
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
public func update(mention: Bool?) {
|
||||
guard self.mention != mention else { return }
|
||||
self.mention = mention
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
public func update(poll: Bool?) {
|
||||
guard self.poll != poll else { return }
|
||||
self.poll = poll
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
public func update(reblog: Bool?) {
|
||||
guard self.reblog != reblog else { return }
|
||||
self.reblog = reblog
|
||||
|
||||
didUpdate(at: Date())
|
||||
}
|
||||
|
||||
public func didUpdate(at networkDate: Date) {
|
||||
self.updatedAt = networkDate
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SubscriptionAlerts {
|
||||
|
||||
private func boolean(from number: NSNumber?) -> Bool? {
|
||||
return number.flatMap { $0.intValue == 1 }
|
||||
}
|
||||
|
||||
private func number(from boolean: Bool?) -> NSNumber? {
|
||||
return boolean.flatMap { NSNumber(integerLiteral: $0 ? 1 : 0) }
|
||||
}
|
||||
|
||||
public var favourite: Bool? {
|
||||
get { boolean(from: favouriteRaw) }
|
||||
set { favouriteRaw = number(from: newValue) }
|
||||
}
|
||||
|
||||
public var follow: Bool? {
|
||||
get { boolean(from: followRaw) }
|
||||
set { followRaw = number(from: newValue) }
|
||||
}
|
||||
|
||||
public var followRequest: Bool? {
|
||||
get { boolean(from: followRequestRaw) }
|
||||
set { followRequestRaw = number(from: newValue) }
|
||||
}
|
||||
|
||||
public var mention: Bool? {
|
||||
get { boolean(from: mentionRaw) }
|
||||
set { mentionRaw = number(from: newValue) }
|
||||
}
|
||||
|
||||
public var poll: Bool? {
|
||||
get { boolean(from: pollRaw) }
|
||||
set { pollRaw = number(from: newValue) }
|
||||
}
|
||||
|
||||
public var reblog: Bool? {
|
||||
get { boolean(from: reblogRaw) }
|
||||
set { reblogRaw = number(from: newValue) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SubscriptionAlerts {
|
||||
public struct Property {
|
||||
public let favouriteRaw: NSNumber?
|
||||
public let followRaw: NSNumber?
|
||||
public let followRequestRaw: NSNumber?
|
||||
public let mentionRaw: NSNumber?
|
||||
public let pollRaw: NSNumber?
|
||||
public let reblogRaw: NSNumber?
|
||||
|
||||
public init(
|
||||
favourite: Bool?,
|
||||
follow: Bool?,
|
||||
followRequest: Bool?,
|
||||
mention: Bool?,
|
||||
poll: Bool?,
|
||||
reblog: Bool?
|
||||
) {
|
||||
self.favouriteRaw = favourite.flatMap { NSNumber(value: $0 ? 1 : 0) }
|
||||
self.followRaw = follow.flatMap { NSNumber(value: $0 ? 1 : 0) }
|
||||
self.followRequestRaw = followRequest.flatMap { NSNumber(value: $0 ? 1 : 0) }
|
||||
self.mentionRaw = mention.flatMap { NSNumber(value: $0 ? 1 : 0) }
|
||||
self.pollRaw = poll.flatMap { NSNumber(value: $0 ? 1 : 0) }
|
||||
self.reblogRaw = reblog.flatMap { NSNumber(value: $0 ? 1 : 0) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SubscriptionAlerts: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \SubscriptionAlerts.createdAt, ascending: false)]
|
||||
}
|
||||
}
|
|
@ -12,13 +12,14 @@ public final class Tag: NSManagedObject {
|
|||
public typealias ID = UUID
|
||||
@NSManaged public private(set) var identifier: ID
|
||||
@NSManaged public private(set) var createAt: Date
|
||||
|
||||
@NSManaged public private(set) var updatedAt: Date
|
||||
|
||||
@NSManaged public private(set) var name: String
|
||||
@NSManaged public private(set) var url: String
|
||||
|
||||
|
||||
// many-to-many relationship
|
||||
@NSManaged public private(set) var toot: Toot
|
||||
|
||||
@NSManaged public private(set) var statuses: Set<Status>?
|
||||
|
||||
// one-to-many relationship
|
||||
@NSManaged public private(set) var histories: Set<History>?
|
||||
}
|
||||
|
@ -26,8 +27,16 @@ public final class Tag: NSManagedObject {
|
|||
public extension Tag {
|
||||
override func awakeFromInsert() {
|
||||
super.awakeFromInsert()
|
||||
identifier = UUID()
|
||||
setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier))
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(Tag.createAt))
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt))
|
||||
}
|
||||
|
||||
override func willSave() {
|
||||
super.willSave()
|
||||
setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func insert(
|
||||
into context: NSManagedObjectContext,
|
||||
|
@ -57,8 +66,36 @@ public extension Tag {
|
|||
}
|
||||
}
|
||||
|
||||
extension Tag: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
return [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)]
|
||||
public extension Tag {
|
||||
func updateHistory(index: Int, day: Date, uses: String, account: String) {
|
||||
guard let histories = self.histories?.sorted(by: {
|
||||
$0.createAt.compare($1.createAt) == .orderedAscending
|
||||
}) else { return }
|
||||
let history = histories[index]
|
||||
history.update(day: day)
|
||||
history.update(uses: uses)
|
||||
history.update(accounts: account)
|
||||
}
|
||||
|
||||
func appendHistory(history: History) {
|
||||
self.mutableSetValue(forKeyPath: #keyPath(Tag.histories)).add(history)
|
||||
}
|
||||
|
||||
func update(url: String) {
|
||||
if self.url != url {
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Tag: Managed {
|
||||
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||
[NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)]
|
||||
}
|
||||
}
|
||||
|
||||
public extension Tag {
|
||||
static func predicate(name: String) -> NSPredicate {
|
||||
NSPredicate(format: "%K == %@", #keyPath(Tag.name), name)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import Foundation
|
||||
import CoreData
|
||||
|
||||
public protocol Managed: class, NSFetchRequestResult {
|
||||
public protocol Managed: AnyObject, NSFetchRequestResult {
|
||||
static var entityName: String { get }
|
||||
static var defaultSortDescriptors: [NSSortDescriptor] { get }
|
||||
}
|
||||
|
|
|
@ -5,4 +5,16 @@ Mastodon localization template file
|
|||
|
||||
## How to contribute?
|
||||
|
||||
TBD
|
||||
TBD
|
||||
|
||||
## How to maintains
|
||||
|
||||
```zsh
|
||||
// enter workdir
|
||||
cd Mastodon
|
||||
// edit i18n json
|
||||
open ./Localization/app.json
|
||||
// update resource
|
||||
update_localization.sh
|
||||
|
||||
```
|
|
@ -47,6 +47,7 @@ private func map(language: String) -> String? {
|
|||
case "ja_JP": return "ja"
|
||||
case "de_DE": return "de"
|
||||
case "pt_BR": return "pt-BR"
|
||||
case "ar_SA": return "ar"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,10 @@ mkdir -p input/en_US
|
|||
cp ../app.json ./input/en_US
|
||||
cp ../ios-infoPlist.json ./input/en_US
|
||||
|
||||
mkdir -p input/ar_SA
|
||||
cp ../app.json ./input/ar_SA
|
||||
cp ../ios-infoPlist.json ./input/ar_SA
|
||||
|
||||
# curl -o <TBD>.zip -L ${Crowin_Latest_Build}
|
||||
# unzip -o -q <TBD>.zip -d input
|
||||
# rm -rf <TBD>.zip
|
||||
|
|
|
@ -1,13 +1,45 @@
|
|||
{
|
||||
"common": {
|
||||
"alerts": {
|
||||
"common": {
|
||||
"please_try_again": "Please try again.",
|
||||
"please_try_again_later": "Please try again later."
|
||||
},
|
||||
"sign_up_failure": {
|
||||
"title": "Sign Up Failure"
|
||||
},
|
||||
"server_error": {
|
||||
"title": "Server Error"
|
||||
},
|
||||
"vote_failure": {
|
||||
"title": "Vote Failure",
|
||||
"poll_expired": "The poll has expired"
|
||||
},
|
||||
"discard_post_content": {
|
||||
"title": "Discard Publish",
|
||||
"message": "Confirm discard composed post content."
|
||||
},
|
||||
"publish_post_failure": {
|
||||
"title": "Publish Failure",
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection."
|
||||
},
|
||||
"sign_out": {
|
||||
"title": "Sign out",
|
||||
"message": "Are you sure you want to sign out?",
|
||||
"confirm": "Sign Out"
|
||||
},
|
||||
"block_domain": {
|
||||
"title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
|
||||
"block_entire_domain": "Block entire domain"
|
||||
},
|
||||
"save_photo_failure": {
|
||||
"title": "Save Photo Failure",
|
||||
"message": "Please enable photo libaray access permission to save photo."
|
||||
},
|
||||
"delete_post": {
|
||||
"title": "Are you sure you want to delete this post?",
|
||||
"delete": "Delete"
|
||||
}
|
||||
|
||||
},
|
||||
"controls": {
|
||||
"actions": {
|
||||
|
@ -17,25 +49,111 @@
|
|||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"ok": "OK",
|
||||
"done": "Done",
|
||||
"confirm": "Confirm",
|
||||
"continue": "Continue",
|
||||
"cancel": "Cancel",
|
||||
"discard": "Discard",
|
||||
"try_again": "Try Again",
|
||||
"take_photo": "Take photo",
|
||||
"save_photo": "Save photo",
|
||||
"sign_in": "Sign In",
|
||||
"sign_up": "Sign Up",
|
||||
"see_more": "See More",
|
||||
"preview": "Preview",
|
||||
"open_in_safari": "Open in Safari"
|
||||
"share": "Share",
|
||||
"share_user": "Share %s",
|
||||
"share_post": "Share post",
|
||||
"open_in_safari": "Open in Safari",
|
||||
"find_people": "Find people to follow",
|
||||
"manually_search": "Manually search instead",
|
||||
"skip": "Skip",
|
||||
"report_user": "Report %s",
|
||||
"block_domain": "Block %s",
|
||||
"unblock_domain": "Unblock %s",
|
||||
"settings": "Settings",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Home",
|
||||
"search": "Search",
|
||||
"notification": "Notification",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"status": {
|
||||
"user_boosted": "%s boosted",
|
||||
"user_reblogged": "%s reblogged",
|
||||
"user_replied_to": "Replied to %s",
|
||||
"show_post": "Show Post",
|
||||
"status_content_warning": "content warning",
|
||||
"media_content_warning": "Tap to reveal that may be sensitive"
|
||||
"show_user_profile": "Show user profile",
|
||||
"content_warning": "content warning",
|
||||
"content_warning_text": "cw: %s",
|
||||
"media_content_warning": "Tap to reveal that may be sensitive",
|
||||
"poll": {
|
||||
"vote": "Vote",
|
||||
"vote_count": {
|
||||
"single": "%d vote",
|
||||
"multiple": "%d votes"
|
||||
},
|
||||
"voter_count": {
|
||||
"single": "%d voter",
|
||||
"multiple": "%d voters"
|
||||
},
|
||||
"time_left": "%s left",
|
||||
"closed": "Closed"
|
||||
},
|
||||
"actions": {
|
||||
"reply": "Reply",
|
||||
"reblog": "Reblog",
|
||||
"unreblog": "Unreblog",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"tag": {
|
||||
"url": "URL",
|
||||
"mention": "Mention",
|
||||
"link": "Link",
|
||||
"hashtag": "Hashtag",
|
||||
"email": "Email",
|
||||
"emoji": "Emoji"
|
||||
}
|
||||
},
|
||||
"firendship": {
|
||||
"follow": "Follow",
|
||||
"following": "Following",
|
||||
"request": "Request",
|
||||
"pending": "Pending",
|
||||
"block": "Block",
|
||||
"block_user": "Block %s",
|
||||
"block_domain": "Block %s",
|
||||
"unblock": "Unblock",
|
||||
"unblock_user": "Unblock %s",
|
||||
"blocked": "Blocked",
|
||||
"mute": "Mute",
|
||||
"mute_user": "Mute %s",
|
||||
"unmute": "Unmute",
|
||||
"unmute_user": "Unmute %s",
|
||||
"muted": "Muted",
|
||||
"edit_info": "Edit info"
|
||||
},
|
||||
"timeline": {
|
||||
"load_more": "Load More"
|
||||
"loader": {
|
||||
"load_missing_posts": "Load missing posts",
|
||||
"loading_missing_posts": "Loading missing posts...",
|
||||
"show_more_replies": "Show more replies"
|
||||
},
|
||||
"header": {
|
||||
"no_status_found": "No Status Found",
|
||||
"blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.",
|
||||
"blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.",
|
||||
"suspended_warning": "This account has been suspended.",
|
||||
"user_suspended_warning": "%s's account has been suspended."
|
||||
},
|
||||
"accessibility": {
|
||||
"count_replies": "%s replies",
|
||||
"count_reblogs": "%s reblogs",
|
||||
"count_favorites": "%s favorites"
|
||||
}
|
||||
}
|
||||
},
|
||||
"countable": {
|
||||
|
@ -51,25 +169,46 @@
|
|||
},
|
||||
"server_picker": {
|
||||
"title": "Pick a Server,\nany server.",
|
||||
"Button": {
|
||||
"Category": {
|
||||
"All": "All"
|
||||
"button": {
|
||||
"category": {
|
||||
"all": "All",
|
||||
"all_accessiblity_description": "Category: All",
|
||||
"academia": "academia",
|
||||
"activism": "activism",
|
||||
"food": "food",
|
||||
"furry": "furry",
|
||||
"games": "games",
|
||||
"general": "general",
|
||||
"journalism": "journalism",
|
||||
"lgbt": "lgbt",
|
||||
"regional": "regional",
|
||||
"art": "art",
|
||||
"music": "music",
|
||||
"tech": "tech"
|
||||
},
|
||||
"SeeLess": "See Less",
|
||||
"SeeMore": "See More"
|
||||
"see_less": "See Less",
|
||||
"see_more": "See More"
|
||||
},
|
||||
"Label": {
|
||||
"Language": "LANGUAGE",
|
||||
"Users": "USERS",
|
||||
"Category": "CATEGORY"
|
||||
"label": {
|
||||
"language": "LANGUAGE",
|
||||
"users": "USERS",
|
||||
"category": "CATEGORY"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Find a server or join your own..."
|
||||
},
|
||||
"empty_state": {
|
||||
"finding_servers": "Finding available servers...",
|
||||
"bad_network": "Something went wrong while loading data. Check your internet connection.",
|
||||
"no_results": "No results"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "Tell us about you.",
|
||||
"input": {
|
||||
"avatar": {
|
||||
"delete": "Delete"
|
||||
},
|
||||
"username": {
|
||||
"placeholder": "username",
|
||||
"duplicate_prompt": "This username is taken."
|
||||
|
@ -82,27 +221,54 @@
|
|||
},
|
||||
"password": {
|
||||
"placeholder": "password",
|
||||
"prompt": "Your password needs at least:",
|
||||
"prompt_eight_characters": "Eight characters"
|
||||
"hint": "Your password needs at least eight characters"
|
||||
},
|
||||
"invite": {
|
||||
"registration_user_invite_request": "Why do you want to join?"
|
||||
"registration_user_invite_request": "Why do you want to join?"
|
||||
}
|
||||
},
|
||||
"success": "Success",
|
||||
"check_email": "Regsiter request sent. Please check your email."
|
||||
"error": {
|
||||
"item": {
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"agreement": "Agreement",
|
||||
"locale": "Locale",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"reason": {
|
||||
"blocked": "%s contains a disallowed e-mail provider",
|
||||
"unreachable": "%s does not seem to exist",
|
||||
"taken": "%s is already in use",
|
||||
"reserved": "%s is a reserved keyword",
|
||||
"accepted": "%s must be accepted",
|
||||
"blank": "%s is required",
|
||||
"invalid": "%s is invalid",
|
||||
"too_long": "%s is too long",
|
||||
"too_short": "%s is too short",
|
||||
"inclusion": "%s is not a supported value"
|
||||
},
|
||||
"special": {
|
||||
"username_invalid": "Username must only contain alphanumeric characters and underscores",
|
||||
"username_too_long": "Username is too long (can't be longer than 30 characters)",
|
||||
"email_invalid": "This is not a valid e-mail address",
|
||||
"password_too_short": "Password is too short (must be at least 8 characters)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server_rules": {
|
||||
"title": "Some ground rules.",
|
||||
"subtitle": "These rules are set by the admins of %s.",
|
||||
"prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.",
|
||||
"terms_of_service": "terms of service",
|
||||
"privacy_policy": "privacy policy",
|
||||
"button": {
|
||||
"confirm": "I Agree"
|
||||
}
|
||||
},
|
||||
"confirm_email": {
|
||||
"title": "One last thing.",
|
||||
"subtitle": "We just sent an email to %@,\ntap the link to confirm your account.",
|
||||
"subtitle": "We just sent an email to %s,\ntap the link to confirm your account.",
|
||||
"button": {
|
||||
"open_email_app": "Open Email App",
|
||||
"dont_receive_email": "I never got an email"
|
||||
|
@ -120,10 +286,204 @@
|
|||
}
|
||||
},
|
||||
"home_timeline": {
|
||||
"title": "Home"
|
||||
"title": "Home",
|
||||
"navigation_bar_state": {
|
||||
"offline": "Offline",
|
||||
"new_posts": "See new posts",
|
||||
"published": "Published!",
|
||||
"Publishing": "Publishing post..."
|
||||
}
|
||||
},
|
||||
"suggestion_account": {
|
||||
"title": "Find People to Follow",
|
||||
"follow_explain": "When you follow someone, you’ll see their posts in your home feed."
|
||||
},
|
||||
"public_timeline": {
|
||||
"title": "Public"
|
||||
},
|
||||
"compose": {
|
||||
"title": {
|
||||
"new_post": "New Post",
|
||||
"new_reply": "New Reply"
|
||||
},
|
||||
"media_selection": {
|
||||
"camera": "Take Photo",
|
||||
"photo_library": "Photo Library",
|
||||
"browse": "Browse"
|
||||
},
|
||||
"content_input_placeholder": "Type or paste what's on your mind",
|
||||
"compose_action": "Publish",
|
||||
"replying_to_user": "replying to %s",
|
||||
"attachment": {
|
||||
"photo": "photo",
|
||||
"video": "video",
|
||||
"attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.",
|
||||
"description_photo": "Describe photo for low vision people...",
|
||||
"description_video": "Describe what’s happening for low vision people..."
|
||||
},
|
||||
"poll": {
|
||||
"duration_time": "Duration: %s",
|
||||
"thirty_minutes": "30 minutes",
|
||||
"one_hour": "1 Hour",
|
||||
"six_hours": "6 Hours",
|
||||
"one_day": "1 Day",
|
||||
"three_days": "3 Days",
|
||||
"seven_days": "7 Days",
|
||||
"option_number": "Option %ld"
|
||||
},
|
||||
"content_warning": {
|
||||
"placeholder": "Write an accurate warning here..."
|
||||
},
|
||||
"visibility": {
|
||||
"public": "Public",
|
||||
"unlisted": "Unlisted",
|
||||
"private": "Followers only",
|
||||
"direct": "Only people I mention"
|
||||
},
|
||||
"accessibility": {
|
||||
"append_attachment": "Append attachment",
|
||||
"append_poll": "Append poll",
|
||||
"remove_poll": "Remove poll",
|
||||
"custom_emoji_picker": "Custom emoji picker",
|
||||
"enable_content_warning": "Enable content warning",
|
||||
"disable_content_warning": "Disable content warning",
|
||||
"post_visibility_menu": "Post visibility menu",
|
||||
"input_limit_remains_count": "Input limit remains %ld",
|
||||
"input_limit_exceeds_count": "Input limit exceeds %ld"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"subtitle": "%s posts",
|
||||
"dashboard": {
|
||||
"posts": "posts",
|
||||
"following": "following",
|
||||
"followers": "followers",
|
||||
"accessibility": {
|
||||
"count_posts": "%ld posts",
|
||||
"count_following": "%ld following",
|
||||
"count_followers": "%ld followers"
|
||||
}
|
||||
},
|
||||
"segmented_control": {
|
||||
"posts": "Posts",
|
||||
"replies": "Replies",
|
||||
"media": "Media"
|
||||
},
|
||||
"relationship_action_alert": {
|
||||
"confirm_unmute_user": {
|
||||
"title": "Unmute Account",
|
||||
"message": "Confirm unmute %s"
|
||||
},
|
||||
"confirm_unblock_usre": {
|
||||
"title": "Unblock Account",
|
||||
"message": "Confirm unblock %s"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"searchBar": {
|
||||
"placeholder": "Search hashtags and users",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"recommend": {
|
||||
"button_text": "See All",
|
||||
"hash_tag": {
|
||||
"title": "Trending in your timeline",
|
||||
"description": "Hashtags that are getting quite a bit of attention among people you follow",
|
||||
"people_talking": "%s people are talking"
|
||||
},
|
||||
"accounts": {
|
||||
"title": "Accounts you might like",
|
||||
"description": "You may like to follow these accounts",
|
||||
"follow": "Follow"
|
||||
}
|
||||
},
|
||||
"searching": {
|
||||
"segment": {
|
||||
"all": "All",
|
||||
"people": "People",
|
||||
"hashtags": "Hashtags"
|
||||
},
|
||||
"recent_search": "Recent searches",
|
||||
"clear": "clear"
|
||||
}
|
||||
},
|
||||
"hashtag": {
|
||||
"prompt": "%s people talking"
|
||||
},
|
||||
"favorite": {
|
||||
"title": "Your Favorites"
|
||||
},
|
||||
"notification": {
|
||||
"title": {
|
||||
"Everything": "Everything",
|
||||
"Mentions": "Mentions"
|
||||
},
|
||||
"action": {
|
||||
"follow": "followed you",
|
||||
"favourite": "favorited your post",
|
||||
"reblog": "rebloged your post",
|
||||
"poll": "Your poll has ended",
|
||||
"mention": "mentioned you",
|
||||
"follow_request": "request to follow you"
|
||||
}
|
||||
},
|
||||
"thread": {
|
||||
"back_title": "Post",
|
||||
"title": "Post from %s",
|
||||
"reblog": {
|
||||
"single": "%s reblog",
|
||||
"multiple": "%s reblogs"
|
||||
},
|
||||
"favorite": {
|
||||
"single": "%s favorite",
|
||||
"multiple": "%s favorites"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"section": {
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"automatic": "Automatic",
|
||||
"light": "Always Light",
|
||||
"dark": "Always Dark"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"favorites": "Favorites my post",
|
||||
"follows": "Follows me",
|
||||
"boosts": "Reblogs my post",
|
||||
"mentions": "Mentions me",
|
||||
"trigger": {
|
||||
"anyone": "anyone",
|
||||
"follower": "a follower",
|
||||
"follow": "anyone I follow",
|
||||
"noone": "no one",
|
||||
"title": "Notify me when"
|
||||
}
|
||||
},
|
||||
"boringzone": {
|
||||
"title": "The Boring zone",
|
||||
"terms": "Terms of Service",
|
||||
"privacy": "Privacy Policy"
|
||||
},
|
||||
"spicyzone": {
|
||||
"title": "The spicy zone",
|
||||
"clear": "Clear Media Cache",
|
||||
"signout": "Sign Out"
|
||||
}
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"title": "Report %s",
|
||||
"step1": "Step 1 of 2",
|
||||
"step2": "Step 2 of 2",
|
||||
"content1": "Are there any other posts you’d like to add to the report?",
|
||||
"content2": "Is there anything the moderators should know about this report?",
|
||||
"send": "Send Report",
|
||||
"skip_to_send": "Send without comment",
|
||||
"text_placeholder": "Type or paste additional comments"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"NSCameraUsageDescription": "Used to take photo for toot",
|
||||
"NSCameraUsageDescription": "Used to take photo for post status",
|
||||
"NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library"
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -4,49 +4,38 @@
|
|||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>AppShared.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>18</integer>
|
||||
</dict>
|
||||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>10</integer>
|
||||
<integer>14</integer>
|
||||
</dict>
|
||||
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>9</integer>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>Mastodon - Release.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>Mastodon.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>15</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>DB427DD125BAA00100D1B89D</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>DB427DE725BAA00100D1B89D</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>DB427DF225BAA00100D1B89D</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>DB89B9F525C10FD0008580ED</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
"repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a",
|
||||
"version": "4.0.0"
|
||||
"revision": "2132a7bf8da2bea74bb49b0b815950ea4d8f6a7e",
|
||||
"version": "5.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -15,8 +15,8 @@
|
|||
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "eaf6e622dd41b07b251d8f01752eab31bc811493",
|
||||
"version": "5.4.1"
|
||||
"revision": "4d19ad82f80cc71ff829b941ded114c56f4f604c",
|
||||
"version": "5.4.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -24,8 +24,8 @@
|
|||
"repositoryURL": "https://github.com/Alamofire/AlamofireImage.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f",
|
||||
"version": "4.1.0"
|
||||
"revision": "98cbb00ce0ec5fc8e52a5b50a6bfc08d3e5aee10",
|
||||
"version": "4.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -37,6 +37,15 @@
|
|||
"version": "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Base85",
|
||||
"repositoryURL": "https://github.com/MainasuK/Base85.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "626be96816618689627f806b5c875b5adb6346ef",
|
||||
"version": "1.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "CommonOSLog",
|
||||
"repositoryURL": "https://github.com/MainasuK/CommonOSLog",
|
||||
|
@ -46,13 +55,31 @@
|
|||
"version": "0.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "KeychainAccess",
|
||||
"repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||
"version": "4.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Kingfisher",
|
||||
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "daebf8ddf974164d1b9a050c8231e263f3106b09",
|
||||
"version": "6.1.0"
|
||||
"revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e",
|
||||
"version": "6.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Pageboy",
|
||||
"repositoryURL": "https://github.com/uias/Pageboy",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6",
|
||||
"version": "3.6.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -78,8 +105,17 @@
|
|||
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2b6054efa051565954e1d2b9da831680026cd768",
|
||||
"version": "5.0.0"
|
||||
"revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
|
||||
"version": "5.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Tabman",
|
||||
"repositoryURL": "https://github.com/uias/Tabman",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "f43489cdd743ba7ad86a422ebb5fcbf34e333df4",
|
||||
"version": "2.11.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -90,6 +126,33 @@
|
|||
"revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84",
|
||||
"version": "1.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "TOCropViewController",
|
||||
"repositoryURL": "https://github.com/TimOliver/TOCropViewController.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "dad97167bf1be16aeecd109130900995dd01c515",
|
||||
"version": "2.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "TwitterTextEditor",
|
||||
"repositoryURL": "https://github.com/twitter/TwitterTextEditor",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "dfe0edc3bcb6703ee2fd0e627f95e726b63e732a",
|
||||
"version": "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "UITextView+Placeholder",
|
||||
"repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "20f513ded04a040cdf5467f0891849b1763ede3b",
|
||||
"version": "1.4.1"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// SafariActivity.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-8.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SafariServices
|
||||
|
||||
final class SafariActivity: UIActivity {
|
||||
|
||||
weak var sceneCoordinator: SceneCoordinator?
|
||||
var url: NSURL?
|
||||
|
||||
init(sceneCoordinator: SceneCoordinator) {
|
||||
self.sceneCoordinator = sceneCoordinator
|
||||
}
|
||||
|
||||
override var activityType: UIActivity.ActivityType? {
|
||||
return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity")
|
||||
}
|
||||
|
||||
override var activityTitle: String? {
|
||||
return L10n.Common.Controls.Actions.openInSafari
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
return UIImage(systemName: "safari")
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
for item in activityItems {
|
||||
guard let _ = item as? NSURL, sceneCoordinator != nil else { continue }
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
for item in activityItems {
|
||||
guard let url = item as? NSURL else { continue }
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
override var activityViewController: UIViewController? {
|
||||
return nil
|
||||
}
|
||||
|
||||
override func perform() {
|
||||
guard let url = url else {
|
||||
activityDidFinish(false)
|
||||
return
|
||||
}
|
||||
|
||||
sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil))
|
||||
activityDidFinish(true)
|
||||
}
|
||||
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
protocol NeedsDependency: class {
|
||||
protocol NeedsDependency: AnyObject {
|
||||
var context: AppContext! { get set }
|
||||
var coordinator: SceneCoordinator! { get set }
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ final public class SceneCoordinator {
|
|||
private weak var scene: UIScene!
|
||||
private weak var sceneDelegate: SceneDelegate!
|
||||
private weak var appContext: AppContext!
|
||||
private weak var tabBarController: MainTabBarController!
|
||||
|
||||
let id = UUID().uuidString
|
||||
|
||||
|
@ -33,8 +34,8 @@ extension SceneCoordinator {
|
|||
case custom(transitioningDelegate: UIViewControllerTransitioningDelegate)
|
||||
case customPush
|
||||
case safariPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||
case alertController(animated: Bool, completion: (() -> Void)? = nil)
|
||||
case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil)
|
||||
}
|
||||
|
||||
enum Scene {
|
||||
|
@ -46,10 +47,37 @@ extension SceneCoordinator {
|
|||
case mastodonServerRules(viewModel: MastodonServerRulesViewModel)
|
||||
case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel)
|
||||
case mastodonResendEmail(viewModel: MastodonResendEmailViewModel)
|
||||
case mastodonWebView(viewModel:WebViewModel)
|
||||
|
||||
// compose
|
||||
case compose(viewModel: ComposeViewModel)
|
||||
|
||||
// thread
|
||||
case thread(viewModel: ThreadViewModel)
|
||||
|
||||
// Hashtag Timeline
|
||||
case hashtagTimeline(viewModel: HashtagTimelineViewModel)
|
||||
|
||||
// profile
|
||||
case profile(viewModel: ProfileViewModel)
|
||||
case favorite(viewModel: FavoriteViewModel)
|
||||
|
||||
// setting
|
||||
case settings(viewModel: SettingsViewModel)
|
||||
|
||||
// report
|
||||
case report(viewModel: ReportViewModel)
|
||||
|
||||
// suggestion account
|
||||
case suggestionAccount(viewModel: SuggestionAccountViewModel)
|
||||
|
||||
// media preview
|
||||
case mediaPreview(viewModel: MediaPreviewViewModel)
|
||||
|
||||
// misc
|
||||
case safari(url: URL)
|
||||
case alertController(alertController: UIAlertController)
|
||||
|
||||
case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?)
|
||||
#if DEBUG
|
||||
case publicTimeline
|
||||
#endif
|
||||
|
@ -76,13 +104,14 @@ extension SceneCoordinator {
|
|||
func setup() {
|
||||
let viewController = MainTabBarController(context: appContext, coordinator: self)
|
||||
sceneDelegate.window?.rootViewController = viewController
|
||||
tabBarController = viewController
|
||||
}
|
||||
|
||||
func setupOnboardingIfNeeds(animated: Bool) {
|
||||
// Check user authentication status and show onboarding if needs
|
||||
do {
|
||||
let request = MastodonAuthentication.sortedFetchRequest
|
||||
if try appContext.managedObjectContext.fetch(request).isEmpty {
|
||||
if try appContext.managedObjectContext.count(for: request) == 0 {
|
||||
DispatchQueue.main.async {
|
||||
self.present(
|
||||
scene: .welcome,
|
||||
|
@ -104,6 +133,17 @@ extension SceneCoordinator {
|
|||
guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else {
|
||||
return nil
|
||||
}
|
||||
// adapt for child controller
|
||||
if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController {
|
||||
switch viewController {
|
||||
case is ProfileViewController:
|
||||
let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.navigationItem.title, style: .plain, target: nil, action: nil)
|
||||
barButtonItem.tintColor = .white
|
||||
navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem
|
||||
default:
|
||||
navigationControllerVisibleViewController.navigationItem.backBarButtonItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
if let mainTabBarController = presentingViewController as? MainTabBarController,
|
||||
let navigationController = mainTabBarController.selectedViewController as? UINavigationController,
|
||||
|
@ -116,17 +156,18 @@ extension SceneCoordinator {
|
|||
presentingViewController.show(viewController, sender: sender)
|
||||
|
||||
case .showDetail:
|
||||
let navigationController = UINavigationController(rootViewController: viewController)
|
||||
let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
|
||||
presentingViewController.showDetailViewController(navigationController, sender: sender)
|
||||
|
||||
case .modal(let animated, let completion):
|
||||
let modalNavigationController: UINavigationController = {
|
||||
if scene.isOnboarding {
|
||||
return DarkContentStatusBarStyleNavigationController(rootViewController: viewController)
|
||||
return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController)
|
||||
} else {
|
||||
return UINavigationController(rootViewController: viewController)
|
||||
}
|
||||
}()
|
||||
modalNavigationController.modalPresentationCapturesStatusBarAppearance = true
|
||||
if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate {
|
||||
modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate
|
||||
}
|
||||
|
@ -143,18 +184,24 @@ extension SceneCoordinator {
|
|||
sender?.navigationController?.pushViewController(viewController, animated: true)
|
||||
|
||||
case .safariPresent(let animated, let completion):
|
||||
presentingViewController.present(viewController, animated: animated, completion: completion)
|
||||
|
||||
case .activityViewControllerPresent(let animated, let completion):
|
||||
viewController.modalPresentationCapturesStatusBarAppearance = true
|
||||
presentingViewController.present(viewController, animated: animated, completion: completion)
|
||||
|
||||
case .alertController(let animated, let completion):
|
||||
viewController.modalPresentationCapturesStatusBarAppearance = true
|
||||
presentingViewController.present(viewController, animated: animated, completion: completion)
|
||||
|
||||
case .activityViewControllerPresent(let animated, let completion):
|
||||
viewController.modalPresentationCapturesStatusBarAppearance = true
|
||||
presentingViewController.present(viewController, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
return viewController
|
||||
}
|
||||
|
||||
func switchToTabBar(tab: MainTabBarController.Tab) {
|
||||
tabBarController.selectedIndex = tab.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private extension SceneCoordinator {
|
||||
|
@ -190,6 +237,48 @@ private extension SceneCoordinator {
|
|||
let _viewController = MastodonResendEmailViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .mastodonWebView(let viewModel):
|
||||
let _viewController = WebViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .compose(let viewModel):
|
||||
let _viewController = ComposeViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .thread(let viewModel):
|
||||
let _viewController = ThreadViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .hashtagTimeline(let viewModel):
|
||||
let _viewController = HashtagTimelineViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .profile(let viewModel):
|
||||
let _viewController = ProfileViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .favorite(let viewModel):
|
||||
let _viewController = FavoriteViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .settings(let viewModel):
|
||||
let _viewController = SettingsViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .suggestionAccount(let viewModel):
|
||||
let _viewController = SuggestionAccountViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .mediaPreview(let viewModel):
|
||||
let _viewController = MediaPreviewViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .safari(let url):
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return nil
|
||||
}
|
||||
viewController = SFSafariViewController(url: url)
|
||||
case .alertController(let alertController):
|
||||
if let popoverPresentationController = alertController.popoverPresentationController {
|
||||
assert(
|
||||
|
@ -199,6 +288,18 @@ private extension SceneCoordinator {
|
|||
)
|
||||
}
|
||||
viewController = alertController
|
||||
case .activityViewController(let activityViewController, let sourceView, let barButtonItem):
|
||||
activityViewController.popoverPresentationController?.sourceView = sourceView
|
||||
activityViewController.popoverPresentationController?.barButtonItem = barButtonItem
|
||||
viewController = activityViewController
|
||||
case .settings(let viewModel):
|
||||
let _viewController = SettingsViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
case .report(let viewModel):
|
||||
let _viewController = ReportViewController()
|
||||
_viewController.viewModel = viewModel
|
||||
viewController = _viewController
|
||||
#if DEBUG
|
||||
case .publicTimeline:
|
||||
let _viewController = PublicTimelineViewController()
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// SettingFetchedResultController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-25.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class SettingFetchedResultController: NSObject {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let fetchedResultsController: NSFetchedResultsController<Setting>
|
||||
|
||||
// input
|
||||
|
||||
// output
|
||||
let settings = CurrentValueSubject<[Setting], Never>([])
|
||||
|
||||
init(managedObjectContext: NSManagedObjectContext, additionalPredicate: NSPredicate?) {
|
||||
self.fetchedResultsController = {
|
||||
let fetchRequest = Setting.sortedFetchRequest
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
if let additionalPredicate = additionalPredicate {
|
||||
fetchRequest.predicate = additionalPredicate
|
||||
}
|
||||
fetchRequest.fetchBatchSize = 20
|
||||
let controller = NSFetchedResultsController(
|
||||
fetchRequest: fetchRequest,
|
||||
managedObjectContext: managedObjectContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
return controller
|
||||
}()
|
||||
super.init()
|
||||
|
||||
fetchedResultsController.delegate = self
|
||||
|
||||
do {
|
||||
try self.fetchedResultsController.performFetch()
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - NSFetchedResultsControllerDelegate
|
||||
extension SettingFetchedResultController: NSFetchedResultsControllerDelegate {
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
let objects = fetchedResultsController.fetchedObjects ?? []
|
||||
self.settings.value = objects
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
// StatusFetchedResultsController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-30.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class StatusFetchedResultsController: NSObject {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let fetchedResultsController: NSFetchedResultsController<Status>
|
||||
|
||||
// input
|
||||
let domain = CurrentValueSubject<String?, Never>(nil)
|
||||
let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([])
|
||||
|
||||
// output
|
||||
let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([])
|
||||
|
||||
init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) {
|
||||
self.domain.value = domain ?? ""
|
||||
self.fetchedResultsController = {
|
||||
let fetchRequest = Status.sortedFetchRequest
|
||||
fetchRequest.predicate = Status.predicate(domain: domain ?? "", ids: [])
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.fetchBatchSize = 20
|
||||
let controller = NSFetchedResultsController(
|
||||
fetchRequest: fetchRequest,
|
||||
managedObjectContext: managedObjectContext,
|
||||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
return controller
|
||||
}()
|
||||
super.init()
|
||||
|
||||
fetchedResultsController.delegate = self
|
||||
|
||||
Publishers.CombineLatest(
|
||||
self.domain.removeDuplicates().eraseToAnyPublisher(),
|
||||
self.statusIDs.removeDuplicates().eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] domain, ids in
|
||||
guard let self = self else { return }
|
||||
var predicates = [Status.predicate(domain: domain ?? "", ids: ids)]
|
||||
if let additionalPredicate = additionalTweetPredicate {
|
||||
predicates.append(additionalPredicate)
|
||||
}
|
||||
self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
do {
|
||||
try self.fetchedResultsController.performFetch()
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - NSFetchedResultsControllerDelegate
|
||||
extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate {
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
let indexes = statusIDs.value
|
||||
let objects = fetchedResultsController.fetchedObjects ?? []
|
||||
|
||||
let items: [NSManagedObjectID] = objects
|
||||
.compactMap { object in
|
||||
indexes.firstIndex(of: object.id).map { index in (index, object) }
|
||||
}
|
||||
.sorted { $0.0 < $1.0 }
|
||||
.map { $0.1.objectID }
|
||||
self.objectIDs.value = items
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
//
|
||||
// CategoryPickerItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
/// Note: update Equatable when change case
|
||||
enum CategoryPickerItem {
|
||||
case all
|
||||
case category(category: Mastodon.Entity.Category)
|
||||
}
|
||||
|
||||
extension CategoryPickerItem {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return L10n.Scene.ServerPicker.Button.Category.all
|
||||
case .category(let category):
|
||||
switch category.category {
|
||||
case .academia:
|
||||
return "📚"
|
||||
case .activism:
|
||||
return "✊"
|
||||
case .food:
|
||||
return "🍕"
|
||||
case .furry:
|
||||
return "🦁"
|
||||
case .games:
|
||||
return "🕹"
|
||||
case .general:
|
||||
return "💬"
|
||||
case .journalism:
|
||||
return "📰"
|
||||
case .lgbt:
|
||||
return "🏳️🌈"
|
||||
case .regional:
|
||||
return "📍"
|
||||
case .art:
|
||||
return "🎨"
|
||||
case .music:
|
||||
return "🎼"
|
||||
case .tech:
|
||||
return "📱"
|
||||
case ._other:
|
||||
return "❓"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return L10n.Scene.ServerPicker.Button.Category.allAccessiblityDescription
|
||||
case .category(let category):
|
||||
switch category.category {
|
||||
case .academia:
|
||||
return L10n.Scene.ServerPicker.Button.Category.academia
|
||||
case .activism:
|
||||
return L10n.Scene.ServerPicker.Button.Category.activism
|
||||
case .food:
|
||||
return L10n.Scene.ServerPicker.Button.Category.food
|
||||
case .furry:
|
||||
return L10n.Scene.ServerPicker.Button.Category.furry
|
||||
case .games:
|
||||
return L10n.Scene.ServerPicker.Button.Category.games
|
||||
case .general:
|
||||
return L10n.Scene.ServerPicker.Button.Category.general
|
||||
case .journalism:
|
||||
return L10n.Scene.ServerPicker.Button.Category.journalism
|
||||
case .lgbt:
|
||||
return L10n.Scene.ServerPicker.Button.Category.lgbt
|
||||
case .regional:
|
||||
return L10n.Scene.ServerPicker.Button.Category.regional
|
||||
case .art:
|
||||
return L10n.Scene.ServerPicker.Button.Category.art
|
||||
case .music:
|
||||
return L10n.Scene.ServerPicker.Button.Category.music
|
||||
case .tech:
|
||||
return L10n.Scene.ServerPicker.Button.Category.tech
|
||||
case ._other:
|
||||
return "❓" // FIXME:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CategoryPickerItem: Equatable {
|
||||
static func == (lhs: CategoryPickerItem, rhs: CategoryPickerItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.all, .all):
|
||||
return true
|
||||
case (.category(let categoryLeft), .category(let categoryRight)):
|
||||
return categoryLeft.category.rawValue == categoryRight.category.rawValue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CategoryPickerItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .all:
|
||||
hasher.combine(String(describing: CategoryPickerItem.all.self))
|
||||
case .category(let category):
|
||||
hasher.combine(category.category.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
//
|
||||
// ComposeStatusItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
|
||||
/// Note: update Equatable when change case
|
||||
enum ComposeStatusItem {
|
||||
case replyTo(statusObjectID: NSManagedObjectID)
|
||||
case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute)
|
||||
case attachment(attachmentService: MastodonAttachmentService)
|
||||
case pollOption(attribute: ComposePollOptionAttribute)
|
||||
case pollOptionAppendEntry
|
||||
case pollExpiresOption(attribute: ComposePollExpiresOptionAttribute)
|
||||
}
|
||||
|
||||
extension ComposeStatusItem: Equatable { }
|
||||
|
||||
extension ComposeStatusItem: Hashable { }
|
||||
|
||||
extension ComposeStatusItem {
|
||||
final class ComposeStatusAttribute: Equatable, Hashable {
|
||||
private let id = UUID()
|
||||
|
||||
let avatarURL = CurrentValueSubject<URL?, Never>(nil)
|
||||
let displayName = CurrentValueSubject<String?, Never>(nil)
|
||||
let username = CurrentValueSubject<String?, Never>(nil)
|
||||
let composeContent = CurrentValueSubject<String?, Never>(nil)
|
||||
|
||||
let isContentWarningComposing = CurrentValueSubject<Bool, Never>(false)
|
||||
let contentWarningContent = CurrentValueSubject<String, Never>("")
|
||||
|
||||
static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool {
|
||||
return lhs.avatarURL.value == rhs.avatarURL.value &&
|
||||
lhs.displayName.value == rhs.displayName.value &&
|
||||
lhs.username.value == rhs.username.value &&
|
||||
lhs.composeContent.value == rhs.composeContent.value &&
|
||||
lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value &&
|
||||
lhs.contentWarningContent.value == rhs.contentWarningContent.value
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol ComposePollAttributeDelegate: AnyObject {
|
||||
func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?)
|
||||
}
|
||||
|
||||
extension ComposeStatusItem {
|
||||
final class ComposePollOptionAttribute: Equatable, Hashable {
|
||||
private let id = UUID()
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
weak var delegate: ComposePollAttributeDelegate?
|
||||
|
||||
let option = CurrentValueSubject<String, Never>("")
|
||||
|
||||
init() {
|
||||
option
|
||||
.sink { [weak self] option in
|
||||
guard let self = self else { return }
|
||||
self.delegate?.composePollAttribute(self, pollOptionDidChange: option)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
deinit {
|
||||
disposeBag.removeAll()
|
||||
}
|
||||
|
||||
static func == (lhs: ComposePollOptionAttribute, rhs: ComposePollOptionAttribute) -> Bool {
|
||||
return lhs.id == rhs.id &&
|
||||
lhs.option.value == rhs.option.value
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeStatusItem {
|
||||
final class ComposePollExpiresOptionAttribute: Equatable, Hashable {
|
||||
private let id = UUID()
|
||||
|
||||
let expiresOption = CurrentValueSubject<ExpiresOption, Never>(.thirtyMinutes)
|
||||
|
||||
|
||||
static func == (lhs: ComposePollExpiresOptionAttribute, rhs: ComposePollExpiresOptionAttribute) -> Bool {
|
||||
return lhs.id == rhs.id &&
|
||||
lhs.expiresOption.value == rhs.expiresOption.value
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
enum ExpiresOption: Equatable, Hashable, CaseIterable {
|
||||
case thirtyMinutes
|
||||
case oneHour
|
||||
case sixHours
|
||||
case oneDay
|
||||
case threeDays
|
||||
case sevenDays
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes
|
||||
case .oneHour: return L10n.Scene.Compose.Poll.oneHour
|
||||
case .sixHours: return L10n.Scene.Compose.Poll.sixHours
|
||||
case .oneDay: return L10n.Scene.Compose.Poll.oneDay
|
||||
case .threeDays: return L10n.Scene.Compose.Poll.threeDays
|
||||
case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays
|
||||
}
|
||||
}
|
||||
|
||||
var seconds: Int {
|
||||
switch self {
|
||||
case .thirtyMinutes: return 60 * 30
|
||||
case .oneHour: return 60 * 60 * 1
|
||||
case .sixHours: return 60 * 60 * 6
|
||||
case .oneDay: return 60 * 60 * 24
|
||||
case .threeDays: return 60 * 60 * 24 * 3
|
||||
case .sevenDays: return 60 * 60 * 24 * 7
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
//
|
||||
// CustomEmojiPickerItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
enum CustomEmojiPickerItem {
|
||||
case emoji(attribute: CustomEmojiAttribute)
|
||||
}
|
||||
|
||||
extension CustomEmojiPickerItem: Equatable, Hashable { }
|
||||
|
||||
extension CustomEmojiPickerItem {
|
||||
final class CustomEmojiAttribute: Equatable, Hashable {
|
||||
let id = UUID()
|
||||
|
||||
let emoji: Mastodon.Entity.Emoji
|
||||
|
||||
init(emoji: Mastodon.Entity.Emoji) {
|
||||
self.emoji = emoji
|
||||
}
|
||||
|
||||
static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool {
|
||||
return lhs.id == rhs.id &&
|
||||
lhs.emoji.shortcode == rhs.emoji.shortcode
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
// Created by sxiaojian on 2021/1/27.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
|
@ -13,45 +14,82 @@ import MastodonSDK
|
|||
/// Note: update Equatable when change case
|
||||
enum Item {
|
||||
// timeline
|
||||
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute)
|
||||
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute)
|
||||
|
||||
// thread
|
||||
case root(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
|
||||
case reply(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
|
||||
case leaf(statusObjectID: NSManagedObjectID, attribute: StatusAttribute)
|
||||
case leafBottomLoader(statusObjectID: NSManagedObjectID)
|
||||
|
||||
// normal list
|
||||
case toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute)
|
||||
case status(objectID: NSManagedObjectID, attribute: StatusAttribute)
|
||||
|
||||
// loader
|
||||
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
|
||||
case publicMiddleLoader(tootID: String)
|
||||
case publicMiddleLoader(statusID: String)
|
||||
case topLoader
|
||||
case bottomLoader
|
||||
}
|
||||
|
||||
protocol StatusContentWarningAttribute {
|
||||
var isStatusTextSensitive: Bool { get set }
|
||||
var isStatusSensitive: Bool { get set }
|
||||
|
||||
case emptyStateHeader(attribute: EmptyStateHeaderAttribute)
|
||||
|
||||
// reports
|
||||
case reportStatus(objectID: NSManagedObjectID, attribute: ReportStatusAttribute)
|
||||
}
|
||||
|
||||
extension Item {
|
||||
class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute {
|
||||
var isStatusTextSensitive: Bool
|
||||
var isStatusSensitive: Bool
|
||||
class StatusAttribute {
|
||||
var isSeparatorLineHidden: Bool
|
||||
|
||||
let isImageLoaded = CurrentValueSubject<Bool, Never>(false)
|
||||
let isRevealing = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
public init(
|
||||
isStatusTextSensitive: Bool,
|
||||
isStatusSensitive: Bool
|
||||
) {
|
||||
self.isStatusTextSensitive = isStatusTextSensitive
|
||||
self.isStatusSensitive = isStatusSensitive
|
||||
init(isSeparatorLineHidden: Bool = false) {
|
||||
self.isSeparatorLineHidden = isSeparatorLineHidden
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyStateHeaderAttribute: Hashable {
|
||||
let id = UUID()
|
||||
let reason: Reason
|
||||
|
||||
enum Reason: Equatable {
|
||||
case noStatusFound
|
||||
case blocking
|
||||
case blocked
|
||||
case suspended(name: String?)
|
||||
|
||||
static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.noStatusFound, noStatusFound): return true
|
||||
case (.blocking, blocking): return true
|
||||
case (.blocked, blocked): return true
|
||||
case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool {
|
||||
return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive &&
|
||||
lhs.isStatusSensitive == rhs.isStatusSensitive
|
||||
init(reason: Reason) {
|
||||
self.reason = reason
|
||||
}
|
||||
|
||||
|
||||
static func == (lhs: Item.EmptyStateHeaderAttribute, rhs: Item.EmptyStateHeaderAttribute) -> Bool {
|
||||
return lhs.reason == rhs.reason
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(isStatusTextSensitive)
|
||||
hasher.combine(isStatusSensitive)
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
class ReportStatusAttribute: StatusAttribute {
|
||||
var isSelected: Bool
|
||||
|
||||
init(isSeparatorLineHidden: Bool = false, isSelected: Bool = false) {
|
||||
self.isSelected = isSelected
|
||||
super.init(isSeparatorLineHidden: isSeparatorLineHidden)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,14 +98,28 @@ extension Item: Equatable {
|
|||
switch (lhs, rhs) {
|
||||
case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.toot(let objectIDLeft, _), .toot(let objectIDRight, _)):
|
||||
case (.root(let objectIDLeft, _), .root(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.reply(let objectIDLeft, _), .reply(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.leaf(let objectIDLeft, _), .leaf(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.leafBottomLoader(let objectIDLeft), .leafBottomLoader(let objectIDRight)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.status(let objectIDLeft, _), .status(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
|
||||
return upperLeft == upperRight
|
||||
case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)):
|
||||
return upperLeft == upperRight
|
||||
case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)):
|
||||
return upperLeft == upperRight
|
||||
case (.topLoader, .topLoader):
|
||||
return true
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)):
|
||||
return attributeLeft == attributeRight
|
||||
case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
@ -79,16 +131,30 @@ extension Item: Hashable {
|
|||
switch self {
|
||||
case .homeTimelineIndex(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
case .toot(let objectID, _):
|
||||
case .root(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
case .reply(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
case .leaf(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
case .leafBottomLoader(let objectID):
|
||||
hasher.combine(objectID)
|
||||
case .status(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
case .publicMiddleLoader(let upper):
|
||||
hasher.combine(String(describing: Item.publicMiddleLoader.self))
|
||||
hasher.combine(upper)
|
||||
case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper):
|
||||
hasher.combine(String(describing: Item.homeMiddleLoader.self))
|
||||
hasher.combine(upper)
|
||||
case .publicMiddleLoader(let upper):
|
||||
hasher.combine(String(describing: Item.publicMiddleLoader.self))
|
||||
hasher.combine(upper)
|
||||
case .topLoader:
|
||||
hasher.combine(String(describing: Item.topLoader.self))
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: Item.bottomLoader.self))
|
||||
case .emptyStateHeader(let attribute):
|
||||
hasher.combine(attribute)
|
||||
case .reportStatus(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// NotificationItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/13.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
enum NotificationItem {
|
||||
case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute)
|
||||
|
||||
case bottomLoader
|
||||
}
|
||||
|
||||
extension NotificationItem: Equatable {
|
||||
static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.notification(let idLeft, _), .notification(let idRight, _)):
|
||||
return idLeft == idRight
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .notification(let id, _):
|
||||
hasher.combine(id)
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: NotificationItem.bottomLoader.self))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
//
|
||||
// PickServerItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
/// Note: update Equatable when change case
|
||||
enum PickServerItem {
|
||||
case header
|
||||
case categoryPicker(items: [CategoryPickerItem])
|
||||
case search
|
||||
case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute)
|
||||
case loader(attribute: LoaderItemAttribute)
|
||||
}
|
||||
|
||||
extension PickServerItem {
|
||||
final class ServerItemAttribute: Equatable, Hashable {
|
||||
var isLast: Bool
|
||||
var isExpand: Bool
|
||||
|
||||
init(isLast: Bool, isExpand: Bool) {
|
||||
self.isLast = isLast
|
||||
self.isExpand = isExpand
|
||||
}
|
||||
|
||||
static func == (lhs: PickServerItem.ServerItemAttribute, rhs: PickServerItem.ServerItemAttribute) -> Bool {
|
||||
return lhs.isExpand == rhs.isExpand
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(isExpand)
|
||||
}
|
||||
}
|
||||
|
||||
final class LoaderItemAttribute: Equatable, Hashable {
|
||||
let id = UUID()
|
||||
|
||||
var isLast: Bool
|
||||
var isNoResult: Bool
|
||||
|
||||
init(isLast: Bool, isEmptyResult: Bool) {
|
||||
self.isLast = isLast
|
||||
self.isNoResult = isEmptyResult
|
||||
}
|
||||
|
||||
static func == (lhs: PickServerItem.LoaderItemAttribute, rhs: PickServerItem.LoaderItemAttribute) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerItem: Equatable {
|
||||
static func == (lhs: PickServerItem, rhs: PickServerItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.header, .header):
|
||||
return true
|
||||
case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)):
|
||||
return itemsLeft == itemsRight
|
||||
case (.search, .search):
|
||||
return true
|
||||
case (.server(let serverLeft, _), .server(let serverRight, _)):
|
||||
return serverLeft.domain == serverRight.domain
|
||||
case (.loader(let attributeLeft), loader(let attributeRight)):
|
||||
return attributeLeft == attributeRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .header:
|
||||
hasher.combine(String(describing: PickServerItem.header.self))
|
||||
case .categoryPicker(let items):
|
||||
hasher.combine(items)
|
||||
case .search:
|
||||
hasher.combine(String(describing: PickServerItem.search.self))
|
||||
case .server(let server, _):
|
||||
hasher.combine(server.domain)
|
||||
case .loader(let attribute):
|
||||
hasher.combine(attribute)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
//
|
||||
// PollItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Note: update Equatable when change case
|
||||
enum PollItem {
|
||||
case opion(objectID: NSManagedObjectID, attribute: Attribute)
|
||||
}
|
||||
|
||||
|
||||
extension PollItem {
|
||||
class Attribute: Hashable {
|
||||
|
||||
enum SelectState: Equatable, Hashable {
|
||||
case none
|
||||
case off
|
||||
case on
|
||||
}
|
||||
|
||||
enum VoteState: Equatable, Hashable {
|
||||
case hidden
|
||||
case reveal(voted: Bool, percentage: Double, animated: Bool)
|
||||
}
|
||||
|
||||
var selectState: SelectState
|
||||
var voteState: VoteState
|
||||
|
||||
init(selectState: SelectState, voteState: VoteState) {
|
||||
self.selectState = selectState
|
||||
self.voteState = voteState
|
||||
}
|
||||
|
||||
static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool {
|
||||
return lhs.selectState == rhs.selectState &&
|
||||
lhs.voteState == rhs.voteState
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(selectState)
|
||||
hasher.combine(voteState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PollItem: Equatable {
|
||||
static func == (lhs: PollItem, rhs: PollItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.opion(let objectIDLeft, _), .opion(let objectIDRight, _)):
|
||||
return objectIDLeft == objectIDRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension PollItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .opion(let objectID, _):
|
||||
hasher.combine(objectID)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// SearchResultItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/6.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
enum SearchResultItem {
|
||||
case hashtag(tag: Mastodon.Entity.Tag)
|
||||
|
||||
case account(account: Mastodon.Entity.Account)
|
||||
|
||||
case accountObjectID(accountObjectID: NSManagedObjectID)
|
||||
|
||||
case hashtagObjectID(hashtagObjectID: NSManagedObjectID)
|
||||
|
||||
case bottomLoader
|
||||
}
|
||||
|
||||
extension SearchResultItem: Equatable {
|
||||
static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.hashtag(let tagLeft), .hashtag(let tagRight)):
|
||||
return tagLeft == tagRight
|
||||
case (.account(let accountLeft), .account(let accountRight)):
|
||||
return accountLeft == accountRight
|
||||
case (.bottomLoader, .bottomLoader):
|
||||
return true
|
||||
case (.accountObjectID(let idLeft),.accountObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
case (.hashtagObjectID(let idLeft),.hashtagObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResultItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .account(let account):
|
||||
hasher.combine(account)
|
||||
case .hashtag(let tag):
|
||||
hasher.combine(tag)
|
||||
case .accountObjectID(let id):
|
||||
hasher.combine(id)
|
||||
case .hashtagObjectID(let id):
|
||||
hasher.combine(id)
|
||||
case .bottomLoader:
|
||||
hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// SelectedAccountItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/22.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
enum SelectedAccountItem {
|
||||
case accountObjectID(accountObjectID: NSManagedObjectID)
|
||||
case placeHolder(uuid: UUID)
|
||||
}
|
||||
|
||||
extension SelectedAccountItem: Equatable {
|
||||
static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.accountObjectID(let idLeft), .accountObjectID(let idRight)):
|
||||
return idLeft == idRight
|
||||
case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)):
|
||||
return uuidLeft == uuidRight
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SelectedAccountItem: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .accountObjectID(let id):
|
||||
hasher.combine(id)
|
||||
case .placeHolder(let id):
|
||||
hasher.combine(id.uuidString)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// SettingsItem.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
|
||||
enum SettingsItem: Hashable {
|
||||
case apperance(settingObjectID: NSManagedObjectID)
|
||||
case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode)
|
||||
case boringZone(item: Link)
|
||||
case spicyZone(item: Link)
|
||||
}
|
||||
|
||||
extension SettingsItem {
|
||||
|
||||
enum AppearanceMode: String {
|
||||
case automatic
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
enum NotificationSwitchMode: CaseIterable {
|
||||
case favorite
|
||||
case follow
|
||||
case reblog
|
||||
case mention
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .favorite: return L10n.Scene.Settings.Section.Notifications.favorites
|
||||
case .follow: return L10n.Scene.Settings.Section.Notifications.follows
|
||||
case .reblog: return L10n.Scene.Settings.Section.Notifications.boosts
|
||||
case .mention: return L10n.Scene.Settings.Section.Notifications.mentions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Link: CaseIterable {
|
||||
case termsOfService
|
||||
case privacyPolicy
|
||||
case clearMediaCache
|
||||
case signOut
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms
|
||||
case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy
|
||||
case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear
|
||||
case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: UIColor {
|
||||
switch self {
|
||||
case .termsOfService: return .systemBlue
|
||||
case .privacyPolicy: return .systemBlue
|
||||
case .clearMediaCache: return .systemRed
|
||||
case .signOut: return .systemRed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// CategoryPickerSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum CategoryPickerSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension CategoryPickerSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
dependency: NeedsDependency
|
||||
) -> UICollectionViewDiffableDataSource<CategoryPickerSection, CategoryPickerItem> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in
|
||||
guard let _ = dependency else { return nil }
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell
|
||||
switch item {
|
||||
case .all:
|
||||
cell.categoryView.titleLabel.font = .systemFont(ofSize: 17)
|
||||
case .category:
|
||||
cell.categoryView.titleLabel.font = .systemFont(ofSize: 28)
|
||||
}
|
||||
cell.categoryView.titleLabel.text = item.title
|
||||
cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in
|
||||
if cell.isSelected {
|
||||
cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color
|
||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0)
|
||||
if case .all = item {
|
||||
cell.categoryView.titleLabel.textColor = .white
|
||||
}
|
||||
} else {
|
||||
cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
|
||||
cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0)
|
||||
if case .all = item {
|
||||
cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cell.observations)
|
||||
|
||||
cell.isAccessibilityElement = true
|
||||
cell.accessibilityLabel = item.accessibilityDescription
|
||||
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,312 @@
|
|||
//
|
||||
// ComposeStatusSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import TwitterTextEditor
|
||||
import AlamofireImage
|
||||
|
||||
enum ComposeStatusSection: Equatable, Hashable {
|
||||
case repliedTo
|
||||
case status
|
||||
case attachment
|
||||
case poll
|
||||
}
|
||||
|
||||
extension ComposeStatusSection {
|
||||
enum ComposeKind {
|
||||
case post
|
||||
case hashtag(hashtag: String)
|
||||
case mention(mastodonUserObjectID: NSManagedObjectID)
|
||||
case reply(repliedToStatusObjectID: NSManagedObjectID)
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeStatusSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
dependency: NeedsDependency,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
composeKind: ComposeKind,
|
||||
repliedToCellFrameSubscriber: CurrentValueSubject<CGRect, Never>,
|
||||
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
|
||||
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
|
||||
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
|
||||
) -> UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { [
|
||||
weak customEmojiPickerInputViewModel,
|
||||
weak textEditorViewTextAttributesDelegate,
|
||||
weak composeStatusAttachmentTableViewCellDelegate,
|
||||
weak composeStatusPollOptionCollectionViewCellDelegate,
|
||||
weak composeStatusNewPollOptionCollectionViewCellDelegate,
|
||||
weak composeStatusPollExpiresOptionCollectionViewCellDelegate
|
||||
] collectionView, indexPath, item -> UICollectionViewCell? in
|
||||
switch item {
|
||||
case .replyTo(let replyToStatusObjectID):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
|
||||
managedObjectContext.perform {
|
||||
guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||
return
|
||||
}
|
||||
let status = replyTo.reblog ?? replyTo
|
||||
|
||||
// set avatar
|
||||
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
|
||||
// set name username
|
||||
cell.statusView.nameLabel.text = {
|
||||
let author = status.author
|
||||
return author.displayName.isEmpty ? author.username : author.displayName
|
||||
}()
|
||||
cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
|
||||
// set text
|
||||
//status.emoji
|
||||
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
|
||||
// set date
|
||||
cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow
|
||||
|
||||
cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag)
|
||||
}
|
||||
return cell
|
||||
case .input(let replyToStatusObjectID, let attribute):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
|
||||
cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
|
||||
cell.textEditorView.text = attribute.composeContent.value ?? ""
|
||||
managedObjectContext.perform {
|
||||
guard let replyToStatusObjectID = replyToStatusObjectID,
|
||||
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||
cell.statusView.headerContainerView.isHidden = true
|
||||
return
|
||||
}
|
||||
cell.statusView.headerContainerView.isHidden = false
|
||||
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
||||
cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
|
||||
}
|
||||
ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute)
|
||||
cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate
|
||||
cell.composeContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { text in
|
||||
// self size input cell
|
||||
// needs restore content offset to resolve issue #83
|
||||
let oldContentOffset = collectionView.contentOffset
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.contentOffset = oldContentOffset
|
||||
|
||||
// bind input data
|
||||
attribute.composeContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
attribute.isContentWarningComposing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { isContentWarningComposing in
|
||||
// self size input cell
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
|
||||
cell.statusContentWarningEditorView.alpha = 0
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) {
|
||||
cell.statusContentWarningEditorView.alpha = 1
|
||||
} completion: { _ in
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.contentWarningContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { text in
|
||||
// self size input cell
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
// bind input data
|
||||
attribute.contentWarningContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
||||
|
||||
return cell
|
||||
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 = composeStatusAttachmentTableViewCellDelegate
|
||||
attachmentService.imageData
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { imageData in
|
||||
let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1)
|
||||
guard let imageData = imageData,
|
||||
let image = UIImage(data: imageData) else {
|
||||
let placeholder = UIImage.placeholder(
|
||||
size: size,
|
||||
color: Asset.Colors.Background.systemGroupedBackground.color
|
||||
)
|
||||
.af.imageRounded(
|
||||
withCornerRadius: AttachmentContainerView.containerViewCornerRadius
|
||||
)
|
||||
cell.attachmentContainerView.previewImageView.image = placeholder
|
||||
return
|
||||
}
|
||||
cell.attachmentContainerView.previewImageView.image = image
|
||||
.af.imageAspectScaled(toFill: size)
|
||||
.af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
Publishers.CombineLatest(
|
||||
attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(),
|
||||
attachmentService.error.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { uploadState, error in
|
||||
cell.attachmentContainerView.emptyStateView.isHidden = error == nil
|
||||
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
|
||||
if let _ = error {
|
||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
||||
} else {
|
||||
guard let uploadState = uploadState else { return }
|
||||
switch uploadState {
|
||||
case is MastodonAttachmentService.UploadState.Finish,
|
||||
is MastodonAttachmentService.UploadState.Fail:
|
||||
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
||||
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
|
||||
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 = composeStatusPollOptionCollectionViewCellDelegate
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: 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 = composeStatusNewPollOptionCollectionViewCellDelegate
|
||||
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 { expiresOption in
|
||||
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.delegate = composeStatusPollExpiresOptionCollectionViewCellDelegate
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComposeStatusSection {
|
||||
|
||||
static func configureStatusContent(
|
||||
cell: ComposeStatusContentCollectionViewCell,
|
||||
attribute: ComposeStatusItem.ComposeStatusAttribute
|
||||
) {
|
||||
// set avatar
|
||||
attribute.avatarURL
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { avatarURL in
|
||||
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL))
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
// set display name and username
|
||||
Publishers.CombineLatest(
|
||||
attribute.displayName.eraseToAnyPublisher(),
|
||||
attribute.username.eraseToAnyPublisher()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { displayName, username in
|
||||
cell.statusView.nameLabel.text = displayName
|
||||
cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " "
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
// bind compose content
|
||||
cell.composeContent
|
||||
.map { $0 as String? }
|
||||
.assign(to: \.value, on: attribute.composeContent)
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protocol CustomEmojiReplacableTextInput: AnyObject {
|
||||
var inputView: UIView? { get set }
|
||||
func reloadInputViews()
|
||||
|
||||
// UIKeyInput
|
||||
func insertText(_ text: String)
|
||||
// UIResponder
|
||||
var isFirstResponder: Bool { get }
|
||||
}
|
||||
|
||||
class CustomEmojiReplacableTextInputReference {
|
||||
weak var value: CustomEmojiReplacableTextInput?
|
||||
|
||||
init(value: CustomEmojiReplacableTextInput? = nil) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
extension TextEditorView: CustomEmojiReplacableTextInput {
|
||||
func insertText(_ text: String) {
|
||||
try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil)
|
||||
}
|
||||
|
||||
public override var isFirstResponder: Bool {
|
||||
return isEditing
|
||||
}
|
||||
|
||||
}
|
||||
extension UITextField: CustomEmojiReplacableTextInput { }
|
||||
extension UITextView: CustomEmojiReplacableTextInput { }
|
||||
|
||||
extension ComposeStatusSection {
|
||||
|
||||
static func configureCustomEmojiPicker(
|
||||
viewModel: CustomEmojiPickerInputViewModel?,
|
||||
customEmojiReplacableTextInput: CustomEmojiReplacableTextInput,
|
||||
disposeBag: inout Set<AnyCancellable>
|
||||
) {
|
||||
guard let viewModel = viewModel else { return }
|
||||
viewModel.isCustomEmojiComposing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] isCustomEmojiComposing in
|
||||
guard let viewModel = viewModel else { return }
|
||||
customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
|
||||
customEmojiReplacableTextInput.reloadInputViews()
|
||||
viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// CustomEmojiPickerSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Kingfisher
|
||||
|
||||
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)
|
||||
cell.emojiImageView.kf.setImage(
|
||||
with: URL(string: attribute.emoji.url),
|
||||
placeholder: placeholder,
|
||||
options: [
|
||||
.transition(.fade(0.2))
|
||||
],
|
||||
completionHandler: 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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
//
|
||||
// NotificationSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/13.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
enum NotificationSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension NotificationSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
delegate: NotificationTableViewCellDelegate,
|
||||
dependency: NeedsDependency
|
||||
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) {
|
||||
[weak delegate, weak dependency]
|
||||
(tableView, indexPath, notificationItem) -> UITableViewCell? in
|
||||
guard let dependency = dependency else { return nil }
|
||||
switch notificationItem {
|
||||
case .notification(let objectID, let attribute):
|
||||
|
||||
let notification = managedObjectContext.object(with: objectID) as! MastodonNotification
|
||||
guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
|
||||
let actionText = type.actionText
|
||||
let actionImageName = type.actionImageName
|
||||
let color = type.color
|
||||
|
||||
if let status = notification.status {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell
|
||||
cell.delegate = delegate
|
||||
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
|
||||
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
|
||||
let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height)
|
||||
StatusSection.configure(
|
||||
cell: cell,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: frame,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
status: status,
|
||||
requestUserID: requestUserID,
|
||||
statusItemAttribute: attribute
|
||||
)
|
||||
timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.actionImageBackground.backgroundColor = color
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName
|
||||
if let url = notification.account.avatarImageURL() {
|
||||
cell.avatatImageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||
imageTransition: .crossDissolve(0.2)
|
||||
)
|
||||
}
|
||||
cell.avatatImageView.gesture().sink { [weak cell] _ in
|
||||
cell?.delegate?.userAvatarDidPressed(notification: notification)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
|
||||
cell.actionImageView.image = actionImage
|
||||
}
|
||||
return cell
|
||||
|
||||
} else {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell
|
||||
cell.delegate = delegate
|
||||
timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
let timeText = notification.createAt.shortTimeAgoSinceNow
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.acceptButton.publisher(for: .touchUpInside)
|
||||
.sink { [weak cell] _ in
|
||||
guard let cell = cell else { return }
|
||||
cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.rejectButton.publisher(for: .touchUpInside)
|
||||
.sink { [weak cell] _ in
|
||||
guard let cell = cell else { return }
|
||||
cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
cell.actionImageBackground.backgroundColor = color
|
||||
cell.actionLabel.text = actionText + " · " + timeText
|
||||
cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName
|
||||
if let url = notification.account.avatarImageURL() {
|
||||
cell.avatatImageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||
imageTransition: .crossDissolve(0.2)
|
||||
)
|
||||
}
|
||||
cell.avatatImageView.gesture().sink { [weak cell] _ in
|
||||
cell?.delegate?.userAvatarDidPressed(notification: notification)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) {
|
||||
cell.actionImageView.image = actionImage
|
||||
}
|
||||
cell.buttonStackView.isHidden = (type != .followRequest)
|
||||
return cell
|
||||
}
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
//
|
||||
// PickServerSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021/3/5.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonSDK
|
||||
import Kanna
|
||||
import AlamofireImage
|
||||
|
||||
enum PickServerSection: Equatable, Hashable {
|
||||
case header
|
||||
case category
|
||||
case search
|
||||
case servers
|
||||
}
|
||||
|
||||
extension PickServerSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency,
|
||||
pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate,
|
||||
pickServerSearchCellDelegate: PickServerSearchCellDelegate,
|
||||
pickServerCellDelegate: PickServerCellDelegate
|
||||
) -> UITableViewDiffableDataSource<PickServerSection, PickServerItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [
|
||||
weak dependency,
|
||||
weak pickServerCategoriesCellDelegate,
|
||||
weak pickServerSearchCellDelegate,
|
||||
weak pickServerCellDelegate
|
||||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let dependency = dependency else { return nil }
|
||||
switch item {
|
||||
case .header:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell
|
||||
return cell
|
||||
case .categoryPicker(let items):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell
|
||||
cell.delegate = pickServerCategoriesCellDelegate
|
||||
cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource(
|
||||
for: cell.collectionView,
|
||||
dependency: dependency
|
||||
)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<CategoryPickerSection, CategoryPickerItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items, toSection: .main)
|
||||
cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
return cell
|
||||
case .search:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell
|
||||
cell.delegate = pickServerSearchCellDelegate
|
||||
return cell
|
||||
case .server(let server, let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell
|
||||
PickServerSection.configure(cell: cell, server: server, attribute: attribute)
|
||||
cell.delegate = pickServerCellDelegate
|
||||
return cell
|
||||
case .loader(let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerLoaderTableViewCell.self), for: indexPath) as! PickServerLoaderTableViewCell
|
||||
PickServerSection.configure(cell: cell, attribute: attribute)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PickServerSection {
|
||||
|
||||
static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) {
|
||||
cell.domainLabel.text = server.domain
|
||||
cell.descriptionLabel.text = {
|
||||
guard let html = try? HTML(html: server.description, encoding: .utf8) else {
|
||||
return server.description
|
||||
}
|
||||
|
||||
return html.text ?? server.description
|
||||
}()
|
||||
cell.langValueLabel.text = server.language.uppercased()
|
||||
cell.usersValueLabel.text = parseUsersCount(server.totalUsers)
|
||||
cell.categoryValueLabel.text = server.category.uppercased()
|
||||
|
||||
cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse)
|
||||
|
||||
if attribute.isLast {
|
||||
cell.containerView.layer.maskedCorners = [
|
||||
.layerMinXMaxYCorner,
|
||||
.layerMaxXMaxYCorner
|
||||
]
|
||||
cell.containerView.layer.cornerCurve = .continuous
|
||||
cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
|
||||
} else {
|
||||
cell.containerView.layer.cornerRadius = 0
|
||||
}
|
||||
|
||||
cell.expandMode
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { mode in
|
||||
switch mode {
|
||||
case .collapse:
|
||||
// do nothing
|
||||
break
|
||||
case .expand:
|
||||
let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill)
|
||||
.af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false)
|
||||
guard let proxiedThumbnail = server.proxiedThumbnail,
|
||||
let url = URL(string: proxiedThumbnail) else {
|
||||
cell.thumbnailImageView.image = placeholderImage
|
||||
cell.thumbnailActivityIdicator.stopAnimating()
|
||||
return
|
||||
}
|
||||
cell.thumbnailImageView.isHidden = false
|
||||
cell.thumbnailActivityIdicator.startAnimating()
|
||||
|
||||
cell.thumbnailImageView.af.setImage(
|
||||
withURL: url,
|
||||
placeholderImage: placeholderImage,
|
||||
filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3),
|
||||
imageTransition: .crossDissolve(0.33),
|
||||
completion: { [weak cell] response in
|
||||
switch response.result {
|
||||
case .success, .failure:
|
||||
cell?.thumbnailActivityIdicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
private static func parseUsersCount(_ usersCount: Int) -> String {
|
||||
switch usersCount {
|
||||
case 0..<1000:
|
||||
return "\(usersCount)"
|
||||
default:
|
||||
let usersCountInThousand = Float(usersCount) / 1000.0
|
||||
return String(format: "%.1fK", usersCountInThousand)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PickServerSection {
|
||||
|
||||
static func configure(cell: PickServerLoaderTableViewCell, attribute: PickServerItem.LoaderItemAttribute) {
|
||||
if attribute.isLast {
|
||||
cell.containerView.layer.maskedCorners = [
|
||||
.layerMinXMaxYCorner,
|
||||
.layerMaxXMaxYCorner
|
||||
]
|
||||
cell.containerView.layer.cornerCurve = .continuous
|
||||
cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius
|
||||
} else {
|
||||
cell.containerView.layer.cornerRadius = 0
|
||||
}
|
||||
|
||||
attribute.isNoResult ? cell.stopAnimating() : cell.startAnimating()
|
||||
cell.emptyStatusLabel.isHidden = !attribute.isNoResult
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
// PollSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-2.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.Attachment: Hashable {
|
||||
public static func == (lhs: Mastodon.Entity.Attachment, rhs: Mastodon.Entity.Attachment) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
enum PollSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension PollSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
managedObjectContext: NSManagedObjectContext
|
||||
) -> UITableViewDiffableDataSource<PollSection, PollItem> {
|
||||
return UITableViewDiffableDataSource<PollSection, PollItem>(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
|
||||
switch item {
|
||||
case .opion(let objectID, let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell
|
||||
managedObjectContext.performAndWait {
|
||||
let option = managedObjectContext.object(with: objectID) as! PollOption
|
||||
PollSection.configure(cell: cell, pollOption: option, pollItemAttribute: attribute)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PollSection {
|
||||
static func configure(
|
||||
cell: PollOptionTableViewCell,
|
||||
pollOption option: PollOption,
|
||||
pollItemAttribute attribute: PollItem.Attribute
|
||||
) {
|
||||
cell.pollOptionView.optionTextField.text = option.title
|
||||
configure(cell: cell, selectState: attribute.selectState)
|
||||
configure(cell: cell, voteState: attribute.voteState)
|
||||
cell.attribute = attribute
|
||||
cell.layoutIfNeeded()
|
||||
cell.updateTextAppearance()
|
||||
}
|
||||
}
|
||||
|
||||
extension PollSection {
|
||||
|
||||
static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) {
|
||||
switch state {
|
||||
case .none:
|
||||
cell.pollOptionView.checkmarkBackgroundView.isHidden = true
|
||||
cell.pollOptionView.checkmarkImageView.isHidden = true
|
||||
case .off:
|
||||
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground
|
||||
cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor
|
||||
cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1
|
||||
cell.pollOptionView.checkmarkBackgroundView.isHidden = false
|
||||
cell.pollOptionView.checkmarkImageView.isHidden = true
|
||||
case .on:
|
||||
cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground
|
||||
cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor
|
||||
cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 0
|
||||
cell.pollOptionView.checkmarkBackgroundView.isHidden = false
|
||||
cell.pollOptionView.checkmarkImageView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) {
|
||||
switch state {
|
||||
case .hidden:
|
||||
cell.pollOptionView.optionPercentageLabel.isHidden = true
|
||||
cell.pollOptionView.voteProgressStripView.isHidden = true
|
||||
cell.pollOptionView.voteProgressStripView.setProgress(0.0, animated: false)
|
||||
case .reveal(let voted, let percentage, let animated):
|
||||
cell.pollOptionView.optionPercentageLabel.isHidden = false
|
||||
cell.pollOptionView.optionPercentageLabel.text = String(Int(100 * percentage)) + "%"
|
||||
cell.pollOptionView.voteProgressStripView.isHidden = false
|
||||
cell.pollOptionView.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color
|
||||
cell.pollOptionView.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// RecommendAccountSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/1.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
enum RecommendAccountSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension RecommendAccountSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
delegate: SearchRecommendAccountsCollectionViewCellDelegate,
|
||||
managedObjectContext: NSManagedObjectContext
|
||||
) -> UICollectionViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell
|
||||
let user = managedObjectContext.object(with: objectID) as! MastodonUser
|
||||
cell.delegate = delegate
|
||||
cell.config(with: user)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
viewModel: SuggestionAccountViewModel,
|
||||
delegate: SuggestionAccountTableViewCellDelegate
|
||||
) -> UITableViewDiffableDataSource<RecommendAccountSection, NSManagedObjectID> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in
|
||||
guard let viewModel = viewModel else { return nil }
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell
|
||||
let user = managedObjectContext.object(with: objectID) as! MastodonUser
|
||||
let isSelected = viewModel.selectedAccounts.value.contains(objectID)
|
||||
cell.delegate = delegate
|
||||
cell.config(with: user, isSelected: isSelected)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// RecommendHashTagSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/1.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
enum RecommendHashTagSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension RecommendHashTagSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView
|
||||
) -> UICollectionViewDiffableDataSource<RecommendHashTagSection, Mastodon.Entity.Tag> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
//
|
||||
// ReportSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by ihugo on 2021/4/20.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
import AVKit
|
||||
import os.log
|
||||
|
||||
enum ReportSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension ReportSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: ReportViewController,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
timestampUpdatePublisher: AnyPublisher<Date, Never>
|
||||
) -> UITableViewDiffableDataSource<ReportSection, Item> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) {[
|
||||
weak dependency
|
||||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let dependency = dependency else { return UITableViewCell() }
|
||||
|
||||
switch item {
|
||||
case .reportStatus(let objectID, let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell
|
||||
cell.dependency = dependency
|
||||
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
|
||||
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
|
||||
managedObjectContext.performAndWait { [weak dependency] in
|
||||
guard let dependency = dependency else { return }
|
||||
let status = managedObjectContext.object(with: objectID) as! Status
|
||||
StatusSection.configure(
|
||||
cell: cell,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
status: status,
|
||||
requestUserID: requestUserID,
|
||||
statusItemAttribute: attribute
|
||||
)
|
||||
}
|
||||
|
||||
// defalut to select the report status
|
||||
if attribute.isSelected {
|
||||
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
|
||||
} else {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
}
|
||||
|
||||
return cell
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
// SearchResultSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/6.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
|
||||
enum SearchResultSection: Equatable, Hashable {
|
||||
case account
|
||||
case hashtag
|
||||
case mixed
|
||||
case bottomLoader
|
||||
}
|
||||
|
||||
extension SearchResultSection {
|
||||
static func tableViewDiffableDataSource(
|
||||
for tableView: UITableView,
|
||||
dependency: NeedsDependency
|
||||
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
|
||||
switch result {
|
||||
case .account(let account):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
cell.config(with: account)
|
||||
return cell
|
||||
case .hashtag(let tag):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
case .hashtagObjectID(let hashtagObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag
|
||||
cell.config(with: tag)
|
||||
return cell
|
||||
case .accountObjectID(let accountObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||
let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||
cell.config(with: user)
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// SelectedAccountSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/22.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
enum SelectedAccountSection: Equatable, Hashable {
|
||||
case main
|
||||
}
|
||||
|
||||
extension SelectedAccountSection {
|
||||
static func collectionViewDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
managedObjectContext: NSManagedObjectContext
|
||||
) -> UICollectionViewDiffableDataSource<SelectedAccountSection, SelectedAccountItem> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell
|
||||
switch item {
|
||||
case .accountObjectID(let objectID):
|
||||
let user = managedObjectContext.object(with: objectID) as! MastodonUser
|
||||
cell.config(with: user)
|
||||
case .placeHolder:
|
||||
cell.configAsPlaceHolder()
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// SettingsSection.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SettingsSection: Hashable {
|
||||
case apperance
|
||||
case notifications
|
||||
case boringZone
|
||||
case spicyZone
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .apperance: return L10n.Scene.Settings.Section.Appearance.title
|
||||
case .notifications: return L10n.Scene.Settings.Section.Notifications.title
|
||||
case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title
|
||||
case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,12 @@ import CoreData
|
|||
import CoreDataStack
|
||||
import os.log
|
||||
import UIKit
|
||||
import AVKit
|
||||
|
||||
protocol StatusCell: DisposeBagCollectable {
|
||||
var statusView: StatusView { get }
|
||||
var pollCountdownSubscription: AnyCancellable? { get set }
|
||||
}
|
||||
|
||||
enum StatusSection: Equatable, Hashable {
|
||||
case main
|
||||
|
@ -21,11 +27,18 @@ extension StatusSection {
|
|||
dependency: NeedsDependency,
|
||||
managedObjectContext: NSManagedObjectContext,
|
||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||
timelinePostTableViewCellDelegate: StatusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||
statusTableViewCellDelegate: StatusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?,
|
||||
threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate?
|
||||
) -> UITableViewDiffableDataSource<StatusSection, Item> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() }
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [
|
||||
weak dependency,
|
||||
weak statusTableViewCellDelegate,
|
||||
weak timelineMiddleLoaderTableViewCellDelegate,
|
||||
weak threadReplyLoaderTableViewCellDelegate
|
||||
] tableView, indexPath, item -> UITableViewCell? in
|
||||
guard let dependency = dependency else { return UITableViewCell() }
|
||||
guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() }
|
||||
|
||||
switch item {
|
||||
case .homeTimelineIndex(objectID: let objectID, let attribute):
|
||||
|
@ -34,81 +47,191 @@ extension StatusSection {
|
|||
// configure cell
|
||||
managedObjectContext.performAndWait {
|
||||
let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex
|
||||
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute)
|
||||
StatusSection.configure(
|
||||
cell: cell,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
status: timelineIndex.status,
|
||||
requestUserID: timelineIndex.userID,
|
||||
statusItemAttribute: attribute
|
||||
)
|
||||
}
|
||||
cell.delegate = timelinePostTableViewCellDelegate
|
||||
cell.delegate = statusTableViewCellDelegate
|
||||
cell.isAccessibilityElement = true
|
||||
return cell
|
||||
case .toot(let objectID, let attribute):
|
||||
case .status(let objectID, let attribute),
|
||||
.root(let objectID, let attribute),
|
||||
.reply(let objectID, let attribute),
|
||||
.leaf(let objectID, let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
|
||||
let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value
|
||||
let requestUserID = activeMastodonAuthenticationBox?.userID ?? ""
|
||||
// configure cell
|
||||
managedObjectContext.performAndWait {
|
||||
let toot = managedObjectContext.object(with: objectID) as! Toot
|
||||
StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute)
|
||||
let status = managedObjectContext.object(with: objectID) as! Status
|
||||
StatusSection.configure(
|
||||
cell: cell,
|
||||
dependency: dependency,
|
||||
readableLayoutFrame: tableView.readableContentGuide.layoutFrame,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher,
|
||||
status: status,
|
||||
requestUserID: requestUserID,
|
||||
statusItemAttribute: attribute
|
||||
)
|
||||
|
||||
switch item {
|
||||
case .root:
|
||||
StatusSection.configureThreadMeta(cell: cell, status: status)
|
||||
ManagedObjectObserver.observe(object: status.reblog ?? status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { change in
|
||||
guard case .update(let object) = change.changeType,
|
||||
let status = object as? Status else { return }
|
||||
StatusSection.configureThreadMeta(cell: cell, status: status)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
cell.delegate = statusTableViewCellDelegate
|
||||
switch item {
|
||||
case .root:
|
||||
cell.statusView.activeTextLabel.isAccessibilityElement = false
|
||||
var accessibilityElements: [Any] = []
|
||||
accessibilityElements.append(cell.statusView.avatarView)
|
||||
accessibilityElements.append(cell.statusView.nameLabel)
|
||||
accessibilityElements.append(cell.statusView.dateLabel)
|
||||
accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements())
|
||||
accessibilityElements.append(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews)
|
||||
accessibilityElements.append(cell.statusView.playerContainerView)
|
||||
accessibilityElements.append(cell.statusView.actionToolbarContainer)
|
||||
accessibilityElements.append(cell.threadMetaView)
|
||||
cell.accessibilityElements = accessibilityElements
|
||||
default:
|
||||
cell.isAccessibilityElement = true
|
||||
cell.accessibilityElements = nil
|
||||
}
|
||||
cell.delegate = timelinePostTableViewCellDelegate
|
||||
return cell
|
||||
case .publicMiddleLoader(let upperTimelineTootID):
|
||||
case .leafBottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell
|
||||
cell.delegate = threadReplyLoaderTableViewCellDelegate
|
||||
return cell
|
||||
case .publicMiddleLoader(let upperTimelineStatusID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
|
||||
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
|
||||
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID, timelineIndexobjectID: nil)
|
||||
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil)
|
||||
return cell
|
||||
case .homeMiddleLoader(let upperTimelineIndexObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell
|
||||
cell.delegate = timelineMiddleLoaderTableViewCellDelegate
|
||||
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: nil, timelineIndexobjectID: upperTimelineIndexObjectID)
|
||||
timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID)
|
||||
return cell
|
||||
case .topLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
case .bottomLoader:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
|
||||
cell.activityIndicatorView.startAnimating()
|
||||
cell.startAnimating()
|
||||
return cell
|
||||
case .emptyStateHeader(let attribute):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell
|
||||
StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute)
|
||||
return cell
|
||||
case .reportStatus:
|
||||
return UITableViewCell()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusSection {
|
||||
|
||||
static func configure(
|
||||
cell: StatusTableViewCell,
|
||||
cell: StatusCell,
|
||||
dependency: NeedsDependency,
|
||||
readableLayoutFrame: CGRect?,
|
||||
timestampUpdatePublisher: AnyPublisher<Date, Never>,
|
||||
toot: Toot,
|
||||
status: Status,
|
||||
requestUserID: String,
|
||||
statusContentWarningAttribute: StatusContentWarningAttribute?
|
||||
statusItemAttribute: Item.StatusAttribute
|
||||
) {
|
||||
// set header
|
||||
cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil
|
||||
cell.statusView.headerInfoLabel.text = {
|
||||
let author = toot.author
|
||||
let name = author.displayName.isEmpty ? author.username : author.displayName
|
||||
return L10n.Common.Controls.Status.userBoosted(name)
|
||||
}()
|
||||
// safely cancel the listenser when deleted
|
||||
ManagedObjectObserver.observe(object: status.reblog ?? status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { [weak cell] change in
|
||||
guard let cell = cell else { return }
|
||||
guard let changeType = change.changeType else { return }
|
||||
if case .delete = changeType {
|
||||
cell.disposeBag.removeAll()
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
// set name username avatar
|
||||
cell.statusView.nameLabel.text = {
|
||||
let author = (toot.reblog ?? toot).author
|
||||
|
||||
// set header
|
||||
StatusSection.configureHeader(cell: cell, status: status)
|
||||
ManagedObjectObserver.observe(object: status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { [weak cell] change in
|
||||
guard let cell = cell else { return }
|
||||
guard case .update(let object) = change.changeType,
|
||||
let newStatus = object as? Status else { return }
|
||||
StatusSection.configureHeader(cell: cell, status: newStatus)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
// set name username
|
||||
let nameText: String = {
|
||||
let author = (status.reblog ?? status).author
|
||||
return author.displayName.isEmpty ? author.username : author.displayName
|
||||
}()
|
||||
cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct
|
||||
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL()))
|
||||
cell.statusView.nameLabel.configure(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict)
|
||||
cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
|
||||
|
||||
// set avatar
|
||||
if let reblog = status.reblog {
|
||||
cell.statusView.avatarButton.isHidden = true
|
||||
cell.statusView.avatarStackedContainerButton.isHidden = false
|
||||
cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL()))
|
||||
cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
|
||||
} else {
|
||||
cell.statusView.avatarButton.isHidden = false
|
||||
cell.statusView.avatarStackedContainerButton.isHidden = true
|
||||
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
|
||||
}
|
||||
|
||||
// set text
|
||||
cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content)
|
||||
cell.statusView.activeTextLabel.configure(
|
||||
content: (status.reblog ?? status).content,
|
||||
emojiDict: (status.reblog ?? status).emojiDict
|
||||
)
|
||||
cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language
|
||||
|
||||
// set status text content warning
|
||||
let spoilerText = (toot.reblog ?? toot).spoilerText ?? ""
|
||||
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty
|
||||
cell.statusView.isStatusTextSensitive = isStatusTextSensitive
|
||||
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
|
||||
cell.statusView.contentWarningTitle.text = {
|
||||
if spoilerText.isEmpty {
|
||||
return L10n.Common.Controls.Status.statusContentWarning
|
||||
} else {
|
||||
return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)"
|
||||
}
|
||||
}()
|
||||
// set visibility
|
||||
if let visibility = (status.reblog ?? status).visibility {
|
||||
cell.statusView.updateVisibility(visibility: visibility)
|
||||
|
||||
cell.statusView.revealContentWarningButton.publisher(for: \.isHidden)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell] isHidden in
|
||||
cell?.statusView.visibilityImageView.isHidden = !isHidden
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
} else {
|
||||
cell.statusView.visibilityImageView.isHidden = true
|
||||
}
|
||||
|
||||
// prepare media attachments
|
||||
let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
|
||||
let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
|
||||
|
||||
// set image
|
||||
let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments)
|
||||
|
@ -124,79 +247,593 @@ extension StatusSection {
|
|||
}()
|
||||
let scale: CGFloat = {
|
||||
switch mosiacImageViewModel.metas.count {
|
||||
case 1: return 1.3
|
||||
default: return 0.7
|
||||
case 1: return 1.3
|
||||
default: return 0.7
|
||||
}
|
||||
}()
|
||||
return CGSize(width: maxWidth, height: maxWidth * scale)
|
||||
}()
|
||||
if mosiacImageViewModel.metas.count == 1 {
|
||||
let meta = mosiacImageViewModel.metas[0]
|
||||
let imageView = cell.statusView.statusMosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
|
||||
let blurhashImageCache = dependency.context.documentStore.blurhashImageCache
|
||||
let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = {
|
||||
if mosiacImageViewModel.metas.count == 1 {
|
||||
let meta = mosiacImageViewModel.metas[0]
|
||||
let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize)
|
||||
return [mosaic]
|
||||
} else {
|
||||
let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
|
||||
return mosaics
|
||||
}
|
||||
}()
|
||||
for (i, mosiac) in mosaics.enumerated() {
|
||||
let (imageView, blurhashOverlayImageView) = mosiac
|
||||
let meta = mosiacImageViewModel.metas[i]
|
||||
let blurhashImageDataKey = meta.url.absoluteString as NSString
|
||||
if let blurhashImageData = blurhashImageCache.object(forKey: meta.url.absoluteString as NSString),
|
||||
let image = UIImage(data: blurhashImageData as Data) {
|
||||
blurhashOverlayImageView.image = image
|
||||
} else {
|
||||
meta.blurhashImagePublisher()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell] image in
|
||||
blurhashOverlayImageView.image = image
|
||||
image?.pngData().flatMap {
|
||||
blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey)
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
imageView.af.setImage(
|
||||
withURL: meta.url,
|
||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||
imageTransition: .crossDissolve(0.2)
|
||||
) { response in
|
||||
switch response.result {
|
||||
case .success:
|
||||
statusItemAttribute.isImageLoaded.value = true
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
imageView.accessibilityLabel = meta.altText
|
||||
Publishers.CombineLatest(
|
||||
statusItemAttribute.isImageLoaded,
|
||||
statusItemAttribute.isRevealing
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell] isImageLoaded, isMediaRevealing in
|
||||
guard let cell = cell else { return }
|
||||
guard isImageLoaded else {
|
||||
blurhashOverlayImageView.alpha = 1
|
||||
blurhashOverlayImageView.isHidden = false
|
||||
return
|
||||
}
|
||||
|
||||
blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1
|
||||
if isMediaRevealing {
|
||||
let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1
|
||||
}
|
||||
animator.startAnimation()
|
||||
} else {
|
||||
cell.statusView.drawContentWarningImageView()
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty
|
||||
|
||||
// set audio
|
||||
if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first {
|
||||
cell.statusView.audioView.isHidden = false
|
||||
AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, audioService: dependency.context.audioPlaybackService)
|
||||
} else {
|
||||
let imageViews = cell.statusView.statusMosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height)
|
||||
for (i, imageView) in imageViews.enumerated() {
|
||||
let meta = mosiacImageViewModel.metas[i]
|
||||
imageView.af.setImage(
|
||||
withURL: meta.url,
|
||||
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||
imageTransition: .crossDissolve(0.2)
|
||||
cell.statusView.audioView.isHidden = true
|
||||
}
|
||||
|
||||
// set GIF & video
|
||||
let playerViewMaxSize: CGSize = {
|
||||
let maxWidth: CGFloat = {
|
||||
// use statusView width as container width
|
||||
// that width follows readable width and keep constant width after rotate
|
||||
let containerFrame = readableLayoutFrame ?? cell.statusView.frame
|
||||
return containerFrame.width
|
||||
}()
|
||||
let scale: CGFloat = 1.3
|
||||
return CGSize(width: maxWidth, height: maxWidth * scale)
|
||||
}()
|
||||
|
||||
if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first,
|
||||
let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment)
|
||||
{
|
||||
var parent: UIViewController?
|
||||
var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil
|
||||
switch cell {
|
||||
case is StatusTableViewCell:
|
||||
let statusTableViewCell = cell as! StatusTableViewCell
|
||||
parent = statusTableViewCell.delegate?.parent()
|
||||
playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate
|
||||
case is NotificationStatusTableViewCell:
|
||||
let notificationTableViewCell = cell as! NotificationStatusTableViewCell
|
||||
parent = notificationTableViewCell.delegate?.parent()
|
||||
case is ReportedStatusTableViewCell:
|
||||
let reportTableViewCell = cell as! ReportedStatusTableViewCell
|
||||
parent = reportTableViewCell.dependency
|
||||
default:
|
||||
parent = nil
|
||||
assertionFailure("unknown cell")
|
||||
}
|
||||
let playerContainerView = cell.statusView.playerContainerView
|
||||
let playerViewController = playerContainerView.setupPlayer(
|
||||
aspectRatio: videoPlayerViewModel.videoSize,
|
||||
maxSize: playerViewMaxSize,
|
||||
parent: parent
|
||||
)
|
||||
playerViewController.delegate = playerViewControllerDelegate
|
||||
playerViewController.player = videoPlayerViewModel.player
|
||||
playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif
|
||||
playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind)
|
||||
if videoPlayerViewModel.videoKind == .gif {
|
||||
playerContainerView.setMediaIndicator(isHidden: false)
|
||||
} else {
|
||||
videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in
|
||||
UIView.animate(withDuration: 0.33) {
|
||||
switch timeControlStatus {
|
||||
case .playing:
|
||||
playerContainerView.setMediaIndicator(isHidden: true)
|
||||
case .paused, .waitingToPlayAtSpecifiedRate:
|
||||
playerContainerView.setMediaIndicator(isHidden: false)
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
playerContainerView.isHidden = false
|
||||
|
||||
} else {
|
||||
cell.statusView.playerContainerView.playerViewController.player?.pause()
|
||||
cell.statusView.playerContainerView.playerViewController.player = nil
|
||||
}
|
||||
|
||||
// set text content warning
|
||||
StatusSection.configureContentWarningOverlay(
|
||||
statusView: cell.statusView,
|
||||
status: status,
|
||||
attribute: statusItemAttribute,
|
||||
documentStore: dependency.context.documentStore,
|
||||
animated: false
|
||||
)
|
||||
// observe model change
|
||||
ManagedObjectObserver.observe(object: status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { [weak dependency, weak cell] change in
|
||||
guard let cell = cell else { return }
|
||||
guard let dependency = dependency else { return }
|
||||
guard case .update(let object) = change.changeType,
|
||||
let status = object as? Status else { return }
|
||||
StatusSection.configureContentWarningOverlay(
|
||||
statusView: cell.statusView,
|
||||
status: status,
|
||||
attribute: statusItemAttribute,
|
||||
documentStore: dependency.context.documentStore,
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty
|
||||
let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive
|
||||
cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
|
||||
cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
|
||||
|
||||
// toolbar
|
||||
let replyCountTitle: String = {
|
||||
let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0
|
||||
return StatusSection.formattedNumberTitleForActionButton(count)
|
||||
}()
|
||||
cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
let isLike = (toot.reblog ?? toot).favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
|
||||
let favoriteCountTitle: String = {
|
||||
let count = (toot.reblog ?? toot).favouritesCount.intValue
|
||||
return StatusSection.formattedNumberTitleForActionButton(count)
|
||||
}()
|
||||
cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal)
|
||||
cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike
|
||||
// set poll
|
||||
let poll = (status.reblog ?? status).poll
|
||||
StatusSection.configurePoll(
|
||||
cell: cell,
|
||||
poll: poll,
|
||||
requestUserID: requestUserID,
|
||||
updateProgressAnimated: false,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher
|
||||
)
|
||||
if let poll = poll {
|
||||
ManagedObjectObserver.observe(object: poll)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { [weak cell] change in
|
||||
guard let cell = cell else { return }
|
||||
guard case .update(let object) = change.changeType,
|
||||
let newPoll = object as? Poll else { return }
|
||||
StatusSection.configurePoll(
|
||||
cell: cell,
|
||||
poll: newPoll,
|
||||
requestUserID: requestUserID,
|
||||
updateProgressAnimated: true,
|
||||
timestampUpdatePublisher: timestampUpdatePublisher
|
||||
)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
if let statusTableViewCell = cell as? StatusTableViewCell {
|
||||
// toolbar
|
||||
StatusSection.configureActionToolBar(
|
||||
cell: statusTableViewCell,
|
||||
dependency: dependency,
|
||||
status: status,
|
||||
requestUserID: requestUserID
|
||||
)
|
||||
// separator line
|
||||
statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden
|
||||
}
|
||||
|
||||
// set date
|
||||
let createdAt = (toot.reblog ?? toot).createdAt
|
||||
let createdAt = (status.reblog ?? status).createdAt
|
||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||
timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
.sink { [weak cell] _ in
|
||||
guard let cell = cell else { return }
|
||||
cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow
|
||||
cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
|
||||
// observe model change
|
||||
ManagedObjectObserver.observe(object: toot.reblog ?? toot)
|
||||
ManagedObjectObserver.observe(object: status.reblog ?? status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in
|
||||
// do nothing
|
||||
} receiveValue: { change in
|
||||
} receiveValue: { [weak dependency, weak cell] change in
|
||||
guard let dependency = dependency else { return }
|
||||
guard case .update(let object) = change.changeType,
|
||||
let newToot = object as? Toot else { return }
|
||||
let targetToot = newToot.reblog ?? newToot
|
||||
|
||||
let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
|
||||
let favoriteCount = targetToot.favouritesCount.intValue
|
||||
let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount)
|
||||
cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal)
|
||||
cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike
|
||||
os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, targetToot.id, favoriteCount)
|
||||
let status = object as? Status,
|
||||
!status.isDeleted else { return }
|
||||
guard let statusTableViewCell = cell as? StatusTableViewCell else { return }
|
||||
StatusSection.configureActionToolBar(
|
||||
cell: statusTableViewCell,
|
||||
dependency: dependency,
|
||||
status: status,
|
||||
requestUserID: requestUserID
|
||||
)
|
||||
|
||||
os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue)
|
||||
os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
static func configureContentWarningOverlay(
|
||||
statusView: StatusView,
|
||||
status: Status,
|
||||
attribute: Item.StatusAttribute,
|
||||
documentStore: DocumentStore,
|
||||
animated: Bool
|
||||
) {
|
||||
statusView.contentWarningOverlayView.blurContentWarningTitleLabel.text = {
|
||||
let spoilerText = (status.reblog ?? status).spoilerText ?? ""
|
||||
if spoilerText.isEmpty {
|
||||
return L10n.Common.Controls.Status.contentWarning
|
||||
} else {
|
||||
return L10n.Common.Controls.Status.contentWarningText(spoilerText)
|
||||
}
|
||||
}()
|
||||
let appStartUpTimestamp = documentStore.appStartUpTimestamp
|
||||
|
||||
switch (status.reblog ?? status).sensitiveType {
|
||||
case .none:
|
||||
statusView.revealContentWarningButton.isHidden = true
|
||||
statusView.contentWarningOverlayView.isHidden = true
|
||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
|
||||
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||
case .all:
|
||||
statusView.revealContentWarningButton.isHidden = false
|
||||
statusView.contentWarningOverlayView.isHidden = false
|
||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true
|
||||
statusView.playerContainerView.contentWarningOverlayView.isHidden = true
|
||||
|
||||
if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp {
|
||||
statusView.updateRevealContentWarningButton(isRevealing: true)
|
||||
statusView.updateContentWarningDisplay(isHidden: true, animated: animated)
|
||||
attribute.isRevealing.value = true
|
||||
} else {
|
||||
statusView.updateRevealContentWarningButton(isRevealing: false)
|
||||
statusView.updateContentWarningDisplay(isHidden: false, animated: animated)
|
||||
attribute.isRevealing.value = false
|
||||
}
|
||||
case .media(let isSensitive):
|
||||
if !isSensitive, documentStore.defaultRevealStatusDict[status.id] == nil {
|
||||
documentStore.defaultRevealStatusDict[status.id] = true
|
||||
}
|
||||
statusView.revealContentWarningButton.isHidden = false
|
||||
statusView.contentWarningOverlayView.isHidden = true
|
||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = false
|
||||
statusView.playerContainerView.contentWarningOverlayView.isHidden = false
|
||||
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||
|
||||
func updateContentOverlay() {
|
||||
let needsReveal: Bool = {
|
||||
if documentStore.defaultRevealStatusDict[status.id] == true {
|
||||
return true
|
||||
}
|
||||
if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}()
|
||||
attribute.isRevealing.value = needsReveal
|
||||
if needsReveal {
|
||||
statusView.updateRevealContentWarningButton(isRevealing: true)
|
||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView)
|
||||
statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView)
|
||||
} else {
|
||||
statusView.updateRevealContentWarningButton(isRevealing: false)
|
||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView)
|
||||
statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView)
|
||||
}
|
||||
}
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) {
|
||||
updateContentOverlay()
|
||||
} completion: { _ in
|
||||
// do nothing
|
||||
}
|
||||
} else {
|
||||
updateContentOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func configureThreadMeta(
|
||||
cell: StatusTableViewCell,
|
||||
status: Status
|
||||
) {
|
||||
cell.selectionStyle = .none
|
||||
cell.threadMetaView.dateLabel.text = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: status.createdAt)
|
||||
}()
|
||||
cell.threadMetaView.dateLabel.accessibilityLabel = DateFormatter.localizedString(from: status.createdAt, dateStyle: .medium, timeStyle: .short)
|
||||
let reblogCountTitle: String = {
|
||||
let count = status.reblogsCount.intValue
|
||||
if count > 1 {
|
||||
return L10n.Scene.Thread.Reblog.multiple(String(count))
|
||||
} else {
|
||||
return L10n.Scene.Thread.Reblog.single(String(count))
|
||||
}
|
||||
}()
|
||||
cell.threadMetaView.reblogButton.setTitle(reblogCountTitle, for: .normal)
|
||||
|
||||
let favoriteCountTitle: String = {
|
||||
let count = status.favouritesCount.intValue
|
||||
if count > 1 {
|
||||
return L10n.Scene.Thread.Favorite.multiple(String(count))
|
||||
} else {
|
||||
return L10n.Scene.Thread.Favorite.single(String(count))
|
||||
}
|
||||
}()
|
||||
cell.threadMetaView.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
|
||||
|
||||
cell.threadMetaView.isHidden = false
|
||||
}
|
||||
|
||||
|
||||
static func configureHeader(
|
||||
cell: StatusCell,
|
||||
status: Status
|
||||
) {
|
||||
if status.reblog != nil {
|
||||
cell.statusView.headerContainerView.isHidden = false
|
||||
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
|
||||
let headerText: String = {
|
||||
let author = status.author
|
||||
let name = author.displayName.isEmpty ? author.username : author.displayName
|
||||
return L10n.Common.Controls.Status.userReblogged(name)
|
||||
}()
|
||||
cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.author.emojiDict)
|
||||
cell.statusView.headerInfoLabel.isAccessibilityElement = true
|
||||
} else if status.inReplyToID != nil {
|
||||
cell.statusView.headerContainerView.isHidden = false
|
||||
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
||||
let headerText: String = {
|
||||
guard let replyTo = status.replyTo else {
|
||||
return L10n.Common.Controls.Status.userRepliedTo("-")
|
||||
}
|
||||
let author = replyTo.author
|
||||
let name = author.displayName.isEmpty ? author.username : author.displayName
|
||||
return L10n.Common.Controls.Status.userRepliedTo(name)
|
||||
}()
|
||||
cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:])
|
||||
cell.statusView.headerInfoLabel.isAccessibilityElement = true
|
||||
} else {
|
||||
cell.statusView.headerContainerView.isHidden = true
|
||||
cell.statusView.headerInfoLabel.isAccessibilityElement = false
|
||||
}
|
||||
}
|
||||
|
||||
static func configureActionToolBar(
|
||||
cell: StatusTableViewCell,
|
||||
dependency: NeedsDependency,
|
||||
status: Status,
|
||||
requestUserID: String
|
||||
) {
|
||||
let status = status.reblog ?? status
|
||||
|
||||
// set reply
|
||||
let replyCountTitle: String = {
|
||||
let count = status.repliesCount?.intValue ?? 0
|
||||
return StatusSection.formattedNumberTitleForActionButton(count)
|
||||
}()
|
||||
cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal)
|
||||
cell.statusView.actionToolbarContainer.replyButton.accessibilityValue = status.repliesCount.flatMap {
|
||||
L10n.Common.Controls.Timeline.Accessibility.countReplies($0.intValue)
|
||||
} ?? nil
|
||||
// set reblog
|
||||
let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
|
||||
let reblogCountTitle: String = {
|
||||
let count = status.reblogsCount.intValue
|
||||
return StatusSection.formattedNumberTitleForActionButton(count)
|
||||
}()
|
||||
cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal)
|
||||
cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged
|
||||
cell.statusView.actionToolbarContainer.reblogButton.accessibilityLabel = isReblogged ? L10n.Common.Controls.Status.Actions.unreblog : L10n.Common.Controls.Status.Actions.reblog
|
||||
cell.statusView.actionToolbarContainer.reblogButton.accessibilityValue = {
|
||||
guard status.reblogsCount.intValue > 0 else { return nil }
|
||||
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue)
|
||||
}()
|
||||
// set like
|
||||
let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false
|
||||
let favoriteCountTitle: String = {
|
||||
let count = status.favouritesCount.intValue
|
||||
return StatusSection.formattedNumberTitleForActionButton(count)
|
||||
}()
|
||||
cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal)
|
||||
cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike
|
||||
cell.statusView.actionToolbarContainer.favoriteButton.accessibilityLabel = isLike ? L10n.Common.Controls.Status.Actions.unfavorite : L10n.Common.Controls.Status.Actions.favorite
|
||||
cell.statusView.actionToolbarContainer.favoriteButton.accessibilityValue = {
|
||||
guard status.favouritesCount.intValue > 0 else { return nil }
|
||||
return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue)
|
||||
}()
|
||||
Publishers.CombineLatest(
|
||||
dependency.context.blockDomainService.blockedDomains,
|
||||
ManagedObjectObserver.observe(object: status.authorForUserProvider)
|
||||
.assertNoFailure()
|
||||
)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak dependency, weak cell] _, change in
|
||||
guard let cell = cell else { return }
|
||||
guard let dependency = dependency else { return }
|
||||
switch change.changeType {
|
||||
case .delete:
|
||||
return
|
||||
case .update(_):
|
||||
break
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status)
|
||||
}
|
||||
|
||||
static func configurePoll(
|
||||
cell: StatusCell,
|
||||
poll: Poll?,
|
||||
requestUserID: String,
|
||||
updateProgressAnimated: Bool,
|
||||
timestampUpdatePublisher: AnyPublisher<Date, Never>
|
||||
) {
|
||||
guard let poll = poll,
|
||||
let managedObjectContext = poll.managedObjectContext
|
||||
else {
|
||||
cell.statusView.pollTableView.isHidden = true
|
||||
cell.statusView.pollStatusStackView.isHidden = true
|
||||
cell.statusView.pollVoteButton.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
cell.statusView.pollTableView.isHidden = false
|
||||
cell.statusView.pollStatusStackView.isHidden = false
|
||||
cell.statusView.pollVoteCountLabel.text = {
|
||||
if poll.multiple {
|
||||
let count = poll.votersCount?.intValue ?? 0
|
||||
if count > 1 {
|
||||
return L10n.Common.Controls.Status.Poll.VoterCount.single(count)
|
||||
} else {
|
||||
return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count)
|
||||
}
|
||||
} else {
|
||||
let count = poll.votesCount.intValue
|
||||
if count > 1 {
|
||||
return L10n.Common.Controls.Status.Poll.VoteCount.single(count)
|
||||
} else {
|
||||
return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if poll.expired {
|
||||
cell.pollCountdownSubscription = nil
|
||||
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed
|
||||
} else if let expiresAt = poll.expiresAt {
|
||||
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
|
||||
cell.pollCountdownSubscription = timestampUpdatePublisher
|
||||
.sink { _ in
|
||||
cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow)
|
||||
}
|
||||
} else {
|
||||
// assertionFailure()
|
||||
cell.pollCountdownSubscription = nil
|
||||
cell.statusView.pollCountdownLabel.text = "-"
|
||||
}
|
||||
|
||||
cell.statusView.pollTableView.allowsSelection = !poll.expired
|
||||
|
||||
let votedOptions = poll.options.filter { option in
|
||||
(option.votedBy ?? Set()).map(\.id).contains(requestUserID)
|
||||
}
|
||||
let didVotedLocal = !votedOptions.isEmpty
|
||||
let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID)
|
||||
cell.statusView.pollVoteButton.isEnabled = didVotedLocal
|
||||
cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired)
|
||||
|
||||
cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource(
|
||||
for: cell.statusView.pollTableView,
|
||||
managedObjectContext: managedObjectContext
|
||||
)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<PollSection, PollItem>()
|
||||
snapshot.appendSections([.main])
|
||||
|
||||
let pollItems = poll.options
|
||||
.sorted(by: { $0.index.intValue < $1.index.intValue })
|
||||
.map { option -> PollItem in
|
||||
let attribute: PollItem.Attribute = {
|
||||
let selectState: PollItem.Attribute.SelectState = {
|
||||
// check didVotedRemote later to make the local change possible
|
||||
if !votedOptions.isEmpty {
|
||||
return votedOptions.contains(option) ? .on : .off
|
||||
} else if poll.expired {
|
||||
return .none
|
||||
} else if didVotedRemote, votedOptions.isEmpty {
|
||||
return .none
|
||||
} else {
|
||||
return .off
|
||||
}
|
||||
}()
|
||||
let voteState: PollItem.Attribute.VoteState = {
|
||||
var needsReveal: Bool
|
||||
if poll.expired {
|
||||
needsReveal = true
|
||||
} else if didVotedRemote {
|
||||
needsReveal = true
|
||||
} else {
|
||||
needsReveal = false
|
||||
}
|
||||
guard needsReveal else { return .hidden }
|
||||
let percentage: Double = {
|
||||
guard poll.votesCount.intValue > 0 else { return 0.0 }
|
||||
return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue)
|
||||
}()
|
||||
let voted = votedOptions.isEmpty ? true : votedOptions.contains(option)
|
||||
return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated)
|
||||
}()
|
||||
return PollItem.Attribute(selectState: selectState, voteState: voteState)
|
||||
}()
|
||||
let option = PollItem.opion(objectID: option.objectID, attribute: attribute)
|
||||
return option
|
||||
}
|
||||
snapshot.appendItems(pollItems, toSection: .main)
|
||||
cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||
}
|
||||
|
||||
static func configureEmptyStateHeader(
|
||||
cell: TimelineHeaderTableViewCell,
|
||||
attribute: Item.EmptyStateHeaderAttribute
|
||||
) {
|
||||
cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage
|
||||
cell.timelineHeaderView.messageLabel.text = attribute.reason.message
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusSection {
|
||||
|
@ -204,4 +841,37 @@ extension StatusSection {
|
|||
guard let number = number, number > 0 else { return "" }
|
||||
return String(number)
|
||||
}
|
||||
|
||||
private static func setupStatusMoreButtonMenu(
|
||||
cell: StatusTableViewCell,
|
||||
dependency: NeedsDependency,
|
||||
status: Status) {
|
||||
|
||||
guard let userProvider = dependency as? UserProvider else { fatalError() }
|
||||
|
||||
guard let authenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||
return
|
||||
}
|
||||
let author = status.authorForUserProvider
|
||||
let isMyself = authenticationBox.userID == author.id
|
||||
let isInSameDomain = authenticationBox.domain == author.domainFromAcct
|
||||
let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID)
|
||||
let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID)
|
||||
let isDomainBlocking = dependency.context.blockDomainService.blockedDomains.value.contains(author.domainFromAcct)
|
||||
cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true
|
||||
cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu(
|
||||
for: author,
|
||||
isMyself: isMyself,
|
||||
isMuting: isMuting,
|
||||
isBlocking: isBlocking,
|
||||
isInSameDomain: isInSameDomain,
|
||||
isDomainBlocking: isDomainBlocking,
|
||||
provider: userProvider,
|
||||
cell: cell,
|
||||
sourceView: cell.statusView.actionToolbarContainer.moreButton,
|
||||
barButtonItem: nil,
|
||||
shareUser: nil,
|
||||
shareStatus: status
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
// AVPlayer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/3/10.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
|
||||
// MARK: - CustomDebugStringConvertible
|
||||
extension AVPlayer.TimeControlStatus: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch self {
|
||||
case .paused: return "paused"
|
||||
case .waitingToPlayAtSpecifiedRate: return "waitingToPlayAtSpecifiedRate"
|
||||
case .playing: return "playing"
|
||||
@unknown default:
|
||||
assertionFailure()
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,40 +14,179 @@ extension ActiveLabel {
|
|||
|
||||
enum Style {
|
||||
case `default`
|
||||
case timelineHeaderView
|
||||
case statusHeader
|
||||
case statusName
|
||||
case profileField
|
||||
}
|
||||
|
||||
convenience init(style: Style) {
|
||||
self.init()
|
||||
|
||||
switch style {
|
||||
case .default:
|
||||
font = .preferredFont(forTextStyle: .body)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
case .timelineHeaderView:
|
||||
font = .preferredFont(forTextStyle: .footnote)
|
||||
textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
numberOfLines = 0
|
||||
lineSpacing = 5
|
||||
mentionColor = Asset.Colors.Label.highlight.color
|
||||
hashtagColor = Asset.Colors.Label.highlight.color
|
||||
URLColor = Asset.Colors.Label.highlight.color
|
||||
emojiPlaceholderColor = .systemFill
|
||||
#if DEBUG
|
||||
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
|
||||
#endif
|
||||
|
||||
accessibilityContainerType = .semanticGroup
|
||||
|
||||
switch style {
|
||||
case .default:
|
||||
font = .preferredFont(forTextStyle: .body)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
case .statusHeader:
|
||||
font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium), maximumPointSize: 17)
|
||||
textColor = Asset.Colors.Label.secondary.color
|
||||
numberOfLines = 1
|
||||
case .statusName:
|
||||
font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
numberOfLines = 1
|
||||
case .profileField:
|
||||
font = .preferredFont(forTextStyle: .body)
|
||||
textColor = Asset.Colors.Label.primary.color
|
||||
numberOfLines = 1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ActiveLabel {
|
||||
func config(content: String) {
|
||||
/// status content
|
||||
func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
|
||||
activeEntities.removeAll()
|
||||
if let parseResult = try? TootContent.parse(toot: content) {
|
||||
|
||||
if let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict) {
|
||||
text = parseResult.trimmed
|
||||
activeEntities = parseResult.activeEntities
|
||||
accessibilityLabel = parseResult.original
|
||||
} else {
|
||||
text = ""
|
||||
accessibilityLabel = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// account note
|
||||
func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) {
|
||||
configure(content: note, emojiDict: emojiDict)
|
||||
}
|
||||
}
|
||||
|
||||
extension ActiveLabel {
|
||||
/// account field
|
||||
func configure(field: String) {
|
||||
activeEntities.removeAll()
|
||||
let parseResult = MastodonField.parse(field: field)
|
||||
text = parseResult.value
|
||||
activeEntities = parseResult.activeEntities
|
||||
accessibilityLabel = parseResult.value
|
||||
}
|
||||
}
|
||||
|
||||
extension ActiveEntity {
|
||||
|
||||
var accessibilityLabelDescription: String {
|
||||
switch self.type {
|
||||
case .email: return L10n.Common.Controls.Status.Tag.email
|
||||
case .hashtag: return L10n.Common.Controls.Status.Tag.hashtag
|
||||
case .mention: return L10n.Common.Controls.Status.Tag.mention
|
||||
case .url: return L10n.Common.Controls.Status.Tag.url
|
||||
case .emoji: return L10n.Common.Controls.Status.Tag.emoji
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityValueDescription: String {
|
||||
switch self.type {
|
||||
case .email(let text, _): return text
|
||||
case .hashtag(let text, _): return text
|
||||
case .mention(let text, _): return text
|
||||
case .url(_, let trimmed, _, _): return trimmed
|
||||
case .emoji(let text, _, _): return text
|
||||
}
|
||||
}
|
||||
|
||||
func accessibilityElement(in accessibilityContainer: Any) -> ActiveLabelAccessibilityElement? {
|
||||
if case .emoji = self.type {
|
||||
return nil
|
||||
}
|
||||
|
||||
let element = ActiveLabelAccessibilityElement(accessibilityContainer: accessibilityContainer)
|
||||
element.accessibilityTraits = .button
|
||||
element.accessibilityLabel = accessibilityLabelDescription
|
||||
element.accessibilityValue = accessibilityValueDescription
|
||||
return element
|
||||
}
|
||||
}
|
||||
|
||||
final class ActiveLabelAccessibilityElement: UIAccessibilityElement {
|
||||
var index: Int!
|
||||
}
|
||||
|
||||
// MARK: - UIAccessibilityContainer
|
||||
extension ActiveLabel {
|
||||
|
||||
func createAccessibilityElements() -> [UIAccessibilityElement] {
|
||||
var elements: [UIAccessibilityElement] = []
|
||||
|
||||
let element = ActiveLabelAccessibilityElement(accessibilityContainer: self)
|
||||
element.accessibilityTraits = .staticText
|
||||
element.accessibilityLabel = accessibilityLabel
|
||||
element.accessibilityFrame = superview!.convert(frame, to: nil)
|
||||
element.accessibilityLanguage = accessibilityLanguage
|
||||
elements.append(element)
|
||||
|
||||
for eneity in activeEntities {
|
||||
guard let element = eneity.accessibilityElement(in: self) else { continue }
|
||||
var glyphRange = NSRange()
|
||||
layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange)
|
||||
let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
element.accessibilityFrame = self.convert(rect, to: nil)
|
||||
element.accessibilityContainer = self
|
||||
elements.append(element)
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
// public override func accessibilityElementCount() -> Int {
|
||||
// return 1 + activeEntities.count
|
||||
// }
|
||||
//
|
||||
// public override func accessibilityElement(at index: Int) -> Any? {
|
||||
// if index == 0 {
|
||||
// let element = ActiveLabelAccessibilityElement(accessibilityContainer: self)
|
||||
// element.accessibilityTraits = .staticText
|
||||
// element.accessibilityLabel = accessibilityLabel
|
||||
// element.accessibilityFrame = superview!.convert(frame, to: nil)
|
||||
// element.index = index
|
||||
// return element
|
||||
// }
|
||||
//
|
||||
// let index = index - 1
|
||||
// guard index < activeEntities.count else { return nil }
|
||||
// let eneity = activeEntities[index]
|
||||
// guard let element = eneity.accessibilityElement(in: self) else { return nil }
|
||||
//
|
||||
// var glyphRange = NSRange()
|
||||
// layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange)
|
||||
// let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
// element.accessibilityFrame = self.convert(rect, to: nil)
|
||||
// element.accessibilityContainer = self
|
||||
//
|
||||
// return element
|
||||
// }
|
||||
//
|
||||
// public override func index(ofAccessibilityElement element: Any) -> Int {
|
||||
// guard let element = element as? ActiveLabelAccessibilityElement,
|
||||
// let index = element.index else {
|
||||
// return NSNotFound
|
||||
// }
|
||||
//
|
||||
// return index
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
// Array.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by BradGao on 2021/3/31.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array
|
||||
extension Array where Element: Hashable {
|
||||
func removingDuplicates() -> [Element] {
|
||||
var addedDict = [Element: Bool]()
|
||||
|
||||
return filter {
|
||||
addedDict.updateValue(true, forKey: $0) == nil
|
||||
}
|
||||
}
|
||||
|
||||
mutating func removeDuplicates() {
|
||||
self = self.removingDuplicates()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// CryptoSwift
|
||||
//
|
||||
// Copyright (C) 2014-2017 Marcin Krzyżanowski <marcin@krzyzanowskim.com>
|
||||
// This software is provided 'as-is', without any express or implied warranty.
|
||||
//
|
||||
// In no event will the authors be held liable for any damages arising from the use of this software.
|
||||
//
|
||||
// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:
|
||||
//
|
||||
// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required.
|
||||
// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
|
||||
// - This notice may not be removed or altered from any source or binary distribution.
|
||||
//
|
||||
|
||||
extension Array {
|
||||
init(reserveCapacity: Int) {
|
||||
self = Array<Element>()
|
||||
self.reserveCapacity(reserveCapacity)
|
||||
}
|
||||
|
||||
var slice: ArraySlice<Element> {
|
||||
self[self.startIndex ..< self.endIndex]
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == UInt8 {
|
||||
public init(hex: String) {
|
||||
self.init(reserveCapacity: hex.unicodeScalars.lazy.underestimatedCount)
|
||||
var buffer: UInt8?
|
||||
var skip = hex.hasPrefix("0x") ? 2 : 0
|
||||
for char in hex.unicodeScalars.lazy {
|
||||
guard skip == 0 else {
|
||||
skip -= 1
|
||||
continue
|
||||
}
|
||||
guard char.value >= 48 && char.value <= 102 else {
|
||||
removeAll()
|
||||
return
|
||||
}
|
||||
let v: UInt8
|
||||
let c: UInt8 = UInt8(char.value)
|
||||
switch c {
|
||||
case let c where c <= 57:
|
||||
v = c - 48
|
||||
case let c where c >= 65 && c <= 70:
|
||||
v = c - 55
|
||||
case let c where c >= 97:
|
||||
v = c - 87
|
||||
default:
|
||||
removeAll()
|
||||
return
|
||||
}
|
||||
if let b = buffer {
|
||||
append(b << 4 | v)
|
||||
buffer = nil
|
||||
} else {
|
||||
buffer = v
|
||||
}
|
||||
}
|
||||
if let b = buffer {
|
||||
append(b)
|
||||
}
|
||||
}
|
||||
|
||||
public func toHexString() -> String {
|
||||
`lazy`.reduce(into: "") {
|
||||
var s = String($1, radix: 16)
|
||||
if s.count == 1 {
|
||||
s = "0" + s
|
||||
}
|
||||
$0 += s
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
//
|
||||
// CALayer.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-2-26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension CALayer {
|
||||
|
||||
func setupShadow(
|
||||
color: UIColor = .black,
|
||||
alpha: Float = 0.5,
|
||||
x: CGFloat = 0,
|
||||
y: CGFloat = 2,
|
||||
blur: CGFloat = 4,
|
||||
spread: CGFloat = 0,
|
||||
roundedRect: CGRect? = nil,
|
||||
byRoundingCorners corners: UIRectCorner? = nil,
|
||||
cornerRadii: CGSize? = nil
|
||||
) {
|
||||
// assert(roundedRect != .zero)
|
||||
shadowColor = color.cgColor
|
||||
shadowOpacity = alpha
|
||||
shadowOffset = CGSize(width: x, height: y)
|
||||
shadowRadius = blur / 2
|
||||
rasterizationScale = UIScreen.main.scale
|
||||
shouldRasterize = true
|
||||
masksToBounds = false
|
||||
|
||||
guard let roundedRect = roundedRect,
|
||||
let corners = corners,
|
||||
let cornerRadii = cornerRadii else {
|
||||
return
|
||||
}
|
||||
|
||||
if spread == 0 {
|
||||
shadowPath = UIBezierPath(roundedRect: roundedRect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath
|
||||
} else {
|
||||
let rect = roundedRect.insetBy(dx: -spread, dy: -spread)
|
||||
shadowPath = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath
|
||||
}
|
||||
}
|
||||
|
||||
func removeShadow() {
|
||||
shadowRadius = 0
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
//
|
||||
// CGImage.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-31.
|
||||
//
|
||||
|
||||
import CoreImage
|
||||
|
||||
extension CGImage {
|
||||
// Reference
|
||||
// https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf
|
||||
// Luma Y = 0.2126R + 0.7152G + 0.0722B
|
||||
var brightness: CGFloat? {
|
||||
let context = CIContext() // default with metal accelerate
|
||||
let ciImage = CIImage(cgImage: self)
|
||||
let rec709Image = context.createCGImage(
|
||||
ciImage,
|
||||
from: ciImage.extent,
|
||||
format: .RGBA8,
|
||||
colorSpace: CGColorSpace(name: CGColorSpace.itur_709) // BT.709 a.k.a Rec.709
|
||||
)
|
||||
guard let image = rec709Image,
|
||||
image.bitsPerPixel == 32,
|
||||
let data = rec709Image?.dataProvider?.data,
|
||||
let pointer = CFDataGetBytePtr(data) else { return nil }
|
||||
|
||||
let length = CFDataGetLength(data)
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
var luma: CGFloat = 0.0
|
||||
for i in stride(from: 0, to: length, by: 4) {
|
||||
let r = pointer[i]
|
||||
let g = pointer[i + 1]
|
||||
let b = pointer[i + 2]
|
||||
let Y = 0.2126 * CGFloat(r) + 0.7152 * CGFloat(g) + 0.0722 * CGFloat(b)
|
||||
luma += Y
|
||||
}
|
||||
luma /= CGFloat(width * height)
|
||||
return luma
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class BrightnessView: UIView {
|
||||
let label = UILabel()
|
||||
let imageView = UIImageView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
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),
|
||||
])
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.addArrangedSubview(imageView)
|
||||
stackView.addArrangedSubview(label)
|
||||
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.masksToBounds = true
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setImage(_ image: UIImage) {
|
||||
imageView.image = image
|
||||
|
||||
guard let brightness = image.cgImage?.brightness,
|
||||
let style = image.domainLumaCoefficientsStyle else {
|
||||
label.text = "<nil>"
|
||||
return
|
||||
}
|
||||
let styleDescription: String = {
|
||||
switch style {
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
case .unspecified: fallthrough
|
||||
@unknown default:
|
||||
return "Unknown"
|
||||
}
|
||||
}()
|
||||
|
||||
label.text = styleDescription + "\n" + "\(brightness)"
|
||||
}
|
||||
}
|
||||
|
||||
struct CGImage_Brightness_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UIViewPreview(width: 375) {
|
||||
let view = BrightnessView()
|
||||
view.setImage(.placeholder(color: .black))
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 375) {
|
||||
let view = BrightnessView()
|
||||
view.setImage(.placeholder(color: .gray))
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 375) {
|
||||
let view = BrightnessView()
|
||||
view.setImage(.placeholder(color: .separator))
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 375) {
|
||||
let view = BrightnessView()
|
||||
view.setImage(.placeholder(color: .red))
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 375) {
|
||||
let view = BrightnessView()
|
||||
view.setImage(.placeholder(color: .green))
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 375) {
|
||||
let view = BrightnessView()
|
||||
view.setImage(.placeholder(color: .blue))
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 375) {
|
||||
let view = BrightnessView()
|
||||
view.setImage(.placeholder(color: .secondarySystemGroupedBackground))
|
||||
return view
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
//
|
||||
// Emojis.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-5-7.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
protocol EmojiContinaer {
|
||||
var emojisData: Data? { get }
|
||||
}
|
||||
|
||||
extension EmojiContinaer {
|
||||
|
||||
static func encode(emojis: [Mastodon.Entity.Emoji]) -> Data? {
|
||||
return try? JSONEncoder().encode(emojis)
|
||||
}
|
||||
|
||||
var emojis: [Mastodon.Entity.Emoji]? {
|
||||
let decoder = JSONDecoder()
|
||||
return emojisData.flatMap { try? decoder.decode([Mastodon.Entity.Emoji].self, from: $0) }
|
||||
}
|
||||
|
||||
var emojiDict: MastodonStatusContent.EmojiDict {
|
||||
var dict = MastodonStatusContent.EmojiDict()
|
||||
for emoji in emojis ?? [] {
|
||||
guard let url = URL(string: emoji.url) else { continue }
|
||||
dict[emoji.shortcode] = url
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -19,6 +19,17 @@ extension MastodonUser.Property {
|
|||
displayName: entity.displayName,
|
||||
avatar: entity.avatar,
|
||||
avatarStatic: entity.avatarStatic,
|
||||
header: entity.header,
|
||||
headerStatic: entity.headerStatic,
|
||||
note: entity.note,
|
||||
url: entity.url,
|
||||
emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) },
|
||||
statusesCount: entity.statusesCount,
|
||||
followingCount: entity.followingCount,
|
||||
followersCount: entity.followersCount,
|
||||
locked: entity.locked,
|
||||
bot: entity.bot,
|
||||
suspended: entity.suspended,
|
||||
createdAt: entity.createdAt,
|
||||
networkDate: networkDate
|
||||
)
|
||||
|
@ -26,7 +37,67 @@ extension MastodonUser.Property {
|
|||
}
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
var displayNameWithFallback: String {
|
||||
return !displayName.isEmpty ? displayName : username
|
||||
}
|
||||
|
||||
var acctWithDomain: String {
|
||||
if !acct.contains("@") {
|
||||
// Safe concat due to username cannot contains "@"
|
||||
return username + "@" + domain
|
||||
} else {
|
||||
return acct
|
||||
}
|
||||
}
|
||||
|
||||
var domainFromAcct: String {
|
||||
if !acct.contains("@") {
|
||||
return domain
|
||||
} else {
|
||||
let domain = acct.split(separator: "@").last
|
||||
return String(domain!)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
public func headerImageURL() -> URL? {
|
||||
return URL(string: header)
|
||||
}
|
||||
|
||||
public func headerImageURLWithFallback(domain: String) -> URL {
|
||||
return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")!
|
||||
}
|
||||
|
||||
public func avatarImageURL() -> URL? {
|
||||
return URL(string: avatar)
|
||||
}
|
||||
|
||||
public func avatarImageURLWithFallback(domain: String) -> URL {
|
||||
return URL(string: avatar) ?? URL(string: "https://\(domain)/avatars/original/missing.png")!
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MastodonUser {
|
||||
|
||||
var profileURL: URL {
|
||||
if let urlString = self.url,
|
||||
let url = URL(string: urlString) {
|
||||
return url
|
||||
} else {
|
||||
return URL(string: "https://\(self.domain)/@\(username)")!
|
||||
}
|
||||
}
|
||||
|
||||
var activityItems: [Any] {
|
||||
var items: [Any] = []
|
||||
items.append(profileURL)
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
extension MastodonUser: EmojiContinaer { }
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Setting.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension Setting {
|
||||
|
||||
var appearance: SettingsItem.AppearanceMode {
|
||||
return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic
|
||||
}
|
||||
|
||||
var activeSubscription: Subscription? {
|
||||
return (subscriptions ?? Set())
|
||||
.sorted(by: { $0.activedAt > $1.activedAt })
|
||||
.first
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
//
|
||||
// Status.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021/2/4.
|
||||
//
|
||||
|
||||
import CoreDataStack
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension Status.Property {
|
||||
init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) {
|
||||
self.init(
|
||||
domain: domain,
|
||||
id: entity.id,
|
||||
uri: entity.uri,
|
||||
createdAt: entity.createdAt,
|
||||
content: entity.content!,
|
||||
visibility: entity.visibility?.rawValue,
|
||||
sensitive: entity.sensitive ?? false,
|
||||
spoilerText: entity.spoilerText,
|
||||
emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) },
|
||||
reblogsCount: NSNumber(value: entity.reblogsCount),
|
||||
favouritesCount: NSNumber(value: entity.favouritesCount),
|
||||
repliesCount: entity.repliesCount.flatMap { NSNumber(value: $0) },
|
||||
url: entity.uri,
|
||||
inReplyToID: entity.inReplyToID,
|
||||
inReplyToAccountID: entity.inReplyToAccountID,
|
||||
language: entity.language,
|
||||
text: entity.text,
|
||||
networkDate: networkDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Status {
|
||||
enum SensitiveType {
|
||||
case none
|
||||
case all
|
||||
case media(isSensitive: Bool)
|
||||
}
|
||||
|
||||
var sensitiveType: SensitiveType {
|
||||
let spoilerText = self.spoilerText ?? ""
|
||||
|
||||
// cast .all sensitive when has spoiter text
|
||||
if !spoilerText.isEmpty {
|
||||
return .all
|
||||
}
|
||||
|
||||
if let firstAttachment = mediaAttachments?.first {
|
||||
// cast .media when has non audio media
|
||||
if firstAttachment.type != .audio {
|
||||
return .media(isSensitive: sensitive)
|
||||
} else {
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
// not sensitive
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
extension Status {
|
||||
var authorForUserProvider: MastodonUser {
|
||||
let author = (reblog ?? self).author
|
||||
return author
|
||||
}
|
||||
}
|
||||
|
||||
extension Status {
|
||||
var statusURL: URL {
|
||||
if let urlString = self.url,
|
||||
let url = URL(string: urlString)
|
||||
{
|
||||
return url
|
||||
} else {
|
||||
return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")!
|
||||
}
|
||||
}
|
||||
|
||||
var activityItems: [Any] {
|
||||
var items: [Any] = []
|
||||
items.append(self.statusURL)
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
extension Status: EmojiContinaer { }
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// Subscription.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
typealias NotificationSubscription = Subscription
|
||||
|
||||
extension Subscription {
|
||||
|
||||
var policy: Mastodon.API.Subscriptions.Policy {
|
||||
return Mastodon.API.Subscriptions.Policy(rawValue: policyRaw) ?? .all
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// SubscriptionAlerts.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-25.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension SubscriptionAlerts.Property {
|
||||
|
||||
init(policy: Mastodon.API.Subscriptions.Policy) {
|
||||
switch policy {
|
||||
case .all:
|
||||
self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true)
|
||||
case .follower:
|
||||
self.init(favourite: true, follow: nil, followRequest: nil, mention: true, poll: true, reblog: true)
|
||||
case .followed:
|
||||
self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true)
|
||||
case .none, ._other:
|
||||
self.init(favourite: nil, follow: nil, followRequest: nil, mention: nil, poll: nil, reblog: nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
//
|
||||
// Toot.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021/2/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
extension Toot.Property {
|
||||
init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) {
|
||||
self.init(
|
||||
domain: domain,
|
||||
id: entity.id,
|
||||
uri: entity.uri,
|
||||
createdAt: entity.createdAt,
|
||||
content: entity.content,
|
||||
visibility: entity.visibility?.rawValue,
|
||||
sensitive: entity.sensitive ?? false,
|
||||
spoilerText: entity.spoilerText,
|
||||
reblogsCount: NSNumber(value: entity.reblogsCount),
|
||||
favouritesCount: NSNumber(value: entity.favouritesCount),
|
||||
repliesCount: entity.repliesCount.flatMap { NSNumber(value: $0) },
|
||||
url: entity.uri,
|
||||
inReplyToID: entity.inReplyToID,
|
||||
inReplyToAccountID: entity.inReplyToAccountID,
|
||||
language: entity.language,
|
||||
text: entity.text,
|
||||
networkDate: networkDate
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
//
|
||||
// Double.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
func asString(style: DateComponentsFormatter.UnitsStyle) -> String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.minute, .second]
|
||||
formatter.unitsStyle = style
|
||||
formatter.zeroFormattingBehavior = .pad
|
||||
guard let formattedString = formatter.string(from: self) else { return "" }
|
||||
return formattedString
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// Mastodon+API+Subscriptions+Policy.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-4-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.API.Subscriptions.Policy {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: return L10n.Scene.Settings.Section.Notifications.Trigger.anyone
|
||||
case .follower: return L10n.Scene.Settings.Section.Notifications.Trigger.follower
|
||||
case .followed: return L10n.Scene.Settings.Section.Notifications.Trigger.follow
|
||||
case .none, ._other: return L10n.Scene.Settings.Section.Notifications.Trigger.noone
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// Mastodon+Entity+Account.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/4/2.
|
||||
//
|
||||
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.Account: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
//
|
||||
// Mastodon+Entity+ErrorDetailReason.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/1.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.Error.Detail: LocalizedError {
|
||||
|
||||
public var failureReason: String? {
|
||||
let reasons: [[String]] = [
|
||||
usernameErrorDescriptions,
|
||||
emailErrorDescriptions,
|
||||
passwordErrorDescriptions,
|
||||
agreementErrorDescriptions,
|
||||
localeErrorDescriptions,
|
||||
reasonErrorDescriptions,
|
||||
]
|
||||
|
||||
guard !reasons.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return reasons
|
||||
.flatMap { $0 }
|
||||
.joined(separator: "; ")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Mastodon.Entity.Error.Detail {
|
||||
|
||||
enum Item: String {
|
||||
case username
|
||||
case email
|
||||
case password
|
||||
case agreement
|
||||
case locale
|
||||
case reason
|
||||
|
||||
var localized: String {
|
||||
switch self {
|
||||
case .username: return L10n.Scene.Register.Error.Item.username
|
||||
case .email: return L10n.Scene.Register.Error.Item.email
|
||||
case .password: return L10n.Scene.Register.Error.Item.password
|
||||
case .agreement: return L10n.Scene.Register.Error.Item.agreement
|
||||
case .locale: return L10n.Scene.Register.Error.Item.locale
|
||||
case .reason: return L10n.Scene.Register.Error.Item.reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func localizeError(item: Item, for reason: Reason) -> String {
|
||||
switch (item, reason.error) {
|
||||
case (.username, .ERR_INVALID):
|
||||
return L10n.Scene.Register.Error.Special.usernameInvalid
|
||||
case (.username, .ERR_TOO_LONG):
|
||||
return L10n.Scene.Register.Error.Special.usernameTooLong
|
||||
case (.email, .ERR_INVALID):
|
||||
return L10n.Scene.Register.Error.Special.emailInvalid
|
||||
case (.password, .ERR_TOO_SHORT):
|
||||
return L10n.Scene.Register.Error.Special.passwordTooShort
|
||||
case (_, .ERR_BLOCKED): return L10n.Scene.Register.Error.Reason.blocked(item.localized)
|
||||
case (_, .ERR_UNREACHABLE): return L10n.Scene.Register.Error.Reason.unreachable(item.localized)
|
||||
case (_, .ERR_TAKEN): return L10n.Scene.Register.Error.Reason.taken(item.localized)
|
||||
case (_, .ERR_RESERVED): return L10n.Scene.Register.Error.Reason.reserved(item.localized)
|
||||
case (_, .ERR_ACCEPTED): return L10n.Scene.Register.Error.Reason.accepted(item.localized)
|
||||
case (_, .ERR_BLANK): return L10n.Scene.Register.Error.Reason.blank(item.localized)
|
||||
case (_, .ERR_INVALID): return L10n.Scene.Register.Error.Reason.invalid(item.localized)
|
||||
case (_, .ERR_TOO_LONG): return L10n.Scene.Register.Error.Reason.tooLong(item.localized)
|
||||
case (_, .ERR_TOO_SHORT): return L10n.Scene.Register.Error.Reason.tooShort(item.localized)
|
||||
case (_, .ERR_INCLUSION): return L10n.Scene.Register.Error.Reason.inclusion(item.localized)
|
||||
case (_, ._other(let reason)):
|
||||
assertionFailure("Needs handle new error description here")
|
||||
return item.rawValue + " " + reason.description
|
||||
}
|
||||
}
|
||||
|
||||
var usernameErrorDescriptions: [String] {
|
||||
guard let username = username, !username.isEmpty else { return [] }
|
||||
return username.map { Mastodon.Entity.Error.Detail.localizeError(item: .username, for: $0) }
|
||||
}
|
||||
|
||||
var emailErrorDescriptions: [String] {
|
||||
guard let email = email, !email.isEmpty else { return [] }
|
||||
return email.map { Mastodon.Entity.Error.Detail.localizeError(item: .email, for: $0) }
|
||||
}
|
||||
|
||||
var passwordErrorDescriptions: [String] {
|
||||
guard let password = password, !password.isEmpty else { return [] }
|
||||
return password.map { Mastodon.Entity.Error.Detail.localizeError(item: .password, for: $0) }
|
||||
}
|
||||
|
||||
var agreementErrorDescriptions: [String] {
|
||||
guard let agreement = agreement, !agreement.isEmpty else { return [] }
|
||||
return agreement.map { Mastodon.Entity.Error.Detail.localizeError(item: .agreement, for: $0) }
|
||||
}
|
||||
|
||||
var localeErrorDescriptions: [String] {
|
||||
guard let locale = locale, !locale.isEmpty else { return [] }
|
||||
return locale.map { Mastodon.Entity.Error.Detail.localizeError(item: .locale, for: $0) }
|
||||
}
|
||||
|
||||
var reasonErrorDescriptions: [String] {
|
||||
guard let reason = reason, !reason.isEmpty else { return [] }
|
||||
return reason.map { Mastodon.Entity.Error.Detail.localizeError(item: .reason, for: $0) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// Mastodon+Entity+Error.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.API.Error: LocalizedError {
|
||||
|
||||
public var errorDescription: String? {
|
||||
guard let mastodonError = mastodonError else {
|
||||
return "HTTP \(httpResponseStatus.code)"
|
||||
}
|
||||
switch mastodonError {
|
||||
case .generic(let error):
|
||||
if let _ = error.details {
|
||||
return nil // Duplicated with the details
|
||||
} else {
|
||||
return error.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var failureReason: String? {
|
||||
guard let mastodonError = mastodonError else {
|
||||
return httpResponseStatus.reasonPhrase
|
||||
}
|
||||
switch mastodonError {
|
||||
case .generic(let error):
|
||||
if let details = error.details {
|
||||
return details.failureReason
|
||||
} else {
|
||||
return error.errorDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// Mastodon+Entity+History.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by xiaojian sun on 2021/4/2.
|
||||
//
|
||||
|
||||
import MastodonSDK
|
||||
|
||||
extension Mastodon.Entity.History: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(uses)
|
||||
hasher.combine(accounts)
|
||||
hasher.combine(day)
|
||||
}
|
||||
|
||||
public static func == (lhs: Mastodon.Entity.History, rhs: Mastodon.Entity.History) -> Bool {
|
||||
return lhs.uses == rhs.uses && lhs.uses == rhs.uses && lhs.day == rhs.day
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// Mastodon+Entity+Notification+Type.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/4/19.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import UIKit
|
||||
|
||||
extension Mastodon.Entity.Notification.NotificationType {
|
||||
public var color: UIColor {
|
||||
get {
|
||||
var color: UIColor
|
||||
switch self {
|
||||
case .follow:
|
||||
color = Asset.Colors.brandBlue.color
|
||||
case .favourite:
|
||||
color = Asset.Colors.Notification.favourite.color
|
||||
case .reblog:
|
||||
color = Asset.Colors.Notification.reblog.color
|
||||
case .mention:
|
||||
color = Asset.Colors.Notification.mention.color
|
||||
case .poll:
|
||||
color = Asset.Colors.brandBlue.color
|
||||
case .followRequest:
|
||||
color = Asset.Colors.brandBlue.color
|
||||
default:
|
||||
color = .clear
|
||||
}
|
||||
return color
|
||||
}
|
||||
}
|
||||
|
||||
public var actionText: String {
|
||||
get {
|
||||
var actionText: String
|
||||
switch self {
|
||||
case .follow:
|
||||
actionText = L10n.Scene.Notification.Action.follow
|
||||
case .favourite:
|
||||
actionText = L10n.Scene.Notification.Action.favourite
|
||||
case .reblog:
|
||||
actionText = L10n.Scene.Notification.Action.reblog
|
||||
case .mention:
|
||||
actionText = L10n.Scene.Notification.Action.mention
|
||||
case .poll:
|
||||
actionText = L10n.Scene.Notification.Action.poll
|
||||
case .followRequest:
|
||||
actionText = L10n.Scene.Notification.Action.followRequest
|
||||
default:
|
||||
actionText = ""
|
||||
}
|
||||
return actionText
|
||||
}
|
||||
}
|
||||
|
||||
public var actionImageName: String {
|
||||
get {
|
||||
var actionImageName: String
|
||||
switch self {
|
||||
case .follow:
|
||||
actionImageName = "person.crop.circle.badge.checkmark"
|
||||
case .favourite:
|
||||
actionImageName = "star.fill"
|
||||
case .reblog:
|
||||
actionImageName = "arrow.2.squarepath"
|
||||
case .mention:
|
||||
actionImageName = "at"
|
||||
case .poll:
|
||||
actionImageName = "list.bullet"
|
||||
case .followRequest:
|
||||
actionImageName = "person.crop.circle"
|
||||
default:
|
||||
actionImageName = ""
|
||||
}
|
||||
return actionImageName
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
|
@ -12,4 +12,9 @@ extension NSLayoutConstraint {
|
|||
self.priority = priority
|
||||
return self
|
||||
}
|
||||
|
||||
func identifier(_ identifier: String?) -> Self {
|
||||
self.identifier = identifier
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// NSManagedObjectContext.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
func safeFetch<T>(_ request: NSFetchRequest<T>) -> [T] where T : NSFetchRequestResult {
|
||||
do {
|
||||
return try fetch(request)
|
||||
} catch {
|
||||
assertionFailure(error.localizedDescription)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// String.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func capitalizingFirstLetter() -> String {
|
||||
return prefix(1).capitalized + dropFirst()
|
||||
}
|
||||
|
||||
mutating func capitalizeFirstLetter() {
|
||||
self = self.capitalizingFirstLetter()
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
static func normalize(base64String: String) -> String {
|
||||
let base64 = base64String
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
.padding()
|
||||
return base64
|
||||
}
|
||||
|
||||
private func padding() -> String {
|
||||
let remainder = self.count % 4
|
||||
if remainder > 0 {
|
||||
return self.padding(
|
||||
toLength: self.count + 4 - remainder,
|
||||
withPad: "=",
|
||||
startingAt: 0
|
||||
)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import MastodonSDK
|
||||
// Reference:
|
||||
// https://nshipster.com/swift-foundation-error-protocols/
|
||||
extension UIAlertController {
|
||||
|
@ -42,4 +42,3 @@ extension UIAlertController {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,4 +17,3 @@ extension UIBarButtonItem {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -43,3 +43,11 @@ extension UIButton {
|
|||
}
|
||||
}
|
||||
|
||||
extension UIButton {
|
||||
func setBackgroundColor(_ color: UIColor, for state: UIControl.State) {
|
||||
self.setBackgroundImage(
|
||||
UIImage.placeholder(color: color),
|
||||
for: state
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// UIControl.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// A custom subscription to capture UIControl target events.
|
||||
final class UIControlSubscription<SubscriberType: Subscriber, Control: UIControl>: Subscription where SubscriberType.Input == Control {
|
||||
private var subscriber: SubscriberType?
|
||||
private let control: Control
|
||||
|
||||
init(subscriber: SubscriberType, control: Control, event: UIControl.Event) {
|
||||
self.subscriber = subscriber
|
||||
self.control = control
|
||||
control.addTarget(self, action: #selector(eventHandler), for: event)
|
||||
}
|
||||
|
||||
func request(_ demand: Subscribers.Demand) {
|
||||
// We do nothing here as we only want to send events when they occur.
|
||||
// See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
subscriber = nil
|
||||
}
|
||||
|
||||
@objc private func eventHandler() {
|
||||
_ = subscriber?.receive(control)
|
||||
}
|
||||
}
|
||||
|
||||
/// A custom `Publisher` to work with our custom `UIControlSubscription`.
|
||||
struct UIControlPublisher<Control: UIControl>: Publisher {
|
||||
|
||||
typealias Output = Control
|
||||
typealias Failure = Never
|
||||
|
||||
let control: Control
|
||||
let controlEvents: UIControl.Event
|
||||
|
||||
init(control: Control, events: UIControl.Event) {
|
||||
self.control = control
|
||||
self.controlEvents = events
|
||||
}
|
||||
|
||||
func receive<S>(subscriber: S) where S : Subscriber, S.Failure == UIControlPublisher.Failure, S.Input == UIControlPublisher.Output {
|
||||
let subscription = UIControlSubscription(subscriber: subscriber, control: control, event: controlEvents)
|
||||
subscriber.receive(subscription: subscription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extending the `UIControl` types to be able to produce a `UIControl.Event` publisher.
|
||||
protocol CombineCompatible { }
|
||||
extension UIControl: CombineCompatible { }
|
||||
extension CombineCompatible where Self: UIControl {
|
||||
func publisher(for events: UIControl.Event) -> UIControlPublisher<UIControl> {
|
||||
return UIControlPublisher(control: self, events: events)
|
||||
}
|
||||
}
|
|
@ -1,25 +1,23 @@
|
|||
//
|
||||
// UIIamge.swift
|
||||
// UIImage.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/1/28.
|
||||
// Created by sxiaojian on 2021/3/8.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
|
||||
static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage {
|
||||
let render = UIGraphicsImageRenderer(size: size)
|
||||
|
||||
|
||||
return render.image { (context: UIGraphicsImageRendererContext) in
|
||||
context.cgContext.setFillColor(color.cgColor)
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage
|
||||
|
@ -27,20 +25,27 @@ extension UIImage {
|
|||
@available(iOS 14.0, *)
|
||||
var dominantColor: UIColor? {
|
||||
guard let inputImage = CIImage(image: self) else { return nil }
|
||||
|
||||
|
||||
let filter = CIFilter.areaAverage()
|
||||
filter.inputImage = inputImage
|
||||
filter.extent = inputImage.extent
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
|
||||
|
||||
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||
let context = CIContext(options: [.workingColorSpace: kCFNull])
|
||||
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||
|
||||
|
||||
return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
var domainLumaCoefficientsStyle: UIUserInterfaceStyle? {
|
||||
guard let brightness = cgImage?.brightness else { return nil }
|
||||
return brightness > 100 ? .light : .dark // 0 ~ 255
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func blur(radius: CGFloat) -> UIImage? {
|
||||
guard let inputImage = CIImage(image: self) else { return nil }
|
||||
|
@ -53,3 +58,35 @@ extension UIImage {
|
|||
return image
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? {
|
||||
let maxRadius = min(size.width, size.height) / 2
|
||||
let cornerRadius: CGFloat = {
|
||||
guard let radius = radius, radius > 0 else { return maxRadius }
|
||||
return min(radius, maxRadius)
|
||||
}()
|
||||
|
||||
let render = UIGraphicsImageRenderer(size: size)
|
||||
return render.image { (_: UIGraphicsImageRendererContext) in
|
||||
let rect = CGRect(origin: .zero, size: size)
|
||||
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
|
||||
draw(in: rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
static func adaptiveUserInterfaceStyleImage(lightImage: UIImage, darkImage: UIImage) -> UIImage {
|
||||
let imageAsset = UIImageAsset()
|
||||
imageAsset.register(lightImage, with: UITraitCollection(traitsFrom: [
|
||||
UITraitCollection(displayScale: 1.0),
|
||||
UITraitCollection(userInterfaceStyle: .light)
|
||||
]))
|
||||
imageAsset.register(darkImage, with: UITraitCollection(traitsFrom: [
|
||||
UITraitCollection(displayScale: 1.0),
|
||||
UITraitCollection(userInterfaceStyle: .dark)
|
||||
]))
|
||||
return imageAsset.image(with: UITraitCollection.current)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// UIInterpolatingMotionEffect.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-2.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIInterpolatingMotionEffect {
|
||||
static func motionEffect(
|
||||
minX: CGFloat,
|
||||
maxX: CGFloat,
|
||||
minY: CGFloat,
|
||||
maxY: CGFloat
|
||||
) -> UIMotionEffectGroup {
|
||||
let motionEffectX = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.x", type: .tiltAlongHorizontalAxis)
|
||||
motionEffectX.minimumRelativeValue = minX
|
||||
motionEffectX.maximumRelativeValue = maxX
|
||||
|
||||
let motionEffectY = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.y", type: .tiltAlongVerticalAxis)
|
||||
motionEffectY.minimumRelativeValue = minY
|
||||
motionEffectY.maximumRelativeValue = maxY
|
||||
|
||||
let motionEffectGroup = UIMotionEffectGroup()
|
||||
motionEffectGroup.motionEffects = [motionEffectX, motionEffectY]
|
||||
|
||||
return motionEffectGroup
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// UINavigationController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-31.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// This not works!
|
||||
// SeeAlso: `AdaptiveStatusBarStyleNavigationController`
|
||||
extension UINavigationController {
|
||||
open override var childForStatusBarStyle: UIViewController? {
|
||||
return visibleViewController
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// UIScrollView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIScrollView {
|
||||
public enum ScrollDirection {
|
||||
case top
|
||||
case bottom
|
||||
case left
|
||||
case right
|
||||
}
|
||||
|
||||
public func scroll(to direction: ScrollDirection, animated: Bool) {
|
||||
let offset: CGPoint
|
||||
switch direction {
|
||||
case .top:
|
||||
offset = CGPoint(x: contentOffset.x, y: -adjustedContentInset.top)
|
||||
case .bottom:
|
||||
offset = CGPoint(x: contentOffset.x, y: max(-adjustedContentInset.top, contentSize.height - frame.height + adjustedContentInset.bottom))
|
||||
case .left:
|
||||
offset = CGPoint(x: -adjustedContentInset.left, y: contentOffset.y)
|
||||
case .right:
|
||||
offset = CGPoint(x: max(-adjustedContentInset.left, contentSize.width - frame.width + adjustedContentInset.right), y: contentOffset.y)
|
||||
}
|
||||
setContentOffset(offset, animated: animated)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// UITabBarController.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-31.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UITabBarController {
|
||||
open override var childForStatusBarStyle: UIViewController? {
|
||||
return selectedViewController
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
// UITableView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-3-2.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UITableView {
|
||||
|
||||
// static let groupedTableViewPaddingHeaderViewHeight: CGFloat = 16
|
||||
// static var groupedTableViewPaddingHeaderView: UIView {
|
||||
// return UIView(frame: CGRect(x: 0, y: 0, width: 100, height: groupedTableViewPaddingHeaderViewHeight))
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
extension UITableView {
|
||||
|
||||
func deselectRow(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) {
|
||||
guard let indexPathForSelectedRow = indexPathForSelectedRow else { return }
|
||||
|
||||
guard let transitionCoordinator = transitionCoordinator else {
|
||||
deselectRow(at: indexPathForSelectedRow, animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
transitionCoordinator.animate(alongsideTransition: { _ in
|
||||
self.deselectRow(at: indexPathForSelectedRow, animated: animated)
|
||||
}, completion: { context in
|
||||
if context.isCancelled {
|
||||
self.selectRow(at: indexPathForSelectedRow, animated: animated, scrollPosition: .none)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func blinkRow(at indexPath: IndexPath) {
|
||||
DispatchQueue.main.asyncAfter(wallDeadline: .now() + 1) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let cell = self.cellForRow(at: indexPath) else { return }
|
||||
let backgroundColor = cell.backgroundColor
|
||||
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
cell.backgroundColor = Asset.Colors.Label.highlight.color.withAlphaComponent(0.5)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
cell.backgroundColor = backgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue