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-03-10 07:36:28 +01:00
StatusSection . configure (
cell : cell ,
dependency : dependency ,
readableLayoutFrame : tableView . readableContentGuide . layoutFrame , timestampUpdatePublisher : timestampUpdatePublisher , toot : timelineIndex . toot , requestUserID : timelineIndex . userID , statusItemAttribute : 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-03-10 07:36:28 +01:00
StatusSection . configure (
cell : cell ,
dependency : dependency ,
readableLayoutFrame : tableView . readableContentGuide . layoutFrame , timestampUpdatePublisher : timestampUpdatePublisher , toot : toot , requestUserID : requestUserID , statusItemAttribute : 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-03-10 07:36:28 +01:00
dependency : NeedsDependency ,
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 ,
2021-03-05 06:41:48 +01:00
statusItemAttribute : Item . StatusAttribute
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 ? ? " "
2021-03-05 06:41:48 +01:00
let isStatusTextSensitive = statusItemAttribute . isStatusTextSensitive
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 {
2021-03-08 04:42:10 +01:00
case 1 : return 1.3
default : return 0.7
2021-02-23 12:18:34 +01:00
}
} ( )
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-03-05 06:41:48 +01:00
let isStatusSensitive = statusItemAttribute . isStatusSensitive
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
2021-03-08 04:42:10 +01:00
// s e t a u d i o
if let audioAttachment = mediaAttachments . filter ( { $0 . type = = . audio } ) . first {
cell . statusView . audioView . isHidden = false
AudioContainerViewModel . configure ( cell : cell , audioAttachment : audioAttachment )
} else {
cell . statusView . audioView . isHidden = true
}
2021-03-10 07:36:28 +01:00
// s e t G I F & v i d e o
let playerViewMaxSize : CGSize = {
let maxWidth : CGFloat = {
// u s e s t a t u s 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
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 )
{
let parent = cell . delegate ? . parent ( )
let mosaicPlayerView = cell . statusView . mosaicPlayerView
let playerViewController = mosaicPlayerView . setupPlayer (
aspectRatio : videoPlayerViewModel . videoSize ,
maxSize : playerViewMaxSize ,
parent : parent
)
playerViewController . delegate = cell . delegate ? . playerViewControllerDelegate
playerViewController . player = videoPlayerViewModel . player
playerViewController . showsPlaybackControls = videoPlayerViewModel . videoKind != . gif
mosaicPlayerView . gifIndicatorLabel . isHidden = videoPlayerViewModel . videoKind != . gif
mosaicPlayerView . isHidden = false
} else {
cell . statusView . mosaicPlayerView . playerViewController . player ? . pause ( )
cell . statusView . mosaicPlayerView . playerViewController . player = nil
}
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
2021-03-05 06:41:48 +01:00
StatusSection . configure (
cell : cell ,
poll : poll ,
requestUserID : requestUserID ,
updateProgressAnimated : false ,
timestampUpdatePublisher : timestampUpdatePublisher
)
2021-03-03 12:34:29 +01:00
if let poll = poll {
ManagedObjectObserver . observe ( object : poll )
. sink { _ in
// d o n o t h i n g
} receiveValue : { change in
2021-03-08 04:42:10 +01:00
guard case . update ( let object ) = change . changeType ,
2021-03-03 12:34:29 +01:00
let newPoll = object as ? Poll else { return }
2021-03-05 06:41:48 +01:00
StatusSection . configure (
cell : cell ,
poll : newPoll ,
requestUserID : requestUserID ,
updateProgressAnimated : true ,
timestampUpdatePublisher : timestampUpdatePublisher
)
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 ,
poll : Poll ? ,
2021-03-05 06:41:48 +01:00
requestUserID : String ,
updateProgressAnimated : Bool ,
timestampUpdatePublisher : AnyPublisher < Date , Never >
2021-03-03 12:34:29 +01:00
) {
guard let poll = poll ,
2021-03-10 07:36:28 +01:00
let managedObjectContext = poll . managedObjectContext
else {
2021-03-03 12:34:29 +01:00
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
2021-03-05 08:53:36 +01:00
let votedOptions = poll . options . filter { option in
2021-03-10 07:36:28 +01:00
( option . votedBy ? ? Set ( ) ) . map ( \ . id ) . contains ( requestUserID )
2021-03-05 08:53:36 +01:00
}
let didVotedLocal = ! votedOptions . isEmpty
2021-03-10 07:36:28 +01:00
let didVotedRemote = ( poll . votedBy ? ? Set ( ) ) . map ( \ . id ) . contains ( requestUserID )
2021-03-05 08:53:36 +01:00
cell . statusView . pollVoteButton . isEnabled = didVotedLocal
cell . statusView . pollVoteButton . isHidden = ! poll . multiple ? true : ( didVotedRemote || poll . expired )
2021-03-03 12:34:29 +01:00
cell . statusView . pollTableViewDataSource = PollSection . tableViewDiffableDataSource (
for : cell . statusView . pollTableView ,
managedObjectContext : managedObjectContext
)
var snapshot = NSDiffableDataSourceSnapshot < PollSection , PollItem > ( )
snapshot . appendSections ( [ . main ] )
2021-03-05 08:53:36 +01:00
2021-03-03 12:34:29 +01:00
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-05 08:53:36 +01:00
// c h e c k d i d V o t e d R e m o t e l a t e r t o m a k e t h e l o c a l c h a n g e p o s s i b l e
2021-03-04 11:53:29 +01:00
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-05 08:53:36 +01:00
} else if didVotedRemote , votedOptions . isEmpty {
2021-03-04 11:53:29 +01:00
return . none
2021-03-03 12:34:29 +01:00
} else {
return . off
}
} ( )
let voteState : PollItem . Attribute . VoteState = {
2021-03-05 07:23:26 +01:00
var needsReveal : Bool
if poll . expired {
needsReveal = true
2021-03-05 08:53:36 +01:00
} else if didVotedRemote {
2021-03-05 07:23:26 +01:00
needsReveal = true
} else {
needsReveal = false
}
guard needsReveal else { return . hidden }
2021-03-03 12:34:29 +01:00
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 )
2021-03-05 06:41:48 +01:00
return . reveal ( voted : voted , percentage : percentage , animated : updateProgressAnimated )
2021-03-03 12:34:29 +01:00
} ( )
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 )
}
}