2021-01-28 09:10:30 +01:00
//
// T i m e l i n e S e c t i o n . s w i f t
// M a s t o d o n
//
// C r e a t e d b y s x i a o j i a n o n 2 0 2 1 / 1 / 2 7 .
//
import Combine
import CoreData
import CoreDataStack
import os . log
import UIKit
2021-04-19 12:06:02 +02:00
import AVKit
2021-07-07 11:51:47 +02:00
import AlamofireImage
2021-06-28 13:41:41 +02:00
import MastodonMeta
2021-07-09 13:07:12 +02:00
import MastodonSDK
import NaturalLanguage
2022-01-27 14:23:39 +01:00
import MastodonUI
2021-06-28 13:41:41 +02:00
2021-02-24 09:11:48 +01:00
enum StatusSection : Equatable , Hashable {
2021-01-28 09:10:30 +01:00
case main
}
2021-02-24 09:11:48 +01:00
extension StatusSection {
2021-06-19 12:33:29 +02:00
2021-07-15 03:56:26 +02:00
static let logger = Logger ( subsystem : " StatusSection " , category : " logic " )
2022-01-27 14:23:39 +01:00
struct Configuration {
weak var statusTableViewCellDelegate : StatusTableViewCellDelegate ?
weak var timelineMiddleLoaderTableViewCellDelegate : TimelineMiddleLoaderTableViewCellDelegate ?
}
2021-07-15 03:56:26 +02:00
2022-01-27 14:23:39 +01:00
static func diffableDataSource (
tableView : UITableView ,
context : AppContext ,
configuration : Configuration
) -> UITableViewDiffableDataSource < StatusSection , StatusItem > {
tableView . register ( StatusTableViewCell . self , forCellReuseIdentifier : String ( describing : StatusTableViewCell . self ) )
tableView . register ( TimelineMiddleLoaderTableViewCell . self , forCellReuseIdentifier : String ( describing : TimelineMiddleLoaderTableViewCell . self ) )
tableView . register ( StatusThreadRootTableViewCell . self , forCellReuseIdentifier : String ( describing : StatusThreadRootTableViewCell . self ) )
tableView . register ( TimelineBottomLoaderTableViewCell . self , forCellReuseIdentifier : String ( describing : TimelineBottomLoaderTableViewCell . self ) )
return UITableViewDiffableDataSource ( tableView : tableView ) { tableView , indexPath , item -> UITableViewCell ? in
2021-01-28 09:10:30 +01:00
switch item {
2022-01-27 14:23:39 +01:00
case . feed ( let record ) :
2021-02-23 08:16:55 +01:00
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : StatusTableViewCell . self ) , for : indexPath ) as ! StatusTableViewCell
2022-01-27 14:23:39 +01:00
context . managedObjectContext . performAndWait {
guard let feed = record . object ( in : context . managedObjectContext ) else { return }
configure (
context : context ,
tableView : tableView ,
cell : cell ,
viewModel : StatusTableViewCell . ViewModel ( value : . feed ( feed ) ) ,
configuration : configuration
)
2021-02-07 07:42:50 +01:00
}
return cell
2022-01-27 14:23:39 +01:00
case . feedLoader ( let record ) :
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : TimelineMiddleLoaderTableViewCell . self ) , for : indexPath ) as ! TimelineMiddleLoaderTableViewCell
context . managedObjectContext . performAndWait {
guard let feed = record . object ( in : context . managedObjectContext ) else { return }
configure (
2021-03-10 07:36:28 +01:00
cell : cell ,
2022-01-27 14:23:39 +01:00
feed : feed ,
configuration : configuration
2021-03-10 07:36:28 +01:00
)
2021-05-12 12:26:53 +02:00
}
2021-04-13 13:46:42 +02:00
return cell
2022-01-27 14:23:39 +01:00
case . status ( let record ) :
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : StatusTableViewCell . self ) , for : indexPath ) as ! StatusTableViewCell
context . managedObjectContext . performAndWait {
guard let status = record . object ( in : context . managedObjectContext ) else { return }
configure (
context : context ,
tableView : tableView ,
cell : cell ,
viewModel : StatusTableViewCell . ViewModel ( value : . status ( status ) ) ,
configuration : configuration
)
}
2021-02-07 07:42:50 +01:00
return cell
2022-01-27 14:23:39 +01:00
case . thread ( let thread ) :
let cell = dequeueConfiguredReusableCell (
context : context ,
tableView : tableView ,
indexPath : indexPath ,
configuration : ThreadCellRegistrationConfiguration (
thread : thread ,
configuration : configuration
)
)
2021-02-04 08:09:58 +01:00
return cell
2021-04-13 13:46:42 +02:00
case . topLoader :
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : TimelineBottomLoaderTableViewCell . self ) , for : indexPath ) as ! TimelineBottomLoaderTableViewCell
2022-01-27 14:23:39 +01:00
cell . activityIndicatorView . startAnimating ( )
2021-04-13 13:46:42 +02:00
return cell
2021-02-03 06:01:50 +01:00
case . bottomLoader :
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : TimelineBottomLoaderTableViewCell . self ) , for : indexPath ) as ! TimelineBottomLoaderTableViewCell
2022-01-27 14:23:39 +01:00
cell . activityIndicatorView . startAnimating ( )
2021-07-15 11:26:04 +02:00
return cell
2022-01-27 14:23:39 +01:00
}
}
} // e n d f u n c
}
extension StatusSection {
struct ThreadCellRegistrationConfiguration {
let thread : StatusItem . Thread
let configuration : Configuration
}
static func dequeueConfiguredReusableCell (
context : AppContext ,
tableView : UITableView ,
indexPath : IndexPath ,
configuration : ThreadCellRegistrationConfiguration
) -> UITableViewCell {
let managedObjectContext = context . managedObjectContext
switch configuration . thread {
case . root ( let threadContext ) :
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : StatusThreadRootTableViewCell . self ) , for : indexPath ) as ! StatusThreadRootTableViewCell
managedObjectContext . performAndWait {
guard let status = threadContext . status . object ( in : managedObjectContext ) else { return }
StatusSection . configure (
context : context ,
tableView : tableView ,
cell : cell ,
viewModel : StatusThreadRootTableViewCell . ViewModel ( value : . status ( status ) ) ,
configuration : configuration . configuration
)
}
return cell
case . reply ( let threadContext ) ,
. leaf ( let threadContext ) :
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : StatusTableViewCell . self ) , for : indexPath ) as ! StatusTableViewCell
managedObjectContext . performAndWait {
guard let status = threadContext . status . object ( in : managedObjectContext ) else { return }
StatusSection . configure (
context : context ,
tableView : tableView ,
cell : cell ,
viewModel : StatusTableViewCell . ViewModel ( value : . status ( status ) ) ,
configuration : configuration . configuration
)
}
return cell
}
}
}
extension StatusSection {
public static func setupStatusPollDataSource (
context : AppContext ,
statusView : StatusView
) {
let managedObjectContext = context . managedObjectContext
statusView . pollTableViewDiffableDataSource = UITableViewDiffableDataSource < PollSection , PollItem > ( tableView : statusView . pollTableView ) { tableView , indexPath , item in
switch item {
case . option ( let record ) :
// F i x c e l l r e u s e a n i m a t i o n i s s u e
let cell : PollOptionTableViewCell = {
let _cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : PollOptionTableViewCell . self ) + " @ \( indexPath . row ) # \( indexPath . section ) " ) as ? PollOptionTableViewCell
_cell ? . prepareForReuse ( )
return _cell ? ? PollOptionTableViewCell ( )
} ( )
context . authenticationService . activeMastodonAuthenticationBox
. map { $0 as UserIdentifier ? }
. assign ( to : \ . userIdentifier , on : cell . pollOptionView . viewModel )
. store ( in : & cell . disposeBag )
managedObjectContext . performAndWait {
guard let option = record . object ( in : managedObjectContext ) else {
assertionFailure ( )
return
}
cell . pollOptionView . configure ( pollOption : option )
// t r i g g e r u p d a t e i f n e e d s
let needsUpdatePoll : Bool = {
// c h e c k f i r s t o p t i o n i n p o l l t o t r i g g e r u p d a t e p o l l o n l y o n c e
guard option . index = = 0 else { return false }
let poll = option . poll
guard ! poll . expired else {
logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : poll expired. Skip update poll \( poll . id ) " )
return false
}
let now = Date ( )
let timeIntervalSinceUpdate = now . timeIntervalSince ( poll . updatedAt )
#if DEBUG
let autoRefreshTimeInterval : TimeInterval = 3 // s p e e d u p t e s t i n g
#else
let autoRefreshTimeInterval : TimeInterval = 30
#endif
guard timeIntervalSinceUpdate > autoRefreshTimeInterval else {
logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : skip update poll \( poll . id ) due to recent updated " )
return false
}
logger . log ( level : . debug , " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : update poll \( poll . id ) … " )
return true
} ( )
if needsUpdatePoll , let authenticationBox = context . authenticationService . activeMastodonAuthenticationBox . value
{
let pollRecord : ManagedObjectRecord < Poll > = . init ( objectID : option . poll . objectID )
Task { [ weak context ] in
guard let context = context else { return }
_ = try await context . apiService . poll (
poll : pollRecord ,
authenticationBox : authenticationBox
)
}
}
} // e n d m a n a g e d O b j e c t C o n t e x t . p e r f o r m A n d W a i t
2021-04-06 10:43:08 +02:00
return cell
2021-01-28 09:10:30 +01:00
}
}
2022-01-27 14:23:39 +01:00
var _snapshot = NSDiffableDataSourceSnapshot < PollSection , PollItem > ( )
_snapshot . appendSections ( [ . main ] )
if #available ( iOS 15.0 , * ) {
statusView . pollTableViewDiffableDataSource ? . applySnapshotUsingReloadData ( _snapshot )
} else {
statusView . pollTableViewDiffableDataSource ? . apply ( _snapshot , animatingDifferences : false )
}
}
}
extension StatusSection {
static func configure (
context : AppContext ,
tableView : UITableView ,
cell : StatusTableViewCell ,
viewModel : StatusTableViewCell . ViewModel ,
configuration : Configuration
) {
setupStatusPollDataSource (
context : context ,
statusView : cell . statusView
)
context . authenticationService . activeMastodonAuthenticationBox
. map { $0 as UserIdentifier ? }
. assign ( to : \ . userIdentifier , on : cell . statusView . viewModel )
. store ( in : & cell . disposeBag )
cell . configure (
tableView : tableView ,
viewModel : viewModel ,
delegate : configuration . statusTableViewCellDelegate
)
}
static func configure (
context : AppContext ,
tableView : UITableView ,
cell : StatusThreadRootTableViewCell ,
viewModel : StatusThreadRootTableViewCell . ViewModel ,
configuration : Configuration
) {
setupStatusPollDataSource (
context : context ,
statusView : cell . statusView
)
context . authenticationService . activeMastodonAuthenticationBox
. map { $0 as UserIdentifier ? }
. assign ( to : \ . userIdentifier , on : cell . statusView . viewModel )
. store ( in : & cell . disposeBag )
cell . configure (
tableView : tableView ,
viewModel : viewModel ,
delegate : configuration . statusTableViewCellDelegate
)
}
static func configure (
cell : TimelineMiddleLoaderTableViewCell ,
feed : Feed ,
configuration : Configuration
) {
cell . configure (
feed : feed ,
delegate : configuration . timelineMiddleLoaderTableViewCellDelegate
)
2021-01-28 09:10:30 +01:00
}
2022-01-27 14:23:39 +01:00
2021-03-03 12:34:29 +01:00
}
2021-07-09 13:07:12 +02:00
extension StatusSection {
enum TimelineContext {
case home
case notifications
case ` public `
case thread
case account
case favorite
case hashtag
case report
2021-07-14 14:28:41 +02:00
case search
2021-07-09 13:07:12 +02:00
var filterContext : Mastodon . Entity . Filter . Context ? {
switch self {
case . home : return . home
case . notifications : return . notifications
case . public : return . public
case . thread : return . thread
case . account : return . account
default : return nil
}
}
}
private static func needsFilterStatus (
content : MastodonMetaContent ? ,
filters : [ Mastodon . Entity . Filter ] ,
timelineContext : TimelineContext
) -> AnyPublisher < Bool , Never > {
guard let content = content ,
2021-07-15 03:56:26 +02:00
let currentFilterContext = timelineContext . filterContext ,
! filters . isEmpty else {
2021-07-09 13:07:12 +02:00
return Just ( false ) . eraseToAnyPublisher ( )
}
return Future < Bool , Never > { promise in
DispatchQueue . global ( qos : . userInteractive ) . async {
var wordFilters : [ Mastodon . Entity . Filter ] = [ ]
var nonWordFilters : [ Mastodon . Entity . Filter ] = [ ]
for filter in filters {
guard filter . context . contains ( where : { $0 = = currentFilterContext } ) else { continue }
if filter . wholeWord {
wordFilters . append ( filter )
} else {
nonWordFilters . append ( filter )
}
}
let text = content . original . lowercased ( )
var needsFilter = false
for filter in nonWordFilters {
guard text . contains ( filter . phrase . lowercased ( ) ) else { continue }
needsFilter = true
break
}
if needsFilter {
DispatchQueue . main . async {
promise ( . success ( true ) )
}
return
}
let tokenizer = NLTokenizer ( unit : . word )
tokenizer . string = text
let phraseWords = wordFilters . map { $0 . phrase . lowercased ( ) }
tokenizer . enumerateTokens ( in : text . startIndex . . < text . endIndex ) { range , _ in
let word = String ( text [ range ] )
if phraseWords . contains ( word ) {
needsFilter = true
return false
} else {
return true
}
}
DispatchQueue . main . async {
promise ( . success ( needsFilter ) )
}
}
}
. eraseToAnyPublisher ( )
}
}
2021-07-15 03:56:26 +02:00
class StatusContentOperation : Operation {
let logger = Logger ( subsystem : " StatusContentOperation " , category : " logic " )
// i n p u t
let statusObjectID : NSManagedObjectID
let mastodonContent : MastodonContent
// o u t p u t
var result : Result < MastodonMetaContent , Error > ?
init (
statusObjectID : NSManagedObjectID ,
mastodonContent : MastodonContent
) {
self . statusObjectID = statusObjectID
self . mastodonContent = mastodonContent
super . init ( )
}
override func main ( ) {
guard ! isCancelled else { return }
// l o g g e r . d e b u g ( " \ ( ( # f i l e a s N S S t r i n g ) . l a s t P a t h C o m p o n e n t , p r i v a c y : . p u b l i c ) [ \ ( # l i n e , p r i v a c y : . p u b l i c ) ] , \ ( # f u n c t i o n , p r i v a c y : . p u b l i c ) : p r c o e s s \ ( s e l f . s t a t u s O b j e c t I D ) … " )
do {
let content = try MastodonMetaContent . convert ( document : mastodonContent )
result = . success ( content )
// l o g g e r . d e b u g ( " \ ( ( # f i l e a s N S S t r i n g ) . l a s t P a t h C o m p o n e n t , p r i v a c y : . p u b l i c ) [ \ ( # l i n e , p r i v a c y : . p u b l i c ) ] , \ ( # f u n c t i o n , p r i v a c y : . p u b l i c ) : p r o c e s s s u c c e s s \ ( s e l f . s t a t u s O b j e c t I D ) " )
} catch {
result = . failure ( error )
// l o g g e r . d e b u g ( " \ ( ( # f i l e a s N S S t r i n g ) . l a s t P a t h C o m p o n e n t , p r i v a c y : . p u b l i c ) [ \ ( # l i n e , p r i v a c y : . p u b l i c ) ] , \ ( # f u n c t i o n , p r i v a c y : . p u b l i c ) : p r o c e s s f a i l \ ( s e l f . s t a t u s O b j e c t I D ) " )
}
}
override func cancel ( ) {
// l o g g e r . d e b u g ( " \ ( ( # f i l e a s N S S t r i n g ) . l a s t P a t h C o m p o n e n t , p r i v a c y : . p u b l i c ) [ \ ( # l i n e , p r i v a c y : . p u b l i c ) ] , \ ( # f u n c t i o n , p r i v a c y : . p u b l i c ) : c a n c e l \ ( s e l f . s t a t u s O b j e c t I D . d e b u g D e s c r i p t i o n ) " )
super . cancel ( )
}
}