-
-
Notifications
You must be signed in to change notification settings - Fork 56
ai: join meetings from the calendar, multiline composer, two-column file picker #374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
68079ad
files mention, help improve, bugs fixing
kunkka19xx a3d4f53
Merge branch 'main' into ai/capa-expanding-1
kunkka19xx 7a287b2
better calendar and meetings interactive
kunkka19xx c53bfdd
contact, message, facetime...
kunkka19xx 628856d
clean and address comments
kunkka19xx 94e43ef
clean again, minimize comment, fix ci
kunkka19xx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
187 changes: 187 additions & 0 deletions
187
apps/macos/LauncherApp/look-app/Support/Actions/ActionController+Links.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| import AppKit | ||
| import Foundation | ||
|
|
||
| /// The two tiers that end in "open a URL from a list": `join` a meeting and | ||
| /// `call` a person. | ||
| /// | ||
| /// Split out of `ActionController` because they share a shape and nothing else | ||
| /// does: resolve a name against a platform store (EventKit, Contacts), settle | ||
| /// access first so "cannot see" never reads as "nothing found", and hand back | ||
| /// a `LinkPicker` for the user to choose from. | ||
| extension ActionController { | ||
| /// Joinable meetings for the user to pick from. Returns feedback only when | ||
| /// there is nothing to show. | ||
| func presentJoinChoices(named name: String?, didAsk: Bool = false) -> String { | ||
| // No access reads downstream as "no meetings", so settle it first. | ||
| switch EventKitService.shared.calendarAccess { | ||
| case .authorized: | ||
| break | ||
| case .notDetermined: | ||
| // Once only: a failed request leaves the status unchanged, and | ||
| // retrying on that loops. | ||
| guard !didAsk else { return Self.noCalendarAccess } | ||
| Task { | ||
| await EventKitService.shared.requestCalendarAccess() | ||
| setFeedback(presentJoinChoices(named: name, didAsk: true)) | ||
| } | ||
| return "" | ||
| // Write-only cannot read events. | ||
| case .writeOnly, .denied, .restricted: | ||
| setLinkPicker(nil) | ||
| return Self.noCalendarAccess | ||
| } | ||
|
|
||
| let wanted = name ?? "" | ||
| let outcome = MeetingService.shared.outcome(name: wanted) | ||
| guard !outcome.meetings.isEmpty else { | ||
| setLinkPicker(nil) | ||
| return Self.nothingToJoin(wanted: wanted, withoutLink: outcome.withoutLink) | ||
| } | ||
| setLinkPicker( | ||
| LinkPicker(header: "Join which?", options: outcome.meetings.map(Self.option), selected: 0)) | ||
| return "" | ||
| } | ||
|
|
||
| /// One meeting as a picker row. | ||
| private static func option(_ meeting: JoinableMeeting) -> LinkOption { | ||
| var detail = [meeting.providerLabel, MeetingTiming.phrase(meeting)] | ||
| if let host = URL(string: meeting.url)?.host { detail.append(host) } | ||
| return LinkOption( | ||
| // Not the URL alone: a personal room and a recurring series | ||
| // repeat it, and duplicate ids break row identity. | ||
| id: "\(meeting.startUnixS)|\(meeting.url)", | ||
| title: meeting.title, | ||
| detail: detail.joined(separator: " · "), | ||
| symbol: "video.fill", | ||
| url: meeting.url) | ||
| } | ||
|
|
||
| /// Ways to reach the person the user named. Always lists, even for one | ||
| /// option: a call has no undo, so the row the user reads is the confirm. | ||
| func presentCallChoices(named name: String, modality: String?, didAsk: Bool = false) | ||
| -> String | ||
| { | ||
| switch ContactsService.shared.access { | ||
| case .authorized: | ||
| break | ||
| case .notDetermined: | ||
| // Once only: see the calendar branch. | ||
| guard !didAsk else { return Self.noContactsAccess } | ||
| Task { | ||
| await ContactsService.shared.requestAccess() | ||
| setFeedback(presentCallChoices(named: name, modality: modality, didAsk: true)) | ||
| } | ||
| return "" | ||
| case .writeOnly, .denied, .restricted: | ||
| setLinkPicker(nil) | ||
| return Self.noContactsAccess | ||
| } | ||
|
|
||
| // The verb did not say how. | ||
| let wantedModality = modality ?? EngineBridge.shared.defaultCallModality | ||
| let matches = ContactsService.shared.matches(name: name) | ||
| let options: [LinkOption] = matches.flatMap { match in | ||
| match.handles | ||
| .filter { $0.modalityID == wantedModality } | ||
| .compactMap { handle in Self.option(match: match, handle: handle) } | ||
| } | ||
|
|
||
| guard !options.isEmpty else { | ||
| setLinkPicker(nil) | ||
| guard !matches.isEmpty else { | ||
| return "No contact matching \u{201C}\(name)\u{201D}." | ||
| } | ||
| // Found the person, but no handle for this modality. | ||
| return "\u{201C}\(matches[0].name)\u{201D} has no number or address for that." | ||
| } | ||
|
|
||
| setLinkPicker( | ||
| LinkPicker( | ||
| header: matches.count > 1 ? "Reach who?" : "Reach how?", | ||
| options: options, | ||
| selected: 0)) | ||
| return "" | ||
| } | ||
|
|
||
| private static func option(match: ContactMatch, handle: ContactHandle) -> LinkOption? { | ||
| guard let url = EngineBridge.shared.callURL(modality: handle.modalityID, handle: handle.handle) | ||
| else { return nil } | ||
| var detail = [handle.modalityLabel] | ||
| if let label = handle.handleLabel, !label.isEmpty { detail.append(label) } | ||
| detail.append(handle.handle) | ||
| return LinkOption( | ||
| id: "\(match.id)|\(handle.id)", | ||
| title: match.name, | ||
| detail: detail.joined(separator: " · "), | ||
| symbol: handle.modalityID == "message" ? "message.fill" : "video.fill", | ||
| url: url) | ||
| } | ||
|
|
||
| private static let noContactsAccess = | ||
| "Look has no contacts access, so it cannot find who you mean. " | ||
| + "Grant it in Settings (\u{2318}\u{21E7},) under Permissions." | ||
|
|
||
| private static let noCalendarAccess = | ||
| "Look has no calendar access, so it cannot see your meetings. " | ||
| + "Grant it in Settings (\u{2318}\u{21E7},) under Permissions." | ||
|
|
||
| /// Why there was nothing to open. A meeting with no link is named, since | ||
| /// that is a different problem from having no such meeting. | ||
| private static func nothingToJoin(wanted: String, withoutLink: [String]) -> String { | ||
| let where_ = "Add a Zoom, Teams, or Meet link to its URL, location, or notes." | ||
| switch withoutLink.count { | ||
| case 0: | ||
| guard !wanted.isEmpty else { return "No meeting to join in the next two days." } | ||
| return "No meeting matching \u{201C}\(wanted)\u{201D} in the next two days." | ||
| case 1: | ||
| return "\u{201C}\(withoutLink[0])\u{201D} has no meeting link. \(where_)" | ||
| default: | ||
| let named = withoutLink.map { "\u{201C}\($0)\u{201D}" }.joined(separator: ", ") | ||
| return "No join link on \(named). \(where_)" | ||
| } | ||
| } | ||
|
|
||
| /// Opens the highlighted row. Returns whether it opened, so the caller can | ||
| /// hide the launcher. Success leaves no feedback: a sticky bar would block | ||
| /// the sessions list, which reads feedback as "busy". | ||
| @discardableResult | ||
| func openSelectedLink() -> Bool { | ||
| guard let picker = linkPicker, let option = picker.selectedOption else { return false } | ||
| // Cleared only on success, so a failed open leaves the list to retry. | ||
| guard let url = URL(string: option.url), NSWorkspace.shared.open(url) else { | ||
| setFeedback("Could not open \(option.title).") | ||
| return false | ||
| } | ||
| setLinkPicker(nil) | ||
| setFeedback("") | ||
| return true | ||
| } | ||
|
|
||
| /// Tab / arrows roll the picker. False when none is up, so the key falls | ||
| /// through. | ||
| @discardableResult | ||
| func movePickerSelection(forward: Bool) -> Bool { | ||
| guard var picker = linkPicker, !picker.options.isEmpty else { return false } | ||
| let count = picker.options.count | ||
| picker.selected = | ||
| forward | ||
| ? (picker.selected >= count - 1 ? 0 : picker.selected + 1) | ||
| : (picker.selected <= 0 ? count - 1 : picker.selected - 1) | ||
| setLinkPicker(picker) | ||
| return true | ||
| } | ||
|
|
||
| /// Moves the highlight to a 1-based position. False when the number names | ||
| /// no row, so it stays an ordinary message. | ||
| @discardableResult | ||
| func selectPickerRow(number: Int) -> Bool { | ||
| guard var picker = linkPicker, number >= 1, number <= picker.options.count else { | ||
| return false | ||
| } | ||
| picker.selected = number - 1 | ||
| setLinkPicker(picker) | ||
| return true | ||
| } | ||
|
|
||
| func clearPicker() { setLinkPicker(nil) } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.