mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Threaded notifications (#4644)
* implemented grouping * implemented presenting the thread * improved the implementation by reusing the presentation action * add Thread group to DMs to differentiate from non threaded messages of the same DM * name for a threaded notification in group room * focus event when tapping on a notification * pr suggestions * document
This commit is contained in:
@@ -420,6 +420,7 @@
|
|||||||
"leave_room_alert_select_new_owner_title" = "Transfer ownership";
|
"leave_room_alert_select_new_owner_title" = "Transfer ownership";
|
||||||
"leave_room_alert_subtitle" = "Are you sure that you want to leave the room?";
|
"leave_room_alert_subtitle" = "Are you sure that you want to leave the room?";
|
||||||
"login_initial_device_name_ios" = "%1$@ iOS";
|
"login_initial_device_name_ios" = "%1$@ iOS";
|
||||||
|
"notification_thread_in_room" = "Thread in %1$@";
|
||||||
"notification_channel_call" = "Call";
|
"notification_channel_call" = "Call";
|
||||||
"notification_channel_listening_for_events" = "Listening for events";
|
"notification_channel_listening_for_events" = "Listening for events";
|
||||||
"notification_channel_noisy" = "Noisy notifications";
|
"notification_channel_noisy" = "Noisy notifications";
|
||||||
|
|||||||
@@ -359,9 +359,14 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
} else {
|
} else {
|
||||||
storedRoomsToAwait = [roomID]
|
storedRoomsToAwait = [roomID]
|
||||||
}
|
}
|
||||||
|
handleAppRoute(.room(roomID: roomID, via: []))
|
||||||
|
} else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID {
|
||||||
|
handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: content.eventID))
|
||||||
|
} else if let eventID = content.eventID {
|
||||||
|
handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []))
|
||||||
|
} else {
|
||||||
|
handleAppRoute(.room(roomID: roomID, via: []))
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAppRoute(.room(roomID: roomID, via: []))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
|
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ enum AppRoute: Hashable {
|
|||||||
case share(ShareExtensionPayload)
|
case share(ShareExtensionPayload)
|
||||||
/// The change roles screen of a room with the transfer ownership setting
|
/// The change roles screen of a room with the transfer ownership setting
|
||||||
case transferOwnership(roomID: String)
|
case transferOwnership(roomID: String)
|
||||||
|
/// A thread within a room, only to be used to handle tap on notification for threaded events.
|
||||||
|
case thread(roomID: String, threadRootEventID: String, focusEventID: String?)
|
||||||
|
|
||||||
/// Whether or not the route should be handled by the authentication flow.
|
/// Whether or not the route should be handled by the authentication flow.
|
||||||
var isAuthenticationRoute: Bool {
|
var isAuthenticationRoute: Bool {
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
roomFlowCoordinator?.clearRoute(animated: animated)
|
roomFlowCoordinator?.clearRoute(animated: animated)
|
||||||
case .roomMemberDetails:
|
case .roomMemberDetails:
|
||||||
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
|
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
|
||||||
|
case .thread(let roomID, let threadRootEventID, let focusEventID):
|
||||||
|
stateMachine.processEvent(.selectRoom(roomID: roomID,
|
||||||
|
via: [],
|
||||||
|
entryPoint: .thread(rootEventID: threadRootEventID,
|
||||||
|
focusEventID: focusEventID)),
|
||||||
|
userInfo: .init(animated: animated))
|
||||||
case .event(let eventID, let roomID, let via):
|
case .event(let eventID, let roomID, let via):
|
||||||
stateMachine.processEvent(.selectRoom(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: .init(animated: animated))
|
stateMachine.processEvent(.selectRoom(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: .init(animated: animated))
|
||||||
case .eventOnRoomAlias(let eventID, let alias):
|
case .eventOnRoomAlias(let eventID, let alias):
|
||||||
@@ -190,24 +196,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
case (.initial, .start, .roomList):
|
case (.initial, .start, .roomList):
|
||||||
presentHomeScreen()
|
presentHomeScreen()
|
||||||
case(.roomList(let detailState), .selectRoom(let roomID, let via, let entryPoint), .roomList):
|
case(.roomList(let detailState), .selectRoom(let roomID, let via, let entryPoint), .roomList):
|
||||||
if case .room(roomID) = detailState,
|
handleSelectRoomTransition(roomID: roomID, via: via, entryPoint: entryPoint, detailState: detailState, animated: animated)
|
||||||
!entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading.
|
|
||||||
let roomFlowCoordinator {
|
|
||||||
let route: AppRoute = switch entryPoint {
|
|
||||||
case .room: .room(roomID: roomID, via: via)
|
|
||||||
case .roomDetails: .roomDetails(roomID: roomID)
|
|
||||||
case .eventID(let eventID): .event(eventID: eventID, roomID: roomID, via: via) // ignored.
|
|
||||||
case .share(let payload): .share(payload)
|
|
||||||
case .transferOwnership: .transferOwnership(roomID: roomID)
|
|
||||||
}
|
|
||||||
roomFlowCoordinator.handleAppRoute(route, animated: animated)
|
|
||||||
} else {
|
|
||||||
if case .space = detailState {
|
|
||||||
dismissRoomFlow(animated: animated)
|
|
||||||
}
|
|
||||||
startRoomFlow(roomID: roomID, via: via, entryPoint: entryPoint, animated: animated)
|
|
||||||
}
|
|
||||||
actionsSubject.send(.hideCallScreenOverlay) // Turn any active call into a PiP so that navigation from a notification is visible to the user.
|
|
||||||
case(.roomList, .deselectRoom, .roomList):
|
case(.roomList, .deselectRoom, .roomList):
|
||||||
dismissRoomFlow(animated: animated)
|
dismissRoomFlow(animated: animated)
|
||||||
|
|
||||||
@@ -298,6 +287,28 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleSelectRoomTransition(roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint, detailState: ChatsFlowCoordinatorStateMachine.DetailState?, animated: Bool) {
|
||||||
|
if case .room(roomID) = detailState,
|
||||||
|
!entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading.
|
||||||
|
let roomFlowCoordinator {
|
||||||
|
let route: AppRoute = switch entryPoint {
|
||||||
|
case .room: .room(roomID: roomID, via: via)
|
||||||
|
case .roomDetails: .roomDetails(roomID: roomID)
|
||||||
|
case .eventID(let eventID): .event(eventID: eventID, roomID: roomID, via: via) // ignored.
|
||||||
|
case .share(let payload): .share(payload)
|
||||||
|
case .transferOwnership: .transferOwnership(roomID: roomID)
|
||||||
|
case .thread(let rootEventID, let focusEventID): .thread(roomID: roomID, threadRootEventID: rootEventID, focusEventID: focusEventID)
|
||||||
|
}
|
||||||
|
roomFlowCoordinator.handleAppRoute(route, animated: animated)
|
||||||
|
} else {
|
||||||
|
if case .space = detailState {
|
||||||
|
dismissRoomFlow(animated: animated)
|
||||||
|
}
|
||||||
|
startRoomFlow(roomID: roomID, via: via, entryPoint: entryPoint, animated: animated)
|
||||||
|
}
|
||||||
|
actionsSubject.send(.hideCallScreenOverlay) // Turn any active call into a PiP so that navigation from a notification is visible to the user.
|
||||||
|
}
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
userSession.clientProxy.actionsPublisher
|
userSession.clientProxy.actionsPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@@ -498,6 +509,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
coordinator.handleAppRoute(.share(payload), animated: animated)
|
coordinator.handleAppRoute(.share(payload), animated: animated)
|
||||||
case .transferOwnership:
|
case .transferOwnership:
|
||||||
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: animated)
|
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: animated)
|
||||||
|
case .thread(let rootEventID, let focusEventID):
|
||||||
|
coordinator.handleAppRoute(.thread(roomID: roomID, threadRootEventID: rootEventID, focusEventID: focusEventID), animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
case .accountProvisioningLink:
|
case .accountProvisioningLink:
|
||||||
break // We always ignore this flow when logged in.
|
break // We always ignore this flow when logged in.
|
||||||
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
|
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
|
||||||
.roomDetails, .roomMemberDetails, .userProfile,
|
.roomDetails, .roomMemberDetails, .userProfile, .thread,
|
||||||
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
|
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
|
||||||
.call, .genericCallLink, .settings, .share, .transferOwnership:
|
.call, .genericCallLink, .settings, .share, .transferOwnership:
|
||||||
// These routes aren't in this flow so clear the entire stack.
|
// These routes aren't in this flow so clear the entire stack.
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ enum RoomFlowCoordinatorEntryPoint: Hashable {
|
|||||||
case room
|
case room
|
||||||
/// The flow will start by showing the room, focussing on the supplied event ID.
|
/// The flow will start by showing the room, focussing on the supplied event ID.
|
||||||
case eventID(String)
|
case eventID(String)
|
||||||
|
/// The flow will start by showing a thread timeline, can only be triggered by notification taps,
|
||||||
|
/// which means it can never be a used for child flows.
|
||||||
|
case thread(rootEventID: String, focusEventID: String?)
|
||||||
/// The flow will start by showing the room's details.
|
/// The flow will start by showing the room's details.
|
||||||
case roomDetails
|
case roomDetails
|
||||||
/// An external media share request
|
/// An external media share request
|
||||||
@@ -154,6 +157,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
} else {
|
} else {
|
||||||
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated))
|
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated))
|
||||||
}
|
}
|
||||||
|
case .thread(let roomID, let threadRootEventID, let focusEventID):
|
||||||
|
Task {
|
||||||
|
await handleRoomRoute(roomID: roomID,
|
||||||
|
via: [],
|
||||||
|
presentationAction: .thread(rootEventID: threadRootEventID,
|
||||||
|
focusEventID: focusEventID),
|
||||||
|
animated: animated)
|
||||||
|
}
|
||||||
case .event(let eventID, let roomID, let via):
|
case .event(let eventID, let roomID, let via):
|
||||||
Task {
|
Task {
|
||||||
await handleRoomRoute(roomID: roomID,
|
await handleRoomRoute(roomID: roomID,
|
||||||
@@ -162,38 +173,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
animated: animated)
|
animated: animated)
|
||||||
}
|
}
|
||||||
case .childEvent(let eventID, let roomID, let via):
|
case .childEvent(let eventID, let roomID, let via):
|
||||||
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
|
handleChildEventRoute(eventID: eventID, roomID: roomID, via: via, animated: animated)
|
||||||
childRoomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
|
||||||
} else if roomID != roomProxy.id {
|
|
||||||
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated))
|
|
||||||
} else {
|
|
||||||
showLoadingIndicator(delay: .seconds(0.5))
|
|
||||||
Task {
|
|
||||||
defer { hideLoadingIndicator() }
|
|
||||||
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
|
|
||||||
case .success(let event):
|
|
||||||
if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
|
|
||||||
if case .thread(threadRootEventID: threadRootEventID, _) = stateMachine.state, let threadCoordinator = childThreadScreenCoordinators.last {
|
|
||||||
threadCoordinator.focusOnEvent(eventID: eventID)
|
|
||||||
} else {
|
|
||||||
// If we are showing the room timeline, we want to focus the thread root.
|
|
||||||
if childThreadScreenCoordinators.isEmpty {
|
|
||||||
roomScreenCoordinator?.focusOnEvent(.init(eventID: threadRootEventID, shouldSetPin: false))
|
|
||||||
}
|
|
||||||
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: eventID))
|
|
||||||
}
|
|
||||||
} else if !childThreadScreenCoordinators.isEmpty {
|
|
||||||
// If we are showing a child thread and we are navigating to a non threaded event
|
|
||||||
// of the same room, we want to push the room on top of the thread.
|
|
||||||
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated))
|
|
||||||
} else {
|
|
||||||
roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false))
|
|
||||||
}
|
|
||||||
case .failure:
|
|
||||||
showErrorIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .share(let payload):
|
case .share(let payload):
|
||||||
guard let roomID = payload.roomID, roomID == self.roomID else {
|
guard let roomID = payload.roomID, roomID == self.roomID else {
|
||||||
fatalError("Navigation route doesn't belong to this room flow.")
|
fatalError("Navigation route doesn't belong to this room flow.")
|
||||||
@@ -226,6 +206,41 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleChildEventRoute(eventID: String, roomID: String, via: [String], animated: Bool) {
|
||||||
|
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
|
||||||
|
childRoomFlowCoordinator.handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via), animated: animated)
|
||||||
|
} else if roomID != roomProxy.id {
|
||||||
|
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated))
|
||||||
|
} else {
|
||||||
|
showLoadingIndicator(delay: .seconds(0.5))
|
||||||
|
Task {
|
||||||
|
defer { hideLoadingIndicator() }
|
||||||
|
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
|
||||||
|
case .success(let event):
|
||||||
|
if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
|
||||||
|
if case .thread(threadRootEventID: threadRootEventID, _) = stateMachine.state, let threadCoordinator = childThreadScreenCoordinators.last {
|
||||||
|
threadCoordinator.focusOnEvent(eventID: eventID)
|
||||||
|
} else {
|
||||||
|
// If we are showing the room timeline, we want to focus the thread root.
|
||||||
|
if childThreadScreenCoordinators.isEmpty {
|
||||||
|
roomScreenCoordinator?.focusOnEvent(.init(eventID: threadRootEventID, shouldSetPin: false))
|
||||||
|
}
|
||||||
|
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: eventID))
|
||||||
|
}
|
||||||
|
} else if !childThreadScreenCoordinators.isEmpty {
|
||||||
|
// If we are showing a child thread and we are navigating to a non threaded event
|
||||||
|
// of the same room, we want to push the room on top of the thread.
|
||||||
|
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: via, entryPoint: .eventID(eventID)), userInfo: EventUserInfo(animated: animated))
|
||||||
|
} else {
|
||||||
|
roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false))
|
||||||
|
}
|
||||||
|
case .failure:
|
||||||
|
showErrorIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func presentTransferOwnershipScreen() {
|
private func presentTransferOwnershipScreen() {
|
||||||
let parameters = RoomChangeRolesScreenCoordinatorParameters(mode: .owner,
|
let parameters = RoomChangeRolesScreenCoordinatorParameters(mode: .owner,
|
||||||
roomProxy: roomProxy,
|
roomProxy: roomProxy,
|
||||||
@@ -281,8 +296,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) {
|
switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) {
|
||||||
case .success(let event):
|
case .success(let event):
|
||||||
if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
|
if flowParameters.appSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
|
||||||
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: threadRootEventID, shouldSetPin: false))), userInfo: EventUserInfo(animated: animated))
|
stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, focusEventID: focusEvent.eventID)), userInfo: EventUserInfo(animated: animated))
|
||||||
stateMachine.tryEvent(.presentThread(threadRootEventID: threadRootEventID, focusEventID: focusEvent.eventID), userInfo: EventUserInfo(animated: false))
|
|
||||||
} else {
|
} else {
|
||||||
stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
|
stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
|
||||||
}
|
}
|
||||||
@@ -552,6 +566,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||||
case .share(.text(_, let text)):
|
case .share(.text(_, let text)):
|
||||||
roomScreenCoordinator?.shareText(text)
|
roomScreenCoordinator?.shareText(text)
|
||||||
|
case .thread(let rootEventID, let focusEventID):
|
||||||
|
roomScreenCoordinator?.focusOnEvent(.init(eventID: rootEventID, shouldSetPin: false))
|
||||||
|
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID))
|
||||||
case .none:
|
case .none:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -586,6 +603,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
case .share(.mediaFiles(_, let mediaFiles)):
|
case .share(.mediaFiles(_, let mediaFiles)):
|
||||||
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
|
stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: mediaFiles.map(\.url)),
|
||||||
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
userInfo: EventUserInfo(animated: animated, timelineController: timelineController))
|
||||||
|
case .thread(let rootEventID, let focusEventID):
|
||||||
|
stateMachine.tryEvent(.presentThread(threadRootEventID: rootEventID, focusEventID: focusEventID))
|
||||||
case .share(.text), .eventFocus:
|
case .share(.text), .eventFocus:
|
||||||
break // These are both handled in the coordinator's init.
|
break // These are both handled in the coordinator's init.
|
||||||
case .none:
|
case .none:
|
||||||
@@ -1564,6 +1583,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
coordinator.handleAppRoute(.share(payload), animated: true)
|
coordinator.handleAppRoute(.share(payload), animated: true)
|
||||||
case .transferOwnership:
|
case .transferOwnership:
|
||||||
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: true)
|
coordinator.handleAppRoute(.transferOwnership(roomID: roomID), animated: true)
|
||||||
|
case .thread:
|
||||||
|
fatalError("This entry point is not allowed for child flows")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,21 @@ extension RoomFlowCoordinator {
|
|||||||
enum PresentationAction: Hashable {
|
enum PresentationAction: Hashable {
|
||||||
case eventFocus(FocusEvent)
|
case eventFocus(FocusEvent)
|
||||||
case share(ShareExtensionPayload)
|
case share(ShareExtensionPayload)
|
||||||
|
case thread(rootEventID: String, focusEventID: String?)
|
||||||
|
|
||||||
var focusedEvent: FocusEvent? {
|
var focusedEvent: FocusEvent? {
|
||||||
switch self {
|
switch self {
|
||||||
case .eventFocus(let focusEvent):
|
case .eventFocus(let focusEvent):
|
||||||
focusEvent
|
focusEvent
|
||||||
|
case .thread(let rootEventID, let focusEventID):
|
||||||
|
// Since this enum is for the room and not the threaded timeline,
|
||||||
|
// we will focus the thread root event id, and not the event id itself
|
||||||
|
// which will be done at the thread presentation level
|
||||||
|
if focusEventID != nil {
|
||||||
|
.init(eventID: rootEventID, shouldSetPin: false)
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
|
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
|
||||||
.roomDetails, .roomMemberDetails, .userProfile,
|
.roomDetails, .roomMemberDetails, .userProfile,
|
||||||
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
|
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
|
||||||
.share, .transferOwnership:
|
.share, .transferOwnership, .thread:
|
||||||
clearPresentedSheets(animated: animated) // Make sure the presented route is visible.
|
clearPresentedSheets(animated: animated) // Make sure the presented route is visible.
|
||||||
chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
|
||||||
if navigationTabCoordinator.selectedTab != .chats {
|
if navigationTabCoordinator.selectedTab != .chats {
|
||||||
|
|||||||
@@ -1026,6 +1026,10 @@ internal enum L10n {
|
|||||||
}
|
}
|
||||||
/// You are viewing the notification! Click me!
|
/// You are viewing the notification! Click me!
|
||||||
internal static var notificationTestPushNotificationContent: String { return L10n.tr("Localizable", "notification_test_push_notification_content") }
|
internal static var notificationTestPushNotificationContent: String { return L10n.tr("Localizable", "notification_test_push_notification_content") }
|
||||||
|
/// Thread in %1$@
|
||||||
|
internal static func notificationThreadInRoom(_ p1: Any) -> String {
|
||||||
|
return L10n.tr("Localizable", "notification_thread_in_room", String(describing: p1))
|
||||||
|
}
|
||||||
/// %1$@: %2$@
|
/// %1$@: %2$@
|
||||||
internal static func notificationTickerTextDm(_ p1: Any, _ p2: Any) -> String {
|
internal static func notificationTickerTextDm(_ p1: Any, _ p2: Any) -> String {
|
||||||
return L10n.tr("Localizable", "notification_ticker_text_dm", String(describing: p1), String(describing: p2))
|
return L10n.tr("Localizable", "notification_ticker_text_dm", String(describing: p1), String(describing: p2))
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ extension UNNotificationContent {
|
|||||||
@objc var pusherNotificationClientIdentifier: String? {
|
@objc var pusherNotificationClientIdentifier: String? {
|
||||||
userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? String
|
userInfo[NotificationConstants.UserInfoKey.pusherNotificationClientIdentifier] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc var threadRootEventID: String? {
|
||||||
|
userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UNMutableNotificationContent {
|
extension UNMutableNotificationContent {
|
||||||
@@ -55,6 +59,15 @@ extension UNMutableNotificationContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var threadRootEventID: String? {
|
||||||
|
get {
|
||||||
|
userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var unreadCount: Int? {
|
var unreadCount: Int? {
|
||||||
userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int
|
userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,7 +395,10 @@ class TimelineTableViewController: UIViewController {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == eventID }),
|
if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == eventID }),
|
||||||
let indexPath = dataSource?.indexPath(for: kvPair.key) {
|
let indexPath = dataSource?.indexPath(for: kvPair.key) {
|
||||||
tableView.scrollToRow(at: indexPath, at: .middle, animated: animated)
|
// Scrolling to the middle created a small bump in the timeline
|
||||||
|
// Using top, which is bottom in the reversed timeline helps with rendering
|
||||||
|
// in full long messages and images
|
||||||
|
tableView.scrollToRow(at: indexPath, at: .top, animated: animated)
|
||||||
coordinator.send(viewAction: .scrolledToFocussedItem)
|
coordinator.send(viewAction: .scrolledToFocussedItem)
|
||||||
// Ensure VoiceOver focus happens after the scroll animation (if any)
|
// Ensure VoiceOver focus happens after the scroll animation (if any)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + (animated ? 0.5 : 0.0)) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + (animated ? 0.5 : 0.0)) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ enum NotificationConstants {
|
|||||||
enum UserInfoKey {
|
enum UserInfoKey {
|
||||||
static let roomIdentifier = "room_id"
|
static let roomIdentifier = "room_id"
|
||||||
static let eventIdentifier = "event_id"
|
static let eventIdentifier = "event_id"
|
||||||
|
static let threadRootEventIdentifier = "thread_root_event_id"
|
||||||
static let unreadCount = "unread_count"
|
static let unreadCount = "unread_count"
|
||||||
static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier"
|
static let pusherNotificationClientIdentifier = "pusher_notification_client_identifier"
|
||||||
static let receiverIdentifier = "receiver_id"
|
static let receiverIdentifier = "receiver_id"
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ struct NotificationItemProxy: NotificationItemProxyProtocol {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var threadRootEventID: String? {
|
||||||
|
notificationItem.threadId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EmptyNotificationItemProxy: NotificationItemProxyProtocol {
|
struct EmptyNotificationItemProxy: NotificationItemProxyProtocol {
|
||||||
@@ -109,4 +113,6 @@ struct EmptyNotificationItemProxy: NotificationItemProxyProtocol {
|
|||||||
var roomJoinedMembers: Int { 0 }
|
var roomJoinedMembers: Int { 0 }
|
||||||
|
|
||||||
var hasMention: Bool { false }
|
var hasMention: Bool { false }
|
||||||
|
|
||||||
|
var threadRootEventID: String? { nil }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ protocol NotificationItemProxyProtocol {
|
|||||||
var isNoisy: Bool { get }
|
var isNoisy: Bool { get }
|
||||||
|
|
||||||
var hasMention: Bool { get }
|
var hasMention: Bool { get }
|
||||||
|
|
||||||
|
var threadRootEventID: String? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NotificationItemProxyProtocol {
|
extension NotificationItemProxyProtocol {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import MatrixRustSDK
|
|||||||
|
|
||||||
final class NSEUserSession {
|
final class NSEUserSession {
|
||||||
let sessionDirectories: SessionDirectories
|
let sessionDirectories: SessionDirectories
|
||||||
|
let appSettings: CommonSettingsProtocol
|
||||||
|
|
||||||
private let baseClient: Client
|
private let baseClient: Client
|
||||||
private let notificationClient: NotificationClient
|
private let notificationClient: NotificationClient
|
||||||
@@ -49,6 +50,7 @@ final class NSEUserSession {
|
|||||||
appSettings: CommonSettingsProtocol) async throws {
|
appSettings: CommonSettingsProtocol) async throws {
|
||||||
sessionDirectories = credentials.restorationToken.sessionDirectories
|
sessionDirectories = credentials.restorationToken.sessionDirectories
|
||||||
userID = credentials.userID
|
userID = credentials.userID
|
||||||
|
self.appSettings = appSettings
|
||||||
|
|
||||||
let homeserverURL = credentials.restorationToken.session.homeserverUrl
|
let homeserverURL = credentials.restorationToken.session.homeserverUrl
|
||||||
let clientBuilder = ClientBuilder
|
let clientBuilder = ClientBuilder
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct NotificationContentBuilder {
|
|||||||
mediaProvider: MediaProviderProtocol) async {
|
mediaProvider: MediaProviderProtocol) async {
|
||||||
notificationContent.receiverID = notificationItem.receiverID
|
notificationContent.receiverID = notificationItem.receiverID
|
||||||
notificationContent.roomID = notificationItem.roomID
|
notificationContent.roomID = notificationItem.roomID
|
||||||
|
notificationContent.threadRootEventID = notificationItem.threadRootEventID
|
||||||
|
|
||||||
switch notificationItem.event {
|
switch notificationItem.event {
|
||||||
case .timeline(let event):
|
case .timeline(let event):
|
||||||
@@ -36,9 +37,16 @@ struct NotificationContentBuilder {
|
|||||||
notificationContent.eventID = nil
|
notificationContent.eventID = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// So that the UI groups notification that are received for the same room but also for the same user
|
// So that the UI groups notification that are received for the same room/thread but also for the same user
|
||||||
|
let threadIdentifier = if userSession.appSettings.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID {
|
||||||
|
// If a threaded message we group notifications also by thread root id
|
||||||
|
"\(notificationItem.receiverID)\(notificationItem.roomID)\(threadRootEventID)"
|
||||||
|
} else {
|
||||||
|
// otherwise only by room and receiver id
|
||||||
|
"\(notificationItem.receiverID)\(notificationItem.roomID)"
|
||||||
|
}
|
||||||
// Removing the @ fixes an iOS bug where the notification crashes if the mute button is tapped
|
// Removing the @ fixes an iOS bug where the notification crashes if the mute button is tapped
|
||||||
notificationContent.threadIdentifier = "\(notificationItem.receiverID)\(notificationItem.roomID)".replacingOccurrences(of: "@", with: "")
|
notificationContent.threadIdentifier = threadIdentifier.replacingOccurrences(of: "@", with: "")
|
||||||
|
|
||||||
MXLog.info("isNoisy: \(notificationItem.isNoisy)")
|
MXLog.info("isNoisy: \(notificationItem.isNoisy)")
|
||||||
notificationContent.sound = notificationItem.isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
|
notificationContent.sound = notificationItem.isNoisy ? UNNotificationSound(named: UNNotificationSoundName(rawValue: "message.caf")) : nil
|
||||||
@@ -133,10 +141,23 @@ struct NotificationContentBuilder {
|
|||||||
|
|
||||||
private func icon(for notificationItem: NotificationItemProxyProtocol) -> NotificationIcon {
|
private func icon(for notificationItem: NotificationItemProxyProtocol) -> NotificationIcon {
|
||||||
if notificationItem.isDM {
|
if notificationItem.isDM {
|
||||||
return NotificationIcon(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: nil)
|
if userSession.appSettings.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID {
|
||||||
|
.init(mediaSource: notificationItem.senderAvatarMediaSource,
|
||||||
|
groupInfo: .init(name: L10n.commonThread,
|
||||||
|
id: "\(notificationItem.roomID)\(threadRootEventID)"))
|
||||||
|
} else {
|
||||||
|
.init(mediaSource: notificationItem.senderAvatarMediaSource, groupInfo: nil)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return NotificationIcon(mediaSource: notificationItem.roomAvatarMediaSource,
|
if userSession.appSettings.threadsEnabled, let threadRootEventID = notificationItem.threadRootEventID {
|
||||||
groupInfo: .init(name: notificationItem.roomDisplayName, id: notificationItem.roomID))
|
.init(mediaSource: notificationItem.roomAvatarMediaSource,
|
||||||
|
groupInfo: .init(name: L10n.notificationThreadInRoom(notificationItem.roomDisplayName),
|
||||||
|
id: "\(notificationItem.roomID)\(threadRootEventID)"))
|
||||||
|
} else {
|
||||||
|
.init(mediaSource: notificationItem.roomAvatarMediaSource,
|
||||||
|
groupInfo: .init(name: notificationItem.roomDisplayName,
|
||||||
|
id: notificationItem.roomID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user