Merge branch 'develop' into release/0.4.0

# Conflicts:
#	Mastodon/Scene/Share/View/Content/StatusView.swift
This commit is contained in:
CMK 2021-05-10 18:54:08 +08:00
commit 36273467c1
24 changed files with 335 additions and 80 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20D75" 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"/>
@ -45,7 +45,6 @@
<attribute name="staticURL" attributeType="String"/>
<attribute name="url" attributeType="String"/>
<attribute name="visibleInPicker" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="emojis" inverseEntity="Status"/>
</entity>
<entity name="History" representedClassName=".History" syncable="YES">
<attribute name="accounts" optional="YES" attributeType="String"/>
@ -87,7 +86,7 @@
<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"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="inNotifications" inverseEntity="Status"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
@ -102,6 +101,7 @@
<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"/>
@ -158,7 +158,7 @@
<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="Nullify" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
<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>
@ -197,6 +197,7 @@
<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"/>
@ -216,16 +217,16 @@
<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="status" 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="status" inverseEntity="HomeTimelineIndex"/>
<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="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="Nullify" destinationEntity="Status" inverseName="reblog" 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="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"/>
@ -267,19 +268,19 @@
<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="149"/>
<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="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="659"/>
<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="584"/>
<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"/>

View File

@ -25,6 +25,9 @@ final public class MastodonUser: NSManagedObject {
@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
@ -88,6 +91,8 @@ extension MastodonUser {
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)
@ -151,6 +156,11 @@ extension MastodonUser {
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)
@ -270,6 +280,7 @@ extension MastodonUser {
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
@ -292,6 +303,7 @@ extension MastodonUser {
headerStatic: String?,
note: String?,
url: String?,
emojisData: Data?,
statusesCount: Int,
followingCount: Int,
followersCount: Int,
@ -313,6 +325,7 @@ extension MastodonUser {
self.headerStatic = headerStatic
self.note = note
self.url = url
self.emojisData = emojisData
self.statusesCount = statusesCount
self.followingCount = followingCount
self.followersCount = followersCount

View File

@ -24,6 +24,8 @@ public final class Status: 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
@ -54,12 +56,13 @@ public final class Status: NSManagedObject {
// one-to-many relationship
@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?
@ -77,7 +80,6 @@ extension Status {
replyTo: Status?,
poll: Poll?,
mentions: [Mention]?,
emojis: [Emoji]?,
tags: [Tag]?,
mediaAttachments: [Attachment]?,
favouritedBy: MastodonUser?,
@ -100,6 +102,8 @@ extension Status {
status.sensitive = property.sensitive
status.spoilerText = property.spoilerText
status.application = application
status.emojisData = property.emojisData
status.reblogsCount = property.reblogsCount
status.favouritesCount = property.favouritesCount
@ -121,9 +125,6 @@ extension Status {
if let mentions = mentions {
status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions)
}
if let emojis = emojis {
status.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis)
}
if let tags = tags {
status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags)
}
@ -148,6 +149,12 @@ extension Status {
return status
}
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
@ -248,6 +255,8 @@ extension Status {
public let sensitive: Bool
public let spoilerText: String?
public let emojisData: Data?
public let reblogsCount: NSNumber
public let favouritesCount: NSNumber
public let repliesCount: NSNumber?
@ -269,6 +278,7 @@ extension Status {
visibility: String?,
sensitive: Bool,
spoilerText: String?,
emojisData: Data?,
reblogsCount: NSNumber,
favouritesCount: NSNumber,
repliesCount: NSNumber?,
@ -288,6 +298,7 @@ extension Status {
self.visibility = visibility
self.sensitive = sensitive
self.spoilerText = spoilerText
self.emojisData = emojisData
self.reblogsCount = reblogsCount
self.favouritesCount = favouritesCount
self.repliesCount = repliesCount

View File

@ -29,12 +29,16 @@
"confirm": "Sign Out"
},
"block_domain": {
"message": "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.",
"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": {
@ -67,7 +71,8 @@
"report_user": "Report %s",
"block_domain": "Block %s",
"unblock_domain": "Unblock %s",
"settings": "Settings"
"settings": "Settings",
"delete": "Delete"
},
"status": {
"user_reblogged": "%s reblogged",

View File

@ -400,6 +400,7 @@
DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; };
DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; };
DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; };
DBAFB7352645463500371D5F /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; };
DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; };
DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; };
DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; };
@ -935,7 +936,6 @@
DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = "<group>"; };
DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultLoader.swift; sourceTree = "<group>"; };
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = "<group>"; };
DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = "<group>"; };
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = "<group>"; };
DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = "<group>"; };
DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAttachmentService+UploadState.swift"; sourceTree = "<group>"; };
@ -962,6 +962,7 @@
DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = "<group>"; };
DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = "<group>"; };
DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = "<group>"; };
DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = "<group>"; };
DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = "<group>"; };
DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = "<group>"; };
DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = "<group>"; };
@ -1594,6 +1595,7 @@
DB6D9F6E2635807F008423CD /* Setting.swift */,
DB6D9F4826353FD6008423CD /* Subscription.swift */,
DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */,
DBAFB7342645463500371D5F /* Emojis.swift */,
);
path = CoreDataStack;
sourceTree = "<group>";
@ -1735,7 +1737,6 @@
DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */,
DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */,
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
DB9A488326034BD7008B817C /* APIService+Status.swift */,
2D61254C262547C200299647 /* APIService+Notification.swift */,
DB9A488F26035963008B817C /* APIService+Media.swift */,
2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */,
@ -3199,6 +3200,7 @@
DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */,
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */,
DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */,
DBAFB7352645463500371D5F /* Emojis.swift in Sources */,
DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */,
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */,
DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */,
@ -3913,8 +3915,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/TwidereProject/ActiveLabel.swift";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 4.0.0;
kind = exactVersion;
version = 5.0.1;
};
};
2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */ = {

View File

@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift",
"state": {
"branch": null,
"revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a",
"version": "4.0.0"
"revision": "40e104063d825d1125ef4b8eeb6460eba8a57483",
"version": "5.0.1"
}
},
{
@ -69,7 +69,7 @@
"repositoryURL": "https://github.com/onevcat/Kingfisher.git",
"state": {
"branch": null,
"revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e",
"revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8",
"version": "6.2.1"
}
},

View File

@ -68,7 +68,8 @@ extension ComposeStatusSection {
}()
cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
// set text
cell.statusView.activeTextLabel.configure(content: status.content)
//status.emoji
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
// set date
cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow

View File

@ -143,6 +143,21 @@ extension StatusSection {
requestUserID: String,
statusItemAttribute: Item.StatusAttribute
) {
// 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 header
StatusSection.configureHeader(cell: cell, status: status)
ManagedObjectObserver.observe(object: status)
@ -158,10 +173,11 @@ extension StatusSection {
.store(in: &cell.disposeBag)
// set name username
cell.statusView.nameLabel.text = {
let nameText: String = {
let author = (status.reblog ?? status).author
return author.displayName.isEmpty ? author.username : author.displayName
}()
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 {
@ -176,7 +192,10 @@ extension StatusSection {
}
// set text
cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content)
cell.statusView.activeTextLabel.configure(
content: (status.reblog ?? status).content,
emojiDict: (status.reblog ?? status).emojiDict
)
// prepare media attachments
let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending }
@ -569,15 +588,16 @@ extension StatusSection {
if status.reblog != nil {
cell.statusView.headerContainerView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage)
cell.statusView.headerInfoLabel.text = {
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)
} else if status.inReplyToID != nil {
cell.statusView.headerContainerView.isHidden = false
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
cell.statusView.headerInfoLabel.text = {
let headerText: String = {
guard let replyTo = status.replyTo else {
return L10n.Common.Controls.Status.userRepliedTo("-")
}
@ -585,6 +605,7 @@ extension StatusSection {
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 ?? [:])
} else {
cell.statusView.headerContainerView.isHidden = true
}
@ -781,7 +802,6 @@ extension StatusSection {
}
let author = status.authorForUserProvider
let isMyself = authenticationBox.userID == author.id
let canReport = !isMyself
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)

View File

@ -14,6 +14,8 @@ extension ActiveLabel {
enum Style {
case `default`
case statusHeader
case statusName
case profileField
}
@ -25,6 +27,7 @@ extension ActiveLabel {
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
@ -33,6 +36,14 @@ extension ActiveLabel {
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
@ -44,9 +55,10 @@ extension ActiveLabel {
extension ActiveLabel {
/// status content
func configure(content: String) {
func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) {
activeEntities.removeAll()
if let parseResult = try? MastodonStatusContent.parse(status: content) {
if let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict) {
text = parseResult.trimmed
activeEntities = parseResult.activeEntities
} else {
@ -55,8 +67,8 @@ extension ActiveLabel {
}
/// account note
func configure(note: String) {
configure(content: note)
func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) {
configure(content: note, emojiDict: emojiDict)
}
}

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

@ -23,6 +23,7 @@ extension MastodonUser.Property {
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,
@ -98,3 +99,5 @@ extension MastodonUser {
return items
}
}
extension MastodonUser: EmojiContinaer { }

View File

@ -16,10 +16,11 @@ extension Status.Property {
id: entity.id,
uri: entity.uri,
createdAt: entity.createdAt,
content: entity.content,
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) },
@ -86,3 +87,5 @@ extension Status {
return items
}
}
extension Status: EmojiContinaer { }

View File

@ -17,8 +17,8 @@ internal enum L10n {
/// Block entire domain
internal static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain")
/// Are you really, really sure you want to block the entire %@? 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.
internal static func message(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Message", String(describing: p1))
internal static func title(_ p1: Any) -> String {
return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1))
}
}
internal enum Common {
@ -27,6 +27,12 @@ internal enum L10n {
/// Please try again later.
internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater")
}
internal enum DeletePost {
/// Delete
internal static let delete = L10n.tr("Localizable", "Common.Alerts.DeletePost.Delete")
/// Are you sure you want to delete this post?
internal static let title = L10n.tr("Localizable", "Common.Alerts.DeletePost.Title")
}
internal enum DiscardPostContent {
/// Confirm discard composed post content.
internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message")
@ -84,6 +90,8 @@ internal enum L10n {
internal static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm")
/// Continue
internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue")
/// Delete
internal static let delete = L10n.tr("Localizable", "Common.Controls.Actions.Delete")
/// Discard
internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard")
/// Done

View File

@ -11,9 +11,21 @@ import ActiveLabel
enum MastodonStatusContent {
static func parse(status: String) throws -> MastodonStatusContent.ParseResult {
let status = status.replacingOccurrences(of: "<br/>", with: "\n")
let rootNode = try Node.parse(document: status)
typealias EmojiShortcode = String
typealias EmojiDict = [EmojiShortcode: URL]
static func parse(content: String, emojiDict: EmojiDict) throws -> MastodonStatusContent.ParseResult {
let document: String = {
var content = content
content = content.replacingOccurrences(of: "<br/>", with: "\n")
for (shortcode, url) in emojiDict {
let emojiNode = "<span class=\"emoji\" href=\"\(url.absoluteString)\">\(shortcode)</span>"
let pattern = ":\(shortcode):"
content = content.replacingOccurrences(of: pattern, with: emojiNode)
}
return content
}()
let rootNode = try Node.parse(document: document)
let text = String(rootNode.text)
var activeEntities: [ActiveEntity] = []
@ -25,7 +37,7 @@ enum MastodonStatusContent {
case .url:
guard let href = entity.href else { continue }
let text = String(entity.text)
activeEntities.append(ActiveEntity(range: range, type: .url(text, trimmed: entity.hrefEllipsis ?? text, url: href)))
activeEntities.append(ActiveEntity(range: range, type: .url(text, trimmed: entity.hrefEllipsis ?? text, url: href, userInfo: nil)))
case .hashtag:
var userInfo: [AnyHashable: Any] = [:]
entity.href.flatMap { href in
@ -40,27 +52,44 @@ enum MastodonStatusContent {
}
let mention = String(entity.text).deletingPrefix("@")
activeEntities.append(ActiveEntity(range: range, type: .mention(mention, userInfo: userInfo)))
default:
case .emoji:
var userInfo: [AnyHashable: Any] = [:]
guard let href = entity.href else { continue }
userInfo["href"] = href
let emoji = String(entity.text)
activeEntities.append(ActiveEntity(range: range, type: .emoji(emoji, url: href, userInfo: userInfo)))
case .none:
continue
}
}
var trimmed = text
for activeEntity in activeEntities {
guard case .url = activeEntity.type else { continue }
MastodonStatusContent.trimEntity(status: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities)
}
return ParseResult(
document: status,
document: document,
original: text,
trimmed: trimmed,
activeEntities: validate(text: trimmed, activeEntities: activeEntities) ? activeEntities : []
activeEntities: activeEntities
)
}
static func trimEntity(status: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) {
guard case let .url(text, trimmed, _, _) = activeEntity.type else { return }
let text: String
let trimmed: String
switch activeEntity.type {
case .url(let _text, let _trimmed, _, _):
text = _text
trimmed = _trimmed
case .emoji(let _text, _, _):
text = _text
trimmed = " "
default:
return
}
guard let index = activeEntities.firstIndex(where: { $0.range == activeEntity.range }) else { return }
guard let range = Range(activeEntity.range, in: status) else { return }
status.replaceSubrange(range, with: trimmed)
@ -73,19 +102,6 @@ enum MastodonStatusContent {
moveActiveEntity.range.location += offset
}
}
private static func validate(text: String, activeEntities: [ActiveEntity]) -> Bool {
for activeEntity in activeEntities {
let count = text.utf16.count
let endIndex = activeEntity.range.location + activeEntity.range.length
guard endIndex <= count else {
assertionFailure("Please file issue")
return false
}
}
return true
}
}
@ -106,6 +122,7 @@ extension MastodonStatusContent {
}
}
extension MastodonStatusContent {
class Node {
@ -154,6 +171,10 @@ extension MastodonStatusContent {
}
}
if _classNames.contains("emoji") {
return .emoji
}
return nil
}()
self.level = level
@ -257,6 +278,7 @@ extension MastodonStatusContent.Node {
case url
case mention
case hashtag
case emoji
}
static func entities(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] {

View File

@ -252,7 +252,7 @@ extension UserProviderFacade {
} else {
let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in
guard let provider = provider else { return }
let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domainFromAcct), preferredStyle: .alert)
let alertController = UIAlertController(title: L10n.Common.Alerts.BlockDomain.title(mastodonUser.domainFromAcct), message: nil, preferredStyle: .alert)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in
}
alertController.addAction(cancelAction)
@ -300,6 +300,35 @@ extension UserProviderFacade {
children.append(shareAction)
}
if let status = shareStatus, isMyself {
let deleteAction = UIAction(title: L10n.Common.Controls.Actions.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) {
[weak provider] _ in
guard let provider = provider else { return }
let alertController = UIAlertController(title: L10n.Common.Alerts.DeletePost.title, message: nil, preferredStyle: .alert)
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in
}
alertController.addAction(cancelAction)
let deleteAction = UIAlertAction(title: L10n.Common.Alerts.DeletePost.delete, style: .destructive) { _ in
guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return }
provider.context.apiService.deleteStatus(domain: activeMastodonAuthenticationBox.domain,
statusID: status.id,
authorizationBox: activeMastodonAuthenticationBox
)
.sink { _ in
// do nothing
} receiveValue: { _ in
// do nothing
}
.store(in: &provider.context.disposeBag)
}
alertController.addAction(deleteAction)
provider.present(alertController, animated: true, completion: nil)
}
children.append(deleteAction)
}
return UIMenu(title: "", options: [], children: children)
}

View File

@ -1,7 +1,9 @@
"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain";
"Common.Alerts.BlockDomain.Message" = "Are you really, really sure you want to block the entire %@? 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.";
"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? 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.";
"Common.Alerts.Common.PleaseTryAgain" = "Please try again.";
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
"Common.Alerts.DeletePost.Delete" = "Delete";
"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?";
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
@ -22,6 +24,7 @@ Please check your internet connection.";
"Common.Controls.Actions.Cancel" = "Cancel";
"Common.Controls.Actions.Confirm" = "Confirm";
"Common.Controls.Actions.Continue" = "Continue";
"Common.Controls.Actions.Delete" = "Delete";
"Common.Controls.Actions.Discard" = "Discard";
"Common.Controls.Actions.Done" = "Done";
"Common.Controls.Actions.Edit" = "Edit";

View File

@ -175,7 +175,7 @@ extension ProfileHeaderViewController {
.receive(on: DispatchQueue.main)
.sink { [weak self] isEditing, note, editingNote in
guard let self = self else { return }
self.profileHeaderView.bioActiveLabel.configure(note: note ?? "")
self.profileHeaderView.bioActiveLabel.configure(note: note ?? "", emojiDict: [:]) // FIXME: custom emoji
self.profileHeaderView.bioTextEditorView.text = editingNote ?? ""
}
.store(in: &disposeBag)

View File

@ -20,7 +20,7 @@ final class ProfileFieldView: UIView {
let valueActiveLabel: ActiveLabel = {
let label = ActiveLabel(style: .profileField)
label.configure(content: "value")
label.configure(content: "value", emojiDict: [:])
return label
}()

View File

@ -114,7 +114,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
let label = ActiveLabel(style: .default)
label.textAlignment = .center
label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at <a href=\"https://github.com/tootsuite/mastodon\">tootsuite/mastodon</a> (v3.3.0).")
label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at <a href=\"https://github.com/tootsuite/mastodon\">tootsuite/mastodon</a> (v3.3.0).", emojiDict: [:])
label.delegate = self
view.addArrangedSubview(label)

View File

@ -11,7 +11,7 @@ import AVKit
import ActiveLabel
import AlamofireImage
protocol StatusViewDelegate: class {
protocol StatusViewDelegate: AnyObject {
func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel)
func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton)
func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton)
@ -69,10 +69,8 @@ final class StatusView: UIView {
return label
}()
let headerInfoLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium), maximumPointSize: 17)
label.textColor = Asset.Colors.Label.secondary.color
let headerInfoLabel: ActiveLabel = {
let label = ActiveLabel(style: .statusHeader)
label.text = "Bob reblogged"
return label
}()
@ -87,10 +85,8 @@ final class StatusView: UIView {
}()
let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton()
let nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 17, weight: .semibold)
label.textColor = Asset.Colors.Label.primary.color
let nameLabel: ActiveLabel = {
let label = ActiveLabel(style: .statusName)
label.text = "Alice"
return label
}()

View File

@ -88,4 +88,50 @@ extension APIService {
.eraseToAnyPublisher()
}
func deleteStatus(
domain: String,
statusID: Mastodon.Entity.Status.ID,
authorizationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let authorization = authorizationBox.userAuthorization
let query = Mastodon.API.Statuses.DeleteStatusQuery(id: statusID)
return Mastodon.API.Statuses.deleteStatus(
session: session,
domain: domain,
query: query,
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> in
return self.backgroundManagedObjectContext.performChanges{
// fetch old Status
let oldStatus: Status? = {
let request = Status.sortedFetchRequest
request.predicate = Status.predicate(domain: domain, id: response.value.id)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try self.backgroundManagedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
if let status = oldStatus {
self.backgroundManagedObjectContext.delete(status)
}
}
.setFailureType(to: Error.self)
.tryMap { result -> Mastodon.Response.Content<Mastodon.Entity.Status> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -89,9 +89,6 @@ extension APIService.CoreData {
let metions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in
Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url), index: index)
}
let emojis = entity.emojis?.compactMap { emoji -> Emoji in
Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category))
}
let tags = entity.tags?.compactMap { tag -> Tag in
let histories = tag.history?.compactMap { history -> History in
History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts))
@ -121,7 +118,6 @@ extension APIService.CoreData {
replyTo: replyTo,
poll: poll,
mentions: metions,
emojis: emojis,
tags: tags,
mediaAttachments: mediaAttachments,
favouritedBy: (entity.favourited ?? false) ? requestMastodonUser : nil,

View File

@ -10,7 +10,7 @@ import Combine
extension Mastodon.API.Statuses {
static func viewStatusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL {
static func statusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL {
let pathComponent = "statuses/" + statusID
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent)
}
@ -38,7 +38,7 @@ extension Mastodon.API.Statuses {
authorization: Mastodon.API.OAuth.Authorization?
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let request = Mastodon.API.get(
url: viewStatusEndpointURL(domain: domain, statusID: statusID),
url: statusEndpointURL(domain: domain, statusID: statusID),
query: nil,
authorization: authorization
)
@ -150,6 +150,54 @@ extension Mastodon.API.Statuses {
}
extension Mastodon.API.Statuses {
/// Delete status
///
/// Delete one of your own statuses.
///
/// - Since: 0.0.0
/// - Version: 3.3.0
/// # Last Update
/// 2021/5/7
/// # Reference
/// [Document](https://docs.joinmastodon.org/methods/statuses/)
/// - Parameters:
/// - session: `URLSession`
/// - domain: Mastodon instance domain. e.g. "example.com"
/// - query: `DeleteStatusQuery`
/// - authorization: User token
/// - Returns: `AnyPublisher` contains `Status` nested in the response
public static func deleteStatus(
session: URLSession,
domain: String,
query: DeleteStatusQuery,
authorization: Mastodon.API.OAuth.Authorization?
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Status>, Error> {
let request = Mastodon.API.delete(
url: statusEndpointURL(domain: domain, statusID: query.id),
query: query,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
}
public struct DeleteStatusQuery: Codable, DeleteQuery {
public let id: Mastodon.Entity.Status.ID
public init(
id: Mastodon.Entity.Status.ID
) {
self.id = id
}
}
}
extension Mastodon.API.Statuses {
static func statusContextEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL {

View File

@ -26,7 +26,7 @@ extension Mastodon.Entity {
public let uri: String
public let createdAt: Date
public let account: Account
public let content: String
public let content: String? // will be optional when delete status
public let visibility: Visibility?
public let sensitive: Bool?