|
@@ -279,6 +279,9 @@ final class ViewController: NSViewController {
|
|
|
let startedAt: Date
|
|
let startedAt: Date
|
|
|
let endedAt: Date
|
|
let endedAt: Date
|
|
|
let audioFilePath: String
|
|
let audioFilePath: String
|
|
|
|
|
+ /// Google account email that was signed in when this recording was saved.
|
|
|
|
|
+ /// Used to scope AI Companion history per-account.
|
|
|
|
|
+ var accountEmail: String?
|
|
|
var microphoneAudioFilePath: String?
|
|
var microphoneAudioFilePath: String?
|
|
|
var systemAudioFilePath: String?
|
|
var systemAudioFilePath: String?
|
|
|
var transcriptStatusRaw: String?
|
|
var transcriptStatusRaw: String?
|
|
@@ -1562,7 +1565,20 @@ private extension ViewController {
|
|
|
}
|
|
}
|
|
|
let decoder = JSONDecoder()
|
|
let decoder = JSONDecoder()
|
|
|
if let decoded = try? decoder.decode([MeetingRecordingSummary].self, from: raw) {
|
|
if let decoded = try? decoder.decode([MeetingRecordingSummary].self, from: raw) {
|
|
|
- aiCompanionLocalRecordings = decoded.sorted(by: { $0.endedAt > $1.endedAt })
|
|
|
|
|
|
|
+ var migrated = decoded
|
|
|
|
|
+ let testEmail = "mqlmac1@gmail.com"
|
|
|
|
|
+ var didMigrate = false
|
|
|
|
|
+ for idx in migrated.indices {
|
|
|
|
|
+ let existing = migrated[idx].accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if existing?.isEmpty != false {
|
|
|
|
|
+ migrated[idx].accountEmail = testEmail
|
|
|
|
|
+ didMigrate = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ aiCompanionLocalRecordings = migrated.sorted(by: { $0.endedAt > $1.endedAt })
|
|
|
|
|
+ if didMigrate {
|
|
|
|
|
+ persistAiCompanionLocalRecordings()
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
aiCompanionLocalRecordings = []
|
|
aiCompanionLocalRecordings = []
|
|
|
}
|
|
}
|
|
@@ -1574,6 +1590,18 @@ private extension ViewController {
|
|
|
UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
|
|
UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func aiCompanionActiveAccountEmail() -> String? {
|
|
|
|
|
+ guard hasGoogleSessionAvailable() else { return nil }
|
|
|
|
|
+ let raw = scheduleCurrentProfile?.email.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard let raw, raw.isEmpty == false, raw.contains("@") else { return nil }
|
|
|
|
|
+ return raw.lowercased()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionRecordingsForActiveAccount() -> [MeetingRecordingSummary] {
|
|
|
|
|
+ guard let email = aiCompanionActiveAccountEmail() else { return [] }
|
|
|
|
|
+ return aiCompanionLocalRecordings.filter { ($0.accountEmail ?? "").lowercased() == email }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func requestSpeechRecognitionAuthorizationIfNeeded() async throws {
|
|
private func requestSpeechRecognitionAuthorizationIfNeeded() async throws {
|
|
|
switch SFSpeechRecognizer.authorizationStatus() {
|
|
switch SFSpeechRecognizer.authorizationStatus() {
|
|
|
case .authorized:
|
|
case .authorized:
|
|
@@ -1921,6 +1949,24 @@ private extension ViewController {
|
|
|
microphoneURL: session.microphoneAudioFileURL,
|
|
microphoneURL: session.microphoneAudioFileURL,
|
|
|
recordingID: session.id
|
|
recordingID: session.id
|
|
|
)
|
|
)
|
|
|
|
|
+ var accountEmail = await MainActor.run { self.aiCompanionActiveAccountEmail() }
|
|
|
|
|
+ if accountEmail == nil, self.hasGoogleSessionAvailable() {
|
|
|
|
|
+ do {
|
|
|
|
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
|
|
+ if let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token) {
|
|
|
|
|
+ let display = await MainActor.run { self.makeGoogleProfileDisplay(from: profile) }
|
|
|
|
|
+ await MainActor.run { [weak self] in
|
|
|
|
|
+ self?.applyGoogleProfile(display)
|
|
|
|
|
+ }
|
|
|
|
|
+ let cleaned = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if let cleaned, cleaned.isEmpty == false {
|
|
|
|
|
+ accountEmail = cleaned.lowercased()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // If profile lookup fails, persist recording without account scoping (it will be hidden in AI Companion).
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
await MainActor.run {
|
|
await MainActor.run {
|
|
|
let summary = MeetingRecordingSummary(
|
|
let summary = MeetingRecordingSummary(
|
|
|
id: session.id,
|
|
id: session.id,
|
|
@@ -1929,6 +1975,7 @@ private extension ViewController {
|
|
|
startedAt: session.startedAt,
|
|
startedAt: session.startedAt,
|
|
|
endedAt: Date(),
|
|
endedAt: Date(),
|
|
|
audioFilePath: finalized.mixedURL.path,
|
|
audioFilePath: finalized.mixedURL.path,
|
|
|
|
|
+ accountEmail: accountEmail,
|
|
|
microphoneAudioFilePath: finalized.microphoneURL?.path,
|
|
microphoneAudioFilePath: finalized.microphoneURL?.path,
|
|
|
systemAudioFilePath: finalized.systemURL?.path,
|
|
systemAudioFilePath: finalized.systemURL?.path,
|
|
|
transcriptStatusRaw: MeetingTranscriptStatus.notRequested.rawValue,
|
|
transcriptStatusRaw: MeetingTranscriptStatus.notRequested.rawValue,
|
|
@@ -2870,15 +2917,92 @@ private extension ViewController {
|
|
|
titleLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
titleLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
|
subtitle.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
subtitle.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
|
|
|
|
|
|
|
|
+ let signedIn = hasGoogleSessionAvailable()
|
|
|
|
|
+ let activeEmail = aiCompanionActiveAccountEmail()
|
|
|
|
|
+
|
|
|
|
|
+ if signedIn == false || activeEmail == nil {
|
|
|
|
|
+ let locked = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
|
|
|
|
|
+ locked.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ styleSurface(locked, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
+
|
|
|
|
|
+ let lockedStack = NSStackView()
|
|
|
|
|
+ lockedStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ lockedStack.orientation = .vertical
|
|
|
|
|
+ lockedStack.alignment = .leading
|
|
|
|
|
+ lockedStack.spacing = 10
|
|
|
|
|
+
|
|
|
|
|
+ let msg = textLabel(
|
|
|
|
|
+ "Connect your Google account to see your AI Companion meeting history.",
|
|
|
|
|
+ font: NSFont.systemFont(ofSize: 14, weight: .semibold),
|
|
|
|
|
+ color: palette.textPrimary
|
|
|
|
|
+ )
|
|
|
|
|
+ msg.alignment = .left
|
|
|
|
|
+ msg.maximumNumberOfLines = 2
|
|
|
|
|
+ msg.lineBreakMode = .byWordWrapping
|
|
|
|
|
+
|
|
|
|
|
+ lockedStack.addArrangedSubview(msg)
|
|
|
|
|
+ locked.addSubview(lockedStack)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ lockedStack.leadingAnchor.constraint(equalTo: locked.leadingAnchor, constant: 14),
|
|
|
|
|
+ lockedStack.trailingAnchor.constraint(equalTo: locked.trailingAnchor, constant: -14),
|
|
|
|
|
+ lockedStack.topAnchor.constraint(equalTo: locked.topAnchor, constant: 14),
|
|
|
|
|
+ lockedStack.bottomAnchor.constraint(equalTo: locked.bottomAnchor, constant: -14)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ contentStack.addArrangedSubview(locked)
|
|
|
|
|
+ locked.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
|
|
|
+
|
|
|
|
|
+ // If tokens exist but we haven't fetched the profile yet, try to hydrate it so we can scope history correctly.
|
|
|
|
|
+ if signedIn, scheduleCurrentProfile == nil {
|
|
|
|
|
+ Task { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ do {
|
|
|
|
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
|
|
+ if let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token) {
|
|
|
|
|
+ await MainActor.run { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ self.applyGoogleProfile(self.makeGoogleProfileDisplay(from: profile))
|
|
|
|
|
+ self.redrawAiCompanionPageIfNeeded()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Leave UI in connect state; schedule page handles showing auth errors.
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ content.addSubview(contentStack)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
|
|
|
|
|
+ scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
|
|
|
|
|
+ scroll.topAnchor.constraint(equalTo: panel.topAnchor),
|
|
|
|
|
+ scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
|
|
|
|
|
+ content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
|
|
|
|
|
+ content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
|
|
|
|
|
+ content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
|
|
|
|
|
+ content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ contentStack.leftAnchor.constraint(equalTo: content.leftAnchor, constant: 28),
|
|
|
|
|
+ contentStack.rightAnchor.constraint(equalTo: content.rightAnchor, constant: -28),
|
|
|
|
|
+ contentStack.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
|
|
|
|
|
+ content.bottomAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 16)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ return panel
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if let session = activeMeetingRecordingSession {
|
|
if let session = activeMeetingRecordingSession {
|
|
|
let activeCard = aiCompanionActiveRecordingCard(session: session)
|
|
let activeCard = aiCompanionActiveRecordingCard(session: session)
|
|
|
contentStack.addArrangedSubview(activeCard)
|
|
contentStack.addArrangedSubview(activeCard)
|
|
|
activeCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
activeCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if aiCompanionLocalRecordings.isEmpty {
|
|
|
|
|
|
|
+ let scopedRecordings = aiCompanionRecordingsForActiveAccount()
|
|
|
|
|
+ if scopedRecordings.isEmpty {
|
|
|
let emptyLabel = textLabel(
|
|
let emptyLabel = textLabel(
|
|
|
- "No previous meetings yet. End a meeting to save it here and generate notes.",
|
|
|
|
|
|
|
+ "No previous meetings yet for \(activeEmail ?? "this account"). End a meeting to save it here and generate notes.",
|
|
|
font: typography.fieldLabel,
|
|
font: typography.fieldLabel,
|
|
|
color: palette.textMuted
|
|
color: palette.textMuted
|
|
|
)
|
|
)
|
|
@@ -3059,7 +3183,7 @@ private extension ViewController {
|
|
|
private func filteredAiCompanionRecordings() -> [MeetingRecordingSummary] {
|
|
private func filteredAiCompanionRecordings() -> [MeetingRecordingSummary] {
|
|
|
let calendar = Calendar.current
|
|
let calendar = Calendar.current
|
|
|
let now = Date()
|
|
let now = Date()
|
|
|
- let recordings = aiCompanionLocalRecordings
|
|
|
|
|
|
|
+ let recordings = aiCompanionRecordingsForActiveAccount()
|
|
|
|
|
|
|
|
func previousCalendarWeekBounds(reference: Date) -> (start: Date, end: Date)? {
|
|
func previousCalendarWeekBounds(reference: Date) -> (start: Date, end: Date)? {
|
|
|
guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: reference),
|
|
guard let thisWeek = calendar.dateInterval(of: .weekOfYear, for: reference),
|
|
@@ -9936,6 +10060,8 @@ private extension ViewController {
|
|
|
scheduleCurrentProfile = profile
|
|
scheduleCurrentProfile = profile
|
|
|
|
|
|
|
|
updateGoogleAuthButtonTitle()
|
|
updateGoogleAuthButtonTitle()
|
|
|
|
|
+ pageCache[.aiCompanion] = nil
|
|
|
|
|
+ redrawAiCompanionPageIfNeeded()
|
|
|
|
|
|
|
|
guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
guard let profile, let pictureURL = profile.pictureURL else { return }
|
|
|
let avatarDiameter = scheduleGoogleSignedInAvatarSize
|
|
let avatarDiameter = scheduleGoogleSignedInAvatarSize
|