Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 156 additions & 154 deletions NextcloudTalk.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions NextcloudTalk/Calls/CallViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,26 @@ class CallViewController: UIViewController,
}

self.audioMuteButton.accessibilityValue = micStatusString

// Control Center shows whether we're muted
TalkWidgetActions.callDidChange(isInCall: !self.hangingUp, isMicrophoneMuted: !isActive)
}
}

// The microphone was muted or unmuted with the control in Control Center
func setMicrophoneMutedFromControl(_ muted: Bool) {
guard let callController else { return }

callController.getAudioEnabledState { isEnabled in
guard isEnabled == muted else { return }

if CallKitManager.isCallKitAvailable() {
DispatchQueue.main.async {
CallKitManager.sharedInstance().changeAudioMuted(muted, forCall: self.room.token)
}
} else {
self.setAudioMuted(muted)
}
}
}

Expand Down Expand Up @@ -3091,6 +3111,10 @@ class CallViewController: UIViewController,
func finishCall() {
callController = nil

DispatchQueue.main.async {
TalkWidgetActions.callDidChange(isInCall: false, isMicrophoneMuted: false)
}

DispatchQueue.main.async {
self.stopPictureInPicture()
}
Expand Down
12 changes: 12 additions & 0 deletions NextcloudTalk/Rooms/ConversationsWidgetController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,18 @@ class ConversationsWidgetController {
func start() {
NotificationCenter.default.addObserver(self, selector: #selector(setNeedsUpdate), name: .NCRoomsManagerDidUpdateRooms, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(setNeedsUpdate), name: .NCSettingsControllerDidChangeActiveAccount, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(activeAccountDidChange), name: .NCSettingsControllerDidChangeActiveAccount, object: nil)

self.setNeedsUpdate()
}

// The status of the new account is only known once it was loaded again
@objc private func activeAccountDidChange() {
DispatchQueue.main.async {
TalkWidgetActions.userStatusDidChange(nil)
}
}

// Rooms are often updated several times in a row, so the update is slightly delayed to only do it once
@objc func setNeedsUpdate() {
DispatchQueue.main.async {
Expand Down Expand Up @@ -97,6 +105,10 @@ class ConversationsWidgetController {
ConversationsWidgetData.remove(appGroupIdentifier: groupIdentifier)
self.lastSavedData = nil
WidgetCenter.shared.reloadAllTimelines()

DispatchQueue.main.async {
TalkWidgetActions.accountsDidChange()
}
}

// MARK: - Avatars
Expand Down
13 changes: 13 additions & 0 deletions NextcloudTalk/Rooms/RoomsTableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI
notificationCenter.addObserver(self, selector: #selector(appStateHasChanged(_:)), name: .NCAppStateHasChangedNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(connectionStateHasChanged(_:)), name: .NCConnectionStateHasChangedNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(roomsDidUpdate(_:)), name: .NCRoomsManagerDidUpdateRooms, object: nil)
notificationCenter.addObserver(self, selector: #selector(userStatusDidChangeFromWidget), name: .TalkWidgetActionsDidChangeUserStatus, object: nil)
notificationCenter.addObserver(self, selector: #selector(notificationWillBePresented(_:)), name: .NCNotificationControllerWillPresent, object: nil)
notificationCenter.addObserver(self, selector: #selector(serverCapabilitiesUpdated(_:)), name: .NCServerCapabilitiesUpdated, object: nil)
notificationCenter.addObserver(self, selector: #selector(userProfileImageUpdated(_:)), name: .NCUserProfileImageUpdated, object: nil)
Expand Down Expand Up @@ -277,6 +278,11 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI
return false
}

// E.g. from the control in Control Center
func presentNewConversation() {
self.presentNewRoomViewController()
}

@objc private func presentNewRoomViewController() {
let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
let newRoomVC = NewRoomTableViewController(account: activeAccount)
Expand Down Expand Up @@ -547,6 +553,7 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI

self.activeUserStatus = userStatus
self.updateProfileButtonImage()
TalkWidgetActions.userStatusDidChange(userStatus.status)

completion([onlineOption])
}
Expand Down Expand Up @@ -1367,12 +1374,18 @@ class RoomsTableViewController: UITableViewController, CCCertificateDelegate, UI
}
}

// The status was changed in a widget or in Control Center
@objc private func userStatusDidChangeFromWidget() {
self.updateUserStatus()
}

private func updateUserStatus() {
let activeAccount = NCDatabaseManager.sharedInstance().activeAccount()
NCAPIController.sharedInstance().getUserStatus(forAccount: activeAccount) { [weak self] userStatus in
if let userStatus {
self?.activeUserStatus = userStatus
self?.updateProfileButtonImage()
TalkWidgetActions.userStatusDidChange(userStatus.status)
}
}
}
Expand Down
132 changes: 132 additions & 0 deletions NextcloudTalk/Rooms/TalkWidgetActions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//

import UIKit
import WidgetKit

// Performs the actions of the widget buttons and the Control Center controls (see TalkWidgetIntents)
// and keeps the state they show up to date (see TalkControlsState).
enum TalkWidgetActions {

// MARK: - Actions

@available(iOS 17.0, *)
static func markConversationAsRead(server: String, userId: String, token: String) async throws {
guard let account = NCDatabaseManager.sharedInstance().talkAccount(forUserId: userId, inServer: server) else {
throw TalkIntentError.message(NSLocalizedString("Account not found", comment: ""))
}

// Update the widget right away, the server is asked afterwards
await MainActor.run {
if let data = ConversationsWidgetData.load(appGroupIdentifier: groupIdentifier) {
try? data.markingAsRead(token: token).save(appGroupIdentifier: groupIdentifier)
WidgetCenter.shared.reloadTimelines(ofKind: ConversationsWidgetData.conversationsWidgetKind)
WidgetCenter.shared.reloadTimelines(ofKind: ConversationsWidgetData.unreadWidgetKind)
}
}

let lastMessageId = NCDatabaseManager.sharedInstance().room(withToken: token, forAccountId: account.accountId)?.lastMessage?.messageId ?? 0

try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
NCAPIController.sharedInstance().setChatReadMarker(lastMessageId, inRoom: token, forAccount: account) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}

// Also updates the widgets with the real state
NCRoomsManager.shared.updateRooms(updatingUserStatus: false, onlyLastModified: false)
}

@available(iOS 17.0, *)
static func setUserStatus(_ status: String) async throws {
guard NCDatabaseManager.sharedInstance().numberOfAccounts() > 0 else {
throw TalkIntentError.message(NSLocalizedString("Account not found", comment: ""))
}

let account = NCDatabaseManager.sharedInstance().activeAccount()

try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
NCAPIController.sharedInstance().setUserStatus(status, forAccount: account) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}

await MainActor.run {
self.userStatusDidChange(status)
NotificationCenter.default.post(name: .TalkWidgetActionsDidChangeUserStatus, object: nil)
}
}

@MainActor
static func setMicrophoneMuted(_ muted: Bool) {
NCRoomsManager.shared.callViewController?.setMicrophoneMutedFromControl(muted)
}

@MainActor
static func presentNewConversation() {
guard NCDatabaseManager.sharedInstance().numberOfAccounts() > 0 else { return }

NCUserInterfaceController.sharedInstance().presentNewConversation()
}

// MARK: - State

@MainActor
static func callDidChange(isInCall: Bool, isMicrophoneMuted: Bool) {
self.updateState { state in
state.isInCall = isInCall
state.isMicrophoneMuted = isInCall && isMicrophoneMuted
}
}

@MainActor
static func userStatusDidChange(_ status: String?) {
self.updateState { state in
state.userStatus = status
}
}

@MainActor
static func accountsDidChange() {
guard NCDatabaseManager.sharedInstance().numberOfAccounts() == 0 else { return }

TalkControlsState.remove(appGroupIdentifier: groupIdentifier)
self.reloadControls()
}

@MainActor
private static func updateState(_ change: (inout TalkControlsState) -> Void) {
var state = TalkControlsState.load(appGroupIdentifier: groupIdentifier)
let previousState = state
change(&state)

guard state != previousState else { return }

state.save(appGroupIdentifier: groupIdentifier)
self.reloadControls()
}

@MainActor
private static func reloadControls() {
if #available(iOS 18.0, *) {
ControlCenter.shared.reloadControls(ofKind: TalkControlsState.muteMicrophoneControlKind)
ControlCenter.shared.reloadControls(ofKind: TalkControlsState.doNotDisturbControlKind)
}

WidgetCenter.shared.reloadTimelines(ofKind: TalkControlsState.statusWidgetKind)
}
}

extension Notification.Name {
static let TalkWidgetActionsDidChangeUserStatus = Notification.Name("TalkWidgetActionsDidChangeUserStatus")
}
12 changes: 12 additions & 0 deletions NextcloudTalk/User Interface/NCUserInterfaceController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,18 @@ public typealias PresentCallControllerCompletionBlock = () -> Void
}
}

func presentNewConversation() {
// Not while in a call, the call view would be dismissed
guard NCRoomsManager.shared.callViewController == nil else { return }

mainViewController.dismiss(animated: true) { [weak self] in
guard let self else { return }

self.popToConversationsList()
self.roomsTableViewController.presentNewConversation()
}
}

func presentAlertViewController(_ alertViewController: UIAlertController) {
if let presentedViewController = mainViewController.presentedViewController, !presentedViewController.isBeingDismissed {
// When the callview is presented, we need to show the alert this way
Expand Down
48 changes: 48 additions & 0 deletions NextcloudTalkTests/Unit/UnitInteractiveWidgetsTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//

import XCTest
@testable import NextcloudTalk

final class UnitInteractiveWidgetsTest: XCTestCase {

func testMarkingAsReadInWidgetData() {
let unreadMention = ConversationsWidgetData.Conversation(id: "1", token: "t1", displayName: "Design", unreadMessages: 3, unreadMention: true,
isFavorite: true, hasCall: false, avatarFileName: "a.png")
let unread = ConversationsWidgetData.Conversation(id: "2", token: "t2", displayName: "Bob", unreadMessages: 1, unreadMention: false,
isFavorite: false, hasCall: true, avatarFileName: nil)
let data = ConversationsWidgetData(server: "https://cloud.example.com", userId: "alice", conversations: [unreadMention, unread],
unreadConversations: 2, unreadMentions: 1)

let markedAsRead = data.markingAsRead(token: "t1")

XCTAssertEqual(markedAsRead.conversations[0].unreadMessages, 0)
XCTAssertFalse(markedAsRead.conversations[0].unreadMention)
XCTAssertEqual(markedAsRead.conversations[0].avatarFileName, "a.png")
XCTAssertEqual(markedAsRead.conversations[1], unread)
XCTAssertEqual(markedAsRead.unreadConversations, 1)
XCTAssertEqual(markedAsRead.unreadMentions, 0)

// Already read or unknown conversations change nothing
XCTAssertEqual(markedAsRead.markingAsRead(token: "t1"), markedAsRead)
XCTAssertEqual(data.markingAsRead(token: "unknown"), data)
}

func testControlsStateRoundTrip() {
let suiteName = "UnitSystemIntegrationTest"
defer { UserDefaults().removePersistentDomain(forName: suiteName) }

XCTAssertEqual(TalkControlsState.load(appGroupIdentifier: suiteName), TalkControlsState())

let state = TalkControlsState(isInCall: true, isMicrophoneMuted: true, userStatus: "dnd")
state.save(appGroupIdentifier: suiteName)

XCTAssertEqual(TalkControlsState.load(appGroupIdentifier: suiteName), state)

TalkControlsState.remove(appGroupIdentifier: suiteName)

XCTAssertEqual(TalkControlsState.load(appGroupIdentifier: suiteName), TalkControlsState())
}
}
Loading
Loading