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-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-01-28 09:10:30 +01:00
static func tableViewDiffableDataSource (
for tableView : UITableView ,
dependency : NeedsDependency ,
managedObjectContext : NSManagedObjectContext ,
timestampUpdatePublisher : AnyPublisher < Date , Never > ,
2021-03-03 09:12:48 +01:00
statusTableViewCellDelegate : StatusTableViewCellDelegate ,
2021-02-05 10:08:51 +01:00
timelineMiddleLoaderTableViewCellDelegate : TimelineMiddleLoaderTableViewCellDelegate ?
2021-02-24 09:11:48 +01:00
) -> UITableViewDiffableDataSource < StatusSection , Item > {
2021-03-03 09:12:48 +01:00
UITableViewDiffableDataSource ( tableView : tableView ) { [ weak statusTableViewCellDelegate , weak timelineMiddleLoaderTableViewCellDelegate ] tableView , indexPath , item -> UITableViewCell ? in
guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell ( ) }
2021-01-28 09:10:30 +01:00
switch item {
2021-02-24 09:11:48 +01:00
case . homeTimelineIndex ( objectID : let objectID , let attribute ) :
2021-02-23 08:16:55 +01:00
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : StatusTableViewCell . self ) , for : indexPath ) as ! StatusTableViewCell
2021-02-07 07:42:50 +01:00
// c o n f i g u r e c e l l
managedObjectContext . performAndWait {
let timelineIndex = managedObjectContext . object ( with : objectID ) as ! HomeTimelineIndex
2021-02-24 09:11:48 +01:00
StatusSection . configure ( cell : cell , readableLayoutFrame : tableView . readableContentGuide . layoutFrame , timestampUpdatePublisher : timestampUpdatePublisher , toot : timelineIndex . toot , requestUserID : timelineIndex . userID , statusContentWarningAttribute : attribute )
2021-02-07 07:42:50 +01:00
}
2021-03-03 09:12:48 +01:00
cell . delegate = statusTableViewCellDelegate
2021-02-07 07:42:50 +01:00
return cell
2021-02-24 09:11:48 +01:00
case . toot ( let objectID , let attribute ) :
2021-02-23 08:16:55 +01:00
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : StatusTableViewCell . self ) , for : indexPath ) as ! StatusTableViewCell
2021-02-08 11:29:27 +01:00
let activeMastodonAuthenticationBox = dependency . context . authenticationService . activeMastodonAuthenticationBox . value
let requestUserID = activeMastodonAuthenticationBox ? . userID ? ? " "
2021-01-28 09:10:30 +01:00
// c o n f i g u r e c e l l
managedObjectContext . performAndWait {
let toot = managedObjectContext . object ( with : objectID ) as ! Toot
2021-02-24 09:11:48 +01:00
StatusSection . configure ( cell : cell , readableLayoutFrame : tableView . readableContentGuide . layoutFrame , timestampUpdatePublisher : timestampUpdatePublisher , toot : toot , requestUserID : requestUserID , statusContentWarningAttribute : attribute )
2021-01-28 09:10:30 +01:00
}
2021-03-03 09:12:48 +01:00
cell . delegate = statusTableViewCellDelegate
2021-01-28 09:10:30 +01:00
return cell
2021-02-07 07:42:50 +01:00
case . publicMiddleLoader ( let upperTimelineTootID ) :
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : TimelineMiddleLoaderTableViewCell . self ) , for : indexPath ) as ! TimelineMiddleLoaderTableViewCell
cell . delegate = timelineMiddleLoaderTableViewCellDelegate
2021-02-18 05:49:24 +01:00
timelineMiddleLoaderTableViewCellDelegate ? . configure ( cell : cell , upperTimelineTootID : upperTimelineTootID , timelineIndexobjectID : nil )
2021-02-07 07:42:50 +01:00
return cell
case . homeMiddleLoader ( let upperTimelineIndexObjectID ) :
2021-02-04 08:09:58 +01:00
let cell = tableView . dequeueReusableCell ( withIdentifier : String ( describing : TimelineMiddleLoaderTableViewCell . self ) , for : indexPath ) as ! TimelineMiddleLoaderTableViewCell
cell . delegate = timelineMiddleLoaderTableViewCellDelegate
2021-02-18 05:49:24 +01:00
timelineMiddleLoaderTableViewCellDelegate ? . configure ( cell : cell , upperTimelineTootID : nil , timelineIndexobjectID : upperTimelineIndexObjectID )
2021-02-04 08:09:58 +01: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
cell . activityIndicatorView . startAnimating ( )
return cell
2021-01-28 09:10:30 +01:00
}
}
}
2021-03-03 12:34:29 +01:00
}
extension StatusSection {
2021-01-28 09:10:30 +01:00
static func configure (
2021-02-23 08:16:55 +01:00
cell : StatusTableViewCell ,
2021-02-23 12:18:34 +01:00
readableLayoutFrame : CGRect ? ,
2021-02-02 07:10:25 +01:00
timestampUpdatePublisher : AnyPublisher < Date , Never > ,
2021-02-08 11:29:27 +01:00
toot : Toot ,
2021-02-24 09:11:48 +01:00
requestUserID : String ,
statusContentWarningAttribute : StatusContentWarningAttribute ?
2021-01-28 09:10:30 +01:00
) {
2021-02-23 08:16:55 +01:00
// s e t h e a d e r
cell . statusView . headerContainerStackView . isHidden = toot . reblog = = nil
2021-02-23 12:18:34 +01:00
cell . statusView . headerInfoLabel . text = {
let author = toot . author
let name = author . displayName . isEmpty ? author . username : author . displayName
2021-02-24 08:29:16 +01:00
return L10n . Common . Controls . Status . userBoosted ( name )
2021-02-23 12:18:34 +01:00
} ( )
2021-02-23 08:16:55 +01:00
2021-02-01 11:06:29 +01:00
// s e t n a m e u s e r n a m e a v a t a r
2021-02-23 12:18:34 +01:00
cell . statusView . nameLabel . text = {
let author = ( toot . reblog ? ? toot ) . 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 ( ) ) )
2021-02-23 08:16:55 +01:00
2021-02-01 11:06:29 +01:00
// s e t t e x t
2021-02-23 08:16:55 +01:00
cell . statusView . activeTextLabel . config ( content : ( toot . reblog ? ? toot ) . content )
2021-02-23 12:18:34 +01:00
2021-02-25 06:47:30 +01:00
// s e t s t a t u s t e x t c o n t e n t w a r n i n g
let spoilerText = ( toot . reblog ? ? toot ) . spoilerText ? ? " "
let isStatusTextSensitive = statusContentWarningAttribute ? . isStatusTextSensitive ? ? ! spoilerText . isEmpty
2021-02-24 11:41:40 +01:00
cell . statusView . isStatusTextSensitive = isStatusTextSensitive
2021-02-24 09:11:48 +01:00
cell . statusView . updateContentWarningDisplay ( isHidden : ! isStatusTextSensitive )
2021-02-25 06:47:30 +01:00
cell . statusView . contentWarningTitle . text = {
if spoilerText . isEmpty {
return L10n . Common . Controls . Status . statusContentWarning
} else {
return L10n . Common . Controls . Status . statusContentWarning + " : \( spoilerText ) "
}
} ( )
2021-02-24 08:29:16 +01:00
2021-02-23 12:18:34 +01:00
// p r e p a r e m e d i a a t t a c h m e n t s
let mediaAttachments = Array ( ( toot . reblog ? ? toot ) . mediaAttachments ? ? [ ] ) . sorted { $0 . index . compare ( $1 . index ) = = . orderedAscending }
// s e t i m a g e
let mosiacImageViewModel = MosaicImageViewModel ( mediaAttachments : mediaAttachments )
let imageViewMaxSize : CGSize = {
let maxWidth : CGFloat = {
// u s e t i m e l i n e P o s t V i e w w i d t h a s c o n t a i n e r w i d t h
// t h a t w i d t h f o l l o w s r e a d a b l e w i d t h a n d k e e p c o n s t a n t w i d t h a f t e r r o t a t e
let containerFrame = readableLayoutFrame ? ? cell . statusView . frame
var containerWidth = containerFrame . width
containerWidth -= 10
containerWidth -= StatusView . avatarImageSize . width
return containerWidth
} ( )
let scale : CGFloat = {
switch mosiacImageViewModel . metas . count {
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 ]
2021-03-02 12:33:33 +01:00
let imageView = cell . statusView . statusMosaicImageViewContainer . setupImageView ( aspectRatio : meta . size , maxSize : imageViewMaxSize )
2021-02-23 12:18:34 +01:00
imageView . af . setImage (
withURL : meta . url ,
placeholderImage : UIImage . placeholder ( color : . systemFill ) ,
imageTransition : . crossDissolve ( 0.2 )
)
} else {
2021-03-02 12:33:33 +01:00
let imageViews = cell . statusView . statusMosaicImageViewContainer . setupImageViews ( count : mosiacImageViewModel . metas . count , maxHeight : imageViewMaxSize . height )
2021-02-23 12:18:34 +01:00
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 )
)
}
}
2021-03-02 12:33:33 +01:00
cell . statusView . statusMosaicImageViewContainer . isHidden = mosiacImageViewModel . metas . isEmpty
2021-02-25 06:47:30 +01:00
let isStatusSensitive = statusContentWarningAttribute ? . isStatusSensitive ? ? ( toot . reblog ? ? toot ) . sensitive
2021-03-02 12:33:33 +01:00
cell . statusView . statusMosaicImageViewContainer . blurVisualEffectView . effect = isStatusSensitive ? MosaicImageViewContainer . blurVisualEffect : nil
cell . statusView . statusMosaicImageViewContainer . vibrancyVisualEffectView . alpha = isStatusSensitive ? 1.0 : 0.0
2021-03-02 09:27:11 +01:00
// s e t p o l l
2021-03-03 12:34:29 +01:00
let poll = ( toot . reblog ? ? toot ) . poll
configure ( cell : cell , timestampUpdatePublisher : timestampUpdatePublisher , poll : poll , requestUserID : requestUserID )
if let poll = poll {
ManagedObjectObserver . observe ( object : poll )
. sink { _ in
// d o n o t h i n g
} receiveValue : { change in
guard case let . update ( object ) = change . changeType ,
let newPoll = object as ? Poll else { return }
StatusSection . configure ( cell : cell , timestampUpdatePublisher : timestampUpdatePublisher , poll : newPoll , requestUserID : requestUserID )
2021-03-02 12:10:45 +01:00
}
2021-03-03 12:34:29 +01:00
. store ( in : & cell . disposeBag )
2021-03-02 12:10:45 +01:00
}
2021-03-03 12:34:29 +01:00
2021-02-08 11:29:27 +01:00
// t o o l b a r
2021-02-23 08:23:18 +01:00
let replyCountTitle : String = {
let count = ( toot . reblog ? ? toot ) . repliesCount ? . intValue ? ? 0
2021-02-24 09:11:48 +01:00
return StatusSection . formattedNumberTitleForActionButton ( count )
2021-02-23 08:23:18 +01:00
} ( )
cell . statusView . actionToolbarContainer . replyButton . setTitle ( replyCountTitle , for : . normal )
2021-02-18 05:49:24 +01:00
let isLike = ( toot . reblog ? ? toot ) . favouritedBy . flatMap { $0 . contains ( where : { $0 . id = = requestUserID } ) } ? ? false
2021-02-08 11:29:27 +01:00
let favoriteCountTitle : String = {
let count = ( toot . reblog ? ? toot ) . favouritesCount . intValue
2021-02-24 09:11:48 +01:00
return StatusSection . formattedNumberTitleForActionButton ( count )
2021-02-08 11:29:27 +01:00
} ( )
2021-02-23 08:16:55 +01:00
cell . statusView . actionToolbarContainer . starButton . setTitle ( favoriteCountTitle , for : . normal )
cell . statusView . actionToolbarContainer . isStarButtonHighlight = isLike
2021-02-18 05:49:24 +01:00
2021-02-01 11:06:29 +01:00
// s e t d a t e
2021-02-02 07:10:25 +01:00
let createdAt = ( toot . reblog ? ? toot ) . createdAt
2021-02-23 08:16:55 +01:00
cell . statusView . dateLabel . text = createdAt . shortTimeAgoSinceNow
2021-02-02 07:10:25 +01:00
timestampUpdatePublisher
. sink { _ in
2021-02-23 08:16:55 +01:00
cell . statusView . dateLabel . text = createdAt . shortTimeAgoSinceNow
2021-02-02 07:10:25 +01:00
}
. store ( in : & cell . disposeBag )
2021-02-18 05:49:24 +01:00
2021-02-08 11:29:27 +01:00
// o b s e r v e m o d e l c h a n g e
ManagedObjectObserver . observe ( object : toot . reblog ? ? toot )
. receive ( on : DispatchQueue . main )
. sink { _ in
// d o n o t h i n g
} receiveValue : { change in
2021-02-18 05:49:24 +01:00
guard case . update ( let object ) = change . changeType ,
2021-02-08 11:29:27 +01:00
let newToot = object as ? Toot else { return }
let targetToot = newToot . reblog ? ? newToot
2021-02-18 05:49:24 +01:00
let isLike = targetToot . favouritedBy . flatMap { $0 . contains ( where : { $0 . id = = requestUserID } ) } ? ? false
2021-02-08 11:29:27 +01:00
let favoriteCount = targetToot . favouritesCount . intValue
2021-02-24 09:11:48 +01:00
let favoriteCountTitle = StatusSection . formattedNumberTitleForActionButton ( favoriteCount )
2021-02-23 08:16:55 +01:00
cell . statusView . actionToolbarContainer . starButton . setTitle ( favoriteCountTitle , for : . normal )
cell . statusView . actionToolbarContainer . isStarButtonHighlight = isLike
2021-02-18 05:49:24 +01:00
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 )
2021-02-08 11:29:27 +01:00
}
. store ( in : & cell . disposeBag )
2021-01-28 09:10:30 +01:00
}
2021-03-03 12:34:29 +01:00
static func configure (
cell : StatusTableViewCell ,
timestampUpdatePublisher : AnyPublisher < Date , Never > ,
poll : Poll ? ,
requestUserID : String
) {
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 . pollVoteButton . isHidden = ! poll . multiple
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
cell . statusView . pollTableView . allowsMultipleSelection = poll . multiple
cell . statusView . pollTableViewDataSource = PollSection . tableViewDiffableDataSource (
for : cell . statusView . pollTableView ,
managedObjectContext : managedObjectContext
)
var snapshot = NSDiffableDataSourceSnapshot < PollSection , PollItem > ( )
snapshot . appendSections ( [ . main ] )
let votedOptions = poll . options . filter { option in
( option . votedBy ? ? Set ( ) ) . map { $0 . id } . contains ( requestUserID )
}
let isPollVoted = ( poll . votedBy ? ? Set ( ) ) . map { $0 . id } . contains ( requestUserID )
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 = {
2021-03-04 11:53:29 +01:00
// m a k e i s P o l l V o t e d c h e c k l a t e r t o m a k e o n l y l o c a l c h a n g e p o s s i b l e
if ! votedOptions . isEmpty {
2021-03-03 12:34:29 +01:00
return votedOptions . contains ( option ) ? . on : . off
} else if poll . expired {
return . none
2021-03-04 11:53:29 +01:00
} else if isPollVoted , votedOptions . isEmpty {
return . none
2021-03-03 12:34:29 +01:00
} else {
return . off
}
} ( )
let voteState : PollItem . Attribute . VoteState = {
guard isPollVoted 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 )
} ( )
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 )
}
2021-01-28 09:10:30 +01:00
}
2021-02-24 09:11:48 +01:00
extension StatusSection {
2021-01-28 09:10:30 +01:00
private static func formattedNumberTitleForActionButton ( _ number : Int ? ) -> String {
guard let number = number , number > 0 else { return " " }
return String ( number )
}
}