Merge branch 'release/0.4.0'

This commit is contained in:
CMK 2021-05-13 18:54:22 +08:00
commit f3a534f0f1
538 changed files with 48160 additions and 3372 deletions

13
.github/scripts/build.sh vendored Executable file
View File

@ -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

9
.github/scripts/setup.sh vendored Executable file
View File

@ -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

27
.github/workflows/main.yml vendored Normal file
View File

@ -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

4
.gitignore vendored
View File

@ -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

12
AppShared/AppName.swift Normal file
View File

@ -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"
}

103
AppShared/AppSecret.swift Normal file
View File

@ -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
}
}

18
AppShared/AppShared.h Normal file
View File

@ -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>

22
AppShared/Info.plist Normal file
View File

@ -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>

View File

@ -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)!
}

View File

@ -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>

View File

@ -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")
}

View File

@ -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

View File

@ -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?,

View File

@ -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)
])
}
}

View File

@ -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

View File

@ -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

View File

@ -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()
}

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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)]
}
}

View File

@ -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)]
}
}

View File

@ -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)]
}
}

View File

@ -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)]
}
}

View File

@ -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)]
}
}

View File

@ -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
)
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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)]
}
}

View File

@ -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)
}
}

View File

@ -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 }
}

View File

@ -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
```

View File

@ -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
}
}

View File

@ -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

View File

@ -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 cant view Artbots profile\n until you unblock them.\nYour account looks like this to them.",
"blocked_warning": "You cant view Artbots 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, youll 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 whats 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 youd 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"
}
}
}
}

View File

@ -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

View File

@ -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>

View File

@ -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"
}
}
]
},

View File

@ -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)
}
}

View File

@ -7,7 +7,7 @@
import UIKit
protocol NeedsDependency: class {
protocol NeedsDependency: AnyObject {
var context: AppContext! { get set }
var coordinator: SceneCoordinator! { get set }
}

View File

@ -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()

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
)
}
}

View File

@ -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 ""
}
}
}

View File

@ -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
// }
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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 { }

View File

@ -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
}
}

View File

@ -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 { }

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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
)
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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) }
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -12,4 +12,9 @@ extension NSLayoutConstraint {
self.priority = priority
return self
}
func identifier(_ identifier: String?) -> Self {
self.identifier = identifier
return self
}
}

View File

@ -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 []
}
}
}

View File

@ -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
}
}

View File

@ -4,7 +4,7 @@
//
import UIKit
import MastodonSDK
// Reference:
// https://nshipster.com/swift-foundation-error-protocols/
extension UIAlertController {
@ -42,4 +42,3 @@ extension UIAlertController {
)
}
}

View File

@ -17,4 +17,3 @@ extension UIBarButtonItem {
}
}

View File

@ -43,3 +43,11 @@ extension UIButton {
}
}
extension UIButton {
func setBackgroundColor(_ color: UIColor, for state: UIControl.State) {
self.setBackgroundImage(
UIImage.placeholder(color: color),
for: state
)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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