Fixed a number of crashes and bugs

Fixed a crash which would occur when rendering a message containing both a mention and a url
Fixed a crash which could occur during migration due to the openGroupServerMessageId essentially being the max UInt64 value which was overflowing the Int64 storage
Fixed a bug where empty read receipt updates were sending messages (even for non one-to-one conversations)
Fixed a bug where loading in large numbers of messages (via the poller) was auto scrolling to the bottom if the user was close to the bottom (now limited to <5)
Fixed a memory leak with the AllMediaViewController (strong delegate references)
Fixed an issue where non-alphanumeric characters would cause issues with global search
Fixed an issue where search result highlighting wasn't working properly
Fixed an issue where the app switcher UI blocking wasn't working
Updated the conversations to mark messages as read while scrolling (rather than all messages when entering/participating in a conversation)
Updated the modal button font weight to be closer to the designs
Added the ability to delete "unsent" messages
This commit is contained in:
Morgan Pretty 2022-09-30 14:31:05 +10:00
parent 5b1e19dd2e
commit 91802e4812
20 changed files with 224 additions and 111 deletions

View File

@ -109,6 +109,9 @@ extension ContextMenuVC {
delegate: ContextMenuActionDelegate?
) -> [Action]? {
// No context items for info messages
guard cellViewModel.variant != .standardIncomingDeleted else {
return [ Action.delete(cellViewModel, delegate) ]
}
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
return nil
}

View File

@ -221,7 +221,7 @@ final class ContextMenuVC: UIViewController {
menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX))
emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX))
case .standardIncoming:
case .standardIncoming, .standardIncomingDeleted:
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
@ -288,8 +288,8 @@ final class ContextMenuVC: UIViewController {
let ratio: CGFloat = (frame.width / frame.height)
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing)
let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
let topMargin = max((UIApplication.shared.keyWindow?.safeAreaInsets.top ?? 0), Values.mediumSpacing)
let bottomMargin = max((UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0), Values.mediumSpacing)
let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height
if diffY > 0 {

View File

@ -1594,6 +1594,14 @@ extension ConversationVC:
func delete(_ cellViewModel: MessageViewModel) {
// Only allow deletion on incoming and outgoing messages
guard cellViewModel.variant != .standardIncomingDeleted else {
Storage.shared.writeAsync { db in
_ = try Interaction
.filter(id: cellViewModel.id)
.deleteAll(db)
}
return
}
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
return
}

View File

@ -138,11 +138,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
result.showsVerticalScrollIndicator = false
result.contentInsetAdjustmentBehavior = .never
result.keyboardDismissMode = .interactive
let bottomInset: CGFloat = viewModel.threadData.canWrite ? Values.mediumSpacing : Values.mediumSpacing + UIApplication.shared.keyWindow!.safeAreaInsets.bottom
result.contentInset = UIEdgeInsets(
top: 0,
leading: 0,
bottom: bottomInset,
bottom: (viewModel.threadData.canWrite ?
Values.mediumSpacing :
(Values.mediumSpacing + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0))
),
trailing: 0
)
result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self)
@ -604,11 +606,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
snInputView.text = draft
}
// Now we have done all the needed diffs, update the viewModel with the latest data and mark
// all messages as read (we do it in here as the 'threadData' actually contains the last
// 'interactionId' for the thread)
// Now we have done all the needed diffs update the viewModel with the latest data
self.viewModel.updateThreadData(updatedThreadData)
self.viewModel.markAllAsRead()
/// **Note:** This needs to happen **after** we have update the viewModel's thread data
if initialLoad || viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember {
@ -682,7 +681,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
source: viewModel.interactionData,
target: updatedData
)
let isInsert: Bool = (changeset.map({ $0.elementInserted.count }).reduce(0, +) > 0)
let numItemsInserted: Int = changeset.map { $0.elementInserted.count }.reduce(0, +)
let isInsert: Bool = (numItemsInserted > 0)
let wasLoadingMore: Bool = self.isLoadingMore
let wasOffsetCloseToBottom: Bool = self.isCloseToBottom
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
@ -758,10 +758,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
}
}
}
else if wasOffsetCloseToBottom && !wasLoadingMore {
// Scroll to the bottom if an interaction was just inserted and we either
// just sent a message or are close enough to the bottom (wait a tiny fraction
// to avoid buggy animation behaviour)
else if wasOffsetCloseToBottom && !wasLoadingMore && numItemsInserted < 5 {
/// Scroll to the bottom if an interaction was just inserted and we either just sent a message or are close enough to the
/// bottom (wait a tiny fraction to avoid buggy animation behaviour)
///
/// **Note:** We won't automatically scroll to the bottom if 5 or more messages were inserted (to avoid endlessly
/// auto-scrolling to the bottom when fetching new pages of data within open groups
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
self?.scrollToBottom(isAnimated: true)
}
@ -771,6 +773,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
self.isLoadingMore = false
self.autoLoadNextPageIfNeeded()
}
else {
// Need to update the scroll button alpha in case new messages were added but we didn't scroll
self.scrollButton.alpha = self.getScrollButtonOpacity()
self.unreadCountView.alpha = self.scrollButton.alpha
}
return
}
@ -1311,6 +1318,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
)
self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: .bottom)
self.viewModel.markAsRead(beforeInclusive: nil)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
@ -1322,8 +1330,41 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollButton.alpha = getScrollButtonOpacity()
unreadCountView.alpha = scrollButton.alpha
self.scrollButton.alpha = self.getScrollButtonOpacity()
self.unreadCountView.alpha = self.scrollButton.alpha
// We want to mark messages as read while we scroll, so grab the newest message and mark
// everything older as read
//
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
// the table content appears above the input view
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
if
let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
let messagesSection: Int = visibleIndexPaths
.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
.section,
let newestCellViewModel: MessageViewModel = visibleIndexPaths
.sorted()
.filter({ $0.section == messagesSection })
.compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
guard let frame: CGRect = tableView.cellForRow(at: indexPath)?.frame else {
return nil
}
return (
view.convert(frame, from: tableView),
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
)
})
// Exclude messages that are partially off the bottom of the screen
.filter({ $0.frame.maxY <= tableVisualBottom })
.last?
.cellViewModel
{
self.viewModel.markAsRead(beforeInclusive: newestCellViewModel.id)
}
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
@ -1472,6 +1513,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// Store the info incase we need to load more data (call will be re-triggered)
self.focusedInteractionId = interactionId
self.shouldHighlightNextScrollToInteraction = highlight
self.viewModel.markAsRead(beforeInclusive: interactionId)
// Ensure the target interaction has been loaded
guard

View File

@ -149,6 +149,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Interaction Data
private var lastInteractionIdMarkedAsRead: Int64?
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
public private(set) var interactionData: [SectionModel] = []
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
@ -380,21 +381,30 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
}
public func markAllAsRead() {
// Don't bother marking anything as read if there are no unread interactions (we can rely
// on the 'threadData.threadUnreadCount' to always be accurate)
/// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for
/// the thread will be marked as read
public func markAsRead(beforeInclusive interactionId: Int64?) {
/// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
/// write queue when it isn't needed, in order to do this we:
///
/// - Don't bother marking anything as read if there are no unread interactions (we can rely on the
/// `threadData.threadUnreadCount` to always be accurate)
/// - Don't bother marking anything as read if this was called with the same `interactionId` that we
/// previously marked as read (ie. when scrolling and the last message hasn't changed)
guard
(self.threadData.threadUnreadCount ?? 0) > 0,
let lastInteractionId: Int64 = self.threadData.interactionId
let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId),
self.lastInteractionIdMarkedAsRead != targetInteractionId
else { return }
let threadId: String = self.threadData.threadId
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
self.lastInteractionIdMarkedAsRead = targetInteractionId
Storage.shared.writeAsync { db in
try Interaction.markAsRead(
db,
interactionId: lastInteractionId,
interactionId: targetInteractionId,
threadId: threadId,
includingOlder: true,
trySendReadReceipt: trySendReadReceipt

View File

@ -1049,51 +1049,52 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
)
// Custom handle links
let links: [String: NSRange] = {
guard
let body: String = cellViewModel.body,
let detector: NSDataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
else { return [:] }
var links: [String: NSRange] = [:]
let matches = detector.matches(
in: body,
options: [],
range: NSRange(location: 0, length: body.count)
)
for match in matches {
guard let matchURL = match.url else { continue }
/// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and
/// set the scheme to 'https' instead as we don't load previews for 'http' so this will result
/// in more previews actually getting loaded without forcing the user to enter 'https://' before
/// every URL they enter
let urlString: String = (matchURL.absoluteString == "http://\(body)" ?
"https://\(body)" :
matchURL.absoluteString
)
if URL(string: urlString) != nil {
links[urlString] = (body as NSString).range(of: urlString)
}
let links: [URL: NSRange] = {
guard let detector: NSDataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return [:]
}
return links
return detector
.matches(
in: attributedText.string,
options: [],
range: NSRange(location: 0, length: attributedText.string.count)
)
.reduce(into: [:]) { result, match in
guard
let matchUrl: URL = match.url,
let originalRange: Range = Range(match.range, in: attributedText.string)
else { return }
/// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and
/// set the scheme to 'https' instead as we don't load previews for 'http' so this will result
/// in more previews actually getting loaded without forcing the user to enter 'https://' before
/// every URL they enter
let originalString: String = String(attributedText.string[originalRange])
guard matchUrl.absoluteString != "http://\(originalString)" else {
guard let httpsUrl: URL = URL(string: "https://\(originalString)") else {
return
}
result[httpsUrl] = match.range
return
}
result[matchUrl] = match.range
}
}()
for (urlString, range) in links {
guard let url: URL = URL(string: urlString) else { continue }
for (linkUrl, urlRange) in links {
attributedText.addAttributes(
[
.font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)),
.foregroundColor: actualTextColor,
.underlineColor: actualTextColor,
.underlineStyle: NSUnderlineStyle.single.rawValue,
.attachment: url
.attachment: linkUrl
],
range: range
range: urlRange
)
}
@ -1105,7 +1106,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
.map { part -> String in
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
return String(part[part.index(after: part.startIndex)..<part.endIndex])
let partRange = (part.index(after: part.startIndex)..<part.index(before: part.endIndex))
return String(part[partRange])
}
.forEach { part in
// Highlight all ranges of the text (Note: The search logic only finds
@ -1114,13 +1116,26 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
normalizedBody
.ranges(
of: (CurrentAppContext().isRTL ?
"\(part.lowercased())(^|[ ])" :
"(^|[ ])\(part.lowercased())"
"(\(part.lowercased()))(^|[^a-zA-Z0-9])" :
"(^|[^a-zA-Z0-9])(\(part.lowercased()))"
),
options: [.regularExpression]
)
.forEach { range in
let legacyRange: NSRange = NSRange(range, in: normalizedBody)
let targetRange: Range<String.Index> = {
let term: String = String(normalizedBody[range])
// If the matched term doesn't actually match the "part" value then it means
// we've matched a term after a non-alphanumeric character so need to shift
// the range over by 1
guard term.starts(with: part.lowercased()) else {
return (normalizedBody.index(after: range.lowerBound)..<range.upperBound)
}
return range
}()
let legacyRange: NSRange = NSRange(targetRange, in: normalizedBody)
attributedText.addThemeAttribute(.background(backgroundPrimaryColor), range: legacyRange)
attributedText.addThemeAttribute(.foreground(textPrimaryColor), range: legacyRange)
}

View File

@ -331,7 +331,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
// data to ensure everything is up to date
if didReturnFromBackground {
self.viewModel.pagedDataObserver?.reload()
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.reload()
}
}
}

View File

@ -23,7 +23,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
private var isAutoLoadingNextPage: Bool = false
private var currentTargetOffset: CGPoint?
public var delegate: DocumentTileViewControllerDelegate?
public weak var delegate: DocumentTileViewControllerDelegate?
// MARK: - Initialization

View File

@ -23,7 +23,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
private var isAutoLoadingNextPage: Bool = false
private var currentTargetOffset: CGPoint?
public var delegate: MediaTileViewControllerDelegate?
public weak var delegate: MediaTileViewControllerDelegate?
var isInBatchSelectMode = false {
didSet {

View File

@ -545,7 +545,8 @@ public final class FullConversationCell: UITableViewCell {
.map { part -> String in
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
return String(part[part.index(after: part.startIndex)..<part.endIndex])
let partRange = (part.index(after: part.startIndex)..<part.index(before: part.endIndex))
return String(part[partRange])
}
.forEach { part in
// Highlight all ranges of the text (Note: The search logic only finds results that start
@ -553,18 +554,31 @@ public final class FullConversationCell: UITableViewCell {
normalizedSnippet
.ranges(
of: (CurrentAppContext().isRTL ?
"\(part.lowercased())(^|[ ])" :
"(^|[ ])\(part.lowercased())"
"(\(part.lowercased()))(^|[^a-zA-Z0-9])" :
"(^|[^a-zA-Z0-9])(\(part.lowercased()))"
),
options: [.regularExpression]
)
.forEach { range in
let targetRange: Range<String.Index> = {
let term: String = String(normalizedSnippet[range])
// If the matched term doesn't actually match the "part" value then it means
// we've matched a term after a non-alphanumeric character so need to shift
// the range over by 1
guard term.starts(with: part.lowercased()) else {
return (normalizedSnippet.index(after: range.lowerBound)..<range.upperBound)
}
return range
}()
// Store the range of the first match so we can focus it in the content displayed
if firstMatchRange == nil {
firstMatchRange = range
firstMatchRange = targetRange
}
let legacyRange: NSRange = NSRange(range, in: normalizedSnippet)
let legacyRange: NSRange = NSRange(targetRange, in: normalizedSnippet)
result.addAttribute(.foregroundColor, value: textColor, range: legacyRange)
result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange)
}

View File

@ -104,13 +104,8 @@ class ScreenLockUI {
return .none;
}
if Storage.shared[.appSwitcherPreviewEnabled] {
Logger.verbose("desiredUIState: screen protection 4.")
return .protection;
}
Logger.verbose("desiredUIState: none 5.")
return .none
Logger.verbose("desiredUIState: screen protection 4.")
return .protection;
}
// MARK: - Lifecycle

View File

@ -49,7 +49,6 @@ public enum SMKLegacy {
internal static let preferencesCollection = "SignalPreferences"
internal static let additionalPreferencesCollection = "SSKPreferences"
internal static let preferencesKeyScreenSecurityDisabled = "Screen Security Key"
internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken"
internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken"
internal static let preferencesKeyAreLinkPreviewsEnabled = "areLinkPreviewsEnabled"

View File

@ -722,7 +722,7 @@ enum _003_YDBToGRDBMigration: Migration {
let wasRead: Bool
let expiresInSeconds: UInt32?
let expiresStartedAtMs: UInt64?
let openGroupServerMessageId: UInt64?
let openGroupServerMessageId: Int64?
let recipientStateMap: [String: SMKLegacy._DBOutgoingMessageRecipientState]?
let mostRecentFailureText: String?
let quotedMessage: SMKLegacy._DBQuotedMessage?
@ -737,9 +737,13 @@ enum _003_YDBToGRDBMigration: Migration {
// The legacy code only considered '!= 0' ids as valid so set those
// values to be null to avoid the unique constraint (it's also more
// correct for the values to be null)
openGroupServerMessageId = (legacyMessage.openGroupServerMessageID == 0 ?
//
// Note: Looks like it was also possible for this to be set to the max
// value which overflows when trying to convert to a signed version so
// we essentially discard the information in those cases)
openGroupServerMessageId = (Int64.zeroingOverflow(legacyMessage.openGroupServerMessageID) == 0 ?
nil :
legacyMessage.openGroupServerMessageID
Int64.zeroingOverflow(legacyMessage.openGroupServerMessageID)
)
quotedMessage = legacyMessage.quotedMessage
@ -904,8 +908,8 @@ enum _003_YDBToGRDBMigration: Migration {
authorId: authorId,
variant: variant,
body: body,
timestampMs: Int64(legacyInteraction.timestamp),
receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp),
timestampMs: Int64.zeroingOverflow(legacyInteraction.timestamp),
receivedAtTimestampMs: Int64.zeroingOverflow(legacyInteraction.receivedAtTimestamp),
wasRead: wasRead,
hasMention: Interaction.isUserMentioned(
db,
@ -923,7 +927,7 @@ enum _003_YDBToGRDBMigration: Migration {
nil
),
linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set
openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) },
openGroupServerMessageId: openGroupServerMessageId,
openGroupWhisperMods: false,
openGroupWhisperTo: nil
).inserted(db)
@ -945,7 +949,7 @@ enum _003_YDBToGRDBMigration: Migration {
try ControlMessageProcessRecord(
threadId: threadId,
variant: variant,
timestampMs: Int64(legacyInteraction.timestamp)
timestampMs: Int64.zeroingOverflow(legacyInteraction.timestamp)
)?.insert(db)
// Remove timestamps we created records for (they will be protected by unique
@ -1086,7 +1090,7 @@ enum _003_YDBToGRDBMigration: Migration {
try Quote(
interactionId: interactionId,
authorId: quotedMessage.authorId,
timestampMs: Int64(quotedMessage.timestamp),
timestampMs: Int64.zeroingOverflow(quotedMessage.timestamp),
body: quotedMessage.body,
attachmentId: attachmentId
).insert(db)
@ -1192,7 +1196,7 @@ enum _003_YDBToGRDBMigration: Migration {
// entries as "legacy"
try ControlMessageProcessRecord.generateLegacyProcessRecords(
db,
receivedMessageTimestamps: receivedMessageTimestamps.map { Int64($0) }
receivedMessageTimestamps: receivedMessageTimestamps.map { Int64.zeroingOverflow($0) }
)
// Clear out processed data (give the memory a change to be freed)
@ -1448,9 +1452,6 @@ enum _003_YDBToGRDBMigration: Migration {
db[.lastRecordedVoipToken] = lastVoipToken
}
// Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the
// setting was disabled, this has been inverted to 'appSwitcherPreviewEnabled' so it can default
// to 'false' (as most Bool values do)
db[.areReadReceiptsEnabled] = (legacyPreferences[SMKLegacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true)
db[.typingIndicatorsEnabled] = (legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] as? Bool == true)
db[.isScreenLockEnabled] = (legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] as? Bool == true)
@ -1461,7 +1462,6 @@ enum _003_YDBToGRDBMigration: Migration {
value: (legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] as? Double)
.defaulting(to: (15 * 60))
)
db[.appSwitcherPreviewEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyScreenSecurityDisabled] as? Bool == false)
db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true)
db[.areCallsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreCallsEnabled] as? Bool == true)
db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults()
@ -1854,3 +1854,9 @@ enum _003_YDBToGRDBMigration: Migration {
)
}
}
fileprivate extension Int64 {
static func zeroingOverflow(_ value: UInt64) -> Int64 {
return (value > UInt64(Int64.max) ? 0 : Int64(value))
}
}

View File

@ -109,6 +109,7 @@ public extension SendReadReceiptsJob {
.filter(interactionIds.contains(Interaction.Columns.id))
// Only `standardIncoming` incoming interactions should have read receipts sent
.filter(Interaction.Columns.variant == Interaction.Variant.standardIncoming)
.filter(Interaction.Columns.wasRead == false) // Only send for unread messages
.joining(
// Don't send read receipts in group threads
required: Interaction.thread
@ -119,7 +120,10 @@ public extension SendReadReceiptsJob {
)
// If there are no timestamp values then do nothing
guard let timestampMsValues: [Int64] = maybeTimestampMsValues else { return nil }
guard
let timestampMsValues: [Int64] = maybeTimestampMsValues,
!timestampMsValues.isEmpty
else { return nil }
// Try to get an existing job (if there is one that's not running)
if

View File

@ -940,6 +940,13 @@ public extension SessionThreadViewModel {
public extension SessionThreadViewModel {
static let searchResultsLimit: Int = 500
/// FTS will fail or try to process characters outside of `[A-Za-z0-9]` are included directly in a search
/// term, in order to resolve this the term needs to be wrapped in quotation marks so the eventual SQL
/// is `MATCH '"{term}"'` or `MATCH '"{term}"*'`
static func searchSafeTerm(_ term: String) -> String {
return "\"\(term)\""
}
static func searchTermParts(_ searchTerm: String) -> [String] {
/// Process the search term in order to extract the parts of the search pattern we want
///
@ -954,7 +961,7 @@ public extension SessionThreadViewModel {
guard index % 2 == 1 else {
return String(value)
.split(separator: " ")
.map { String($0) }
.map { "\"\(String($0))\"" }
}
return ["\"\(value)\""]
@ -972,13 +979,14 @@ public extension SessionThreadViewModel {
let rawPattern: String = searchTermParts(searchTerm)
.joined(separator: " OR ")
.appending("*")
let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*"
/// There are cases where creating a pattern can fail, we want to try and recover from those cases
/// by failling back to simpler patterns if needed
let maybePattern: FTS5Pattern? = (try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table))
.defaulting(
to: (try? db.makeFTS5Pattern(rawPattern: searchTerm, forTable: table))
.defaulting(to: FTS5Pattern(matchingAnyTokenIn: searchTerm))
to: (try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table))
.defaulting(to: FTS5Pattern(matchingAnyTokenIn: fallbackTerm))
)
guard let pattern: FTS5Pattern = maybePattern else { throw StorageError.invalidSearchPattern }

View File

@ -15,12 +15,6 @@ public extension Setting.EnumKey {
}
public extension Setting.BoolKey {
/// Controls whether the preview screen in the app switcher should be enabled
///
/// **Note:** In the legacy setting this flag controlled whether the preview was "disabled" (and defaulted to
/// true), by inverting this flag we can default it to false as is standard for Bool values
static let appSwitcherPreviewEnabled: Setting.BoolKey = "appSwitcherPreviewEnabled"
/// Controls whether typing indicators are enabled
///
/// **Note:** Only works if both participants in a "contact" thread have this setting enabled

View File

@ -116,8 +116,8 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate {
}
public static func createButton(title: String, titleColor: ThemeValue) -> UIButton {
let result: UIButton = UIButton() // TODO: NEED to fix the font (looks bad)
result.titleLabel?.font = .systemFont(ofSize: Values.mediumFontSize, weight: UIFont.Weight(600))
let result: UIButton = UIButton()
result.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.setTitle(title, for: .normal)
result.setThemeTitleColor(titleColor, for: .normal)
result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal)

View File

@ -360,14 +360,26 @@ public final class Storage {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
guard let observer: TransactionObserver = observer else { return }
dbWriter.add(transactionObserver: observer)
// Note: This actually triggers a write to the database so can be blocked by other
// writes, since it's usually called on the main thread when creating a view controller
// this can result in the UI hanging - to avoid this we dispatch (and hope there isn't
// negative impact)
DispatchQueue.global(qos: .default).async {
dbWriter.add(transactionObserver: observer)
}
}
public func removeObserver(_ observer: TransactionObserver?) {
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
guard let observer: TransactionObserver = observer else { return }
dbWriter.remove(transactionObserver: observer)
// Note: This actually triggers a write to the database so can be blocked by other
// writes, since it's usually called on the main thread when creating a view controller
// this can result in the UI hanging - to avoid this we dispatch (and hope there isn't
// negative impact)
DispatchQueue.global(qos: .default).async {
dbWriter.remove(transactionObserver: observer)
}
}
}

View File

@ -5,10 +5,11 @@
import Foundation
import AVFoundation
import MediaPlayer
import CoreServices
import PromiseKit
import SessionUIKit
import CoreServices
import SessionMessagingKit
import SignalCoreKit
public protocol AttachmentApprovalViewControllerDelegate: AnyObject {
func attachmentApproval(
@ -538,7 +539,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
private func setCurrentItem(_ item: SignalAttachmentItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) {
guard let item: SignalAttachmentItem = item, let page = self.buildPage(item: item) else {
owsFailDebug("unexpectedly unable to build new page")
Logger.error("unexpectedly unable to build new page")
return
}
@ -550,7 +551,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
func updateMediaRail() {
guard let currentItem = self.currentItem else {
owsFailDebug("currentItem was unexpectedly nil")
Logger.error("currentItem was unexpectedly nil")
return
}
@ -565,7 +566,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
return cell
default:
owsFailDebug("unexpted rail item type: \(railItem)")
Logger.error("unexpted rail item type: \(railItem)")
return GalleryRailCellView()
}
}

View File

@ -105,7 +105,7 @@ public class ScreenLock {
defaultErrorDescription: defaultErrorDescription)
switch outcome {
case .success:
owsFailDebug("local authentication unexpected success")
Logger.error("local authentication unexpected success")
completion(.failure(error: defaultErrorDescription))
case .cancel, .failure, .unexpectedFailure:
@ -129,8 +129,8 @@ public class ScreenLock {
switch outcome {
case .success:
owsFailDebug("local authentication unexpected success")
completion(.failure(error:defaultErrorDescription))
Logger.error("local authentication unexpected success")
completion(.failure(error: defaultErrorDescription))
case .cancel, .failure, .unexpectedFailure:
completion(outcome)
@ -190,11 +190,11 @@ public class ScreenLock {
return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT".localized())
case .invalidContext:
owsFailDebug("context not valid.")
Logger.error("context not valid.")
return .unexpectedFailure(error: defaultErrorDescription)
case .notInteractive:
owsFailDebug("context not interactive.")
Logger.error("context not interactive.")
return .unexpectedFailure(error: defaultErrorDescription)
@unknown default: