Skip to content

Commit f7d7d12

Browse files
committed
Add quick-contact-actions extension
- Fix icon and reorganize action panel sections - Prepare for publishing: README, changelog, clean up tracked files - Prepare for Raycast Store publishing - Fix Open/Edit in Contacts to use AppleScript - Add favorite contacts feature (⌘⇧S) - Add Edit in Contacts action (⌘⇧O) - Add detail pane profile picture and organization name - Fix flicker: batch contacts and frequency into single render - Fix selection: load frequency before showing cached contacts - Revert detail pane to Metadata.Label with colored icons - Revert list accessories to icons, add colored tags in detail pane - Show primary label tags as accessories instead of count icons - Group non-alpha contacts under # like standard phonebooks - Group contacts by first letter in phonebook-style sections - Fix frequently contacted: use Action with push() instead of Action.Push - Add frequently contacted sorting with persistent tracking - Add colored icons to ContactActions sections - Revert Swift to original thumbnails, simplify detail panel to metadata-only - Generate initials avatars for contacts without photos - Use standard markdown image for circular contact photo in detail panel - Circular contact photo in detail panel with name beside it - Show circular contact photo in detail panel metadata - Re-add contact photo to detail panel as small 80px image - Remove oversized photo from detail panel, keep metadata only - Fix detail panel image: encode spaces in file:// path - Add detail side panel with contact info (⌘D toggle) - Add empty search state and contact row accessories - Add email addresses to FaceTime Video section - Add Open in Contacts action (⌘O) - Group copy actions into separate ActionPanel section - Align shortcuts with Raycast built-in Contacts and add copy actions - Fix reserved shortcut: ⌘P → ⌘⇧P for Call action - Pre-compile Swift contact fetcher for faster startup - Cache contacts for instant loading on repeat opens - Add keyboard shortcuts for quick actions on main contact list - Working state: contacts, photos, search ranking, contact argument feature
1 parent dd868b0 commit f7d7d12

File tree

11 files changed

+3926
-0
lines changed

11 files changed

+3926
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
6+
# Raycast specific files
7+
raycast-env.d.ts
8+
.raycast-swift-build
9+
.swiftpm
10+
compiled_raycast_swift
11+
compiled_raycast_rust
12+
13+
# compiled Swift binary (rebuilt via prebuild)
14+
assets/get-contacts
15+
16+
# misc
17+
.DS_Store
18+
19+
# Claude Code
20+
.claude/
21+
CLAUDE.md
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"printWidth": 120,
3+
"singleQuote": false
4+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Quick Contact Actions Changelog
2+
3+
## [Initial Version] - {PR_MERGE_DATE}
4+
5+
- Search contacts with custom scoring (name, phone digits, email)
6+
- Quick actions: FaceTime Video/Audio, Phone, Message, Email
7+
- Detail side panel with profile picture and organization name
8+
- Circular photo (from Swift) or colored initials (SVG) in detail pane
9+
- Favorites section with pin/unpin support
10+
- Frequently contacted section (top 5)
11+
- Phonebook-style alphabetical sections
12+
- Open and edit contacts in Contacts.app via AppleScript
13+
- Contact argument for quick launch (auto-navigate on unique match)
14+
- Copy actions for name, phone, and email
15+
- Keyboard shortcuts for all actions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Quick Contact Actions
2+
3+
Search your macOS contacts and launch calls, messages, and emails instantly from Raycast.
4+
5+
## Features
6+
7+
- **Spotlight-style search** with smart scoring — matches by name, phone digits, or email
8+
- **Quick actions** — FaceTime Video/Audio, Phone Call, iMessage, Email
9+
- **Detail panel** (⌘D) with profile picture, name, and organization
10+
- **Favorites** (⌘⇧S) — pin contacts to the top
11+
- **Frequently contacted** — your top 5 most-used contacts appear automatically
12+
- **Open/Edit in Contacts** — jump straight to the contact in Contacts.app
13+
- **Contact argument** — type `qca ana` to auto-navigate if there's a unique match
14+
15+
## Keyboard Shortcuts
16+
17+
| Shortcut | Action |
18+
|----------|--------|
19+
| ⌘⇧F | FaceTime Video |
20+
| ⌘⇧A | FaceTime Audio |
21+
| ⌘⇧C | Call |
22+
| ⌘M | Send Message |
23+
| ⌘E | Send Email |
24+
| ⌘O | Open in Contacts |
25+
| ⌘⇧O | Edit in Contacts |
26+
| ⌘D | Toggle Detail Panel |
27+
| ⌘⇧S | Toggle Favorite |
28+
| ⌘. | Copy Name |
29+
| ⌘⇧. | Copy Phone Number |
30+
| ⌘⇧E | Copy Email |
31+
32+
## Permissions
33+
34+
- **Contacts** — required to read your contacts. macOS will prompt automatically on first use.
35+
- **Accessibility** — required for "Edit in Contacts" (sends ⌘L keystroke to Contacts.app via System Events). macOS will prompt when first used.
123 KB
Loading
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import Foundation
2+
import Contacts
3+
import CoreGraphics
4+
import ImageIO
5+
6+
func makeCircularImage(from data: Data, size: Int) -> Data? {
7+
let s = CGFloat(size)
8+
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
9+
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil),
10+
let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8,
11+
bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(),
12+
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
13+
let rect = CGRect(x: 0, y: 0, width: s, height: s)
14+
ctx.addEllipse(in: rect)
15+
ctx.clip()
16+
ctx.draw(cgImage, in: rect)
17+
guard let clipped = ctx.makeImage() else { return nil }
18+
let outData = NSMutableData()
19+
guard let destFinal = CGImageDestinationCreateWithData(outData, "public.png" as CFString, 1, nil) else { return nil }
20+
CGImageDestinationAddImage(destFinal, clipped, nil)
21+
guard CGImageDestinationFinalize(destFinal) else { return nil }
22+
return outData as Data
23+
}
24+
25+
// Handle open/edit mode — reveal contact in Contacts.app
26+
if CommandLine.arguments.count > 2 && (CommandLine.arguments[1] == "--open" || CommandLine.arguments[1] == "--edit") {
27+
let isEdit = CommandLine.arguments[1] == "--edit"
28+
let contactId = CommandLine.arguments[2]
29+
let openStore = CNContactStore()
30+
do {
31+
let keys = [CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor]
32+
let contact = try openStore.unifiedContact(withIdentifier: contactId, keysToFetch: keys)
33+
let name = "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespaces)
34+
let escaped = name.replacingOccurrences(of: "\"", with: "\\\"")
35+
var script = "tell application \"Contacts\"\nset thePeople to every person whose name is \"\(escaped)\"\nif (count of thePeople) > 0 then\nset selection to item 1 of thePeople\nend if\nactivate\nend tell"
36+
if isEdit {
37+
script += "\ndelay 0.1\ntell application \"System Events\" to keystroke \"l\" using command down"
38+
}
39+
let proc = Process()
40+
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
41+
proc.arguments = ["-e", script]
42+
try proc.run()
43+
proc.waitUntilExit()
44+
} catch {
45+
fputs("error: \(error.localizedDescription)\n", stderr)
46+
exit(1)
47+
}
48+
exit(0)
49+
}
50+
51+
// Image cache dir passed as first argument (environment.supportPath from Raycast)
52+
let imageDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : NSTemporaryDirectory()
53+
let imageDirURL = URL(fileURLWithPath: imageDir, isDirectory: true)
54+
try? FileManager.default.createDirectory(at: imageDirURL, withIntermediateDirectories: true)
55+
56+
let store = CNContactStore()
57+
let keysToFetch: [CNKeyDescriptor] = [
58+
CNContactIdentifierKey as CNKeyDescriptor,
59+
CNContactGivenNameKey as CNKeyDescriptor,
60+
CNContactFamilyNameKey as CNKeyDescriptor,
61+
CNContactOrganizationNameKey as CNKeyDescriptor,
62+
CNContactPhoneNumbersKey as CNKeyDescriptor,
63+
CNContactEmailAddressesKey as CNKeyDescriptor,
64+
CNContactImageDataAvailableKey as CNKeyDescriptor,
65+
CNContactThumbnailImageDataKey as CNKeyDescriptor,
66+
]
67+
68+
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
69+
request.sortOrder = .givenName
70+
var contacts: [[String: Any]] = []
71+
72+
do {
73+
try store.enumerateContacts(with: request) { contact, _ in
74+
guard !contact.phoneNumbers.isEmpty || !contact.emailAddresses.isEmpty else { return }
75+
76+
let firstName = contact.givenName
77+
let lastName = contact.familyName
78+
let org = contact.organizationName
79+
var name = "\(firstName) \(lastName)".trimmingCharacters(in: .whitespaces)
80+
if name.isEmpty { name = org }
81+
if name.isEmpty { return }
82+
83+
let phones = contact.phoneNumbers.map { labeled -> [String: String] in
84+
let label = labeled.label.flatMap { CNLabeledValue<NSString>.localizedString(forLabel: $0) } ?? "Phone"
85+
return ["label": label, "value": labeled.value.stringValue]
86+
}
87+
88+
let emails = contact.emailAddresses.map { labeled -> [String: String] in
89+
let label = labeled.label.flatMap { CNLabeledValue<NSString>.localizedString(forLabel: $0) } ?? "Email"
90+
return ["label": label, "value": labeled.value as String]
91+
}
92+
93+
var entry: [String: Any] = ["id": contact.identifier, "name": name, "phones": phones, "emails": emails]
94+
95+
// Include organization only when the contact has a real first/last name (org is supplementary)
96+
if !firstName.isEmpty || !lastName.isEmpty, !org.isEmpty {
97+
entry["organization"] = org
98+
}
99+
100+
// Save thumbnail to disk and return the path
101+
if contact.imageDataAvailable, let imageData = contact.thumbnailImageData {
102+
let safeId = contact.identifier.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
103+
let imageURL = imageDirURL.appendingPathComponent("\(safeId).jpg")
104+
if !FileManager.default.fileExists(atPath: imageURL.path) {
105+
try? imageData.write(to: imageURL)
106+
}
107+
entry["imagePath"] = imageURL.path
108+
109+
// Circular crop for detail pane
110+
let circleURL = imageDirURL.appendingPathComponent("\(safeId)_circle.png")
111+
if !FileManager.default.fileExists(atPath: circleURL.path),
112+
let circleData = makeCircularImage(from: imageData, size: 160) {
113+
try? circleData.write(to: circleURL)
114+
}
115+
if FileManager.default.fileExists(atPath: circleURL.path) {
116+
entry["circleImagePath"] = circleURL.path
117+
}
118+
}
119+
120+
contacts.append(entry)
121+
}
122+
} catch {
123+
fputs("error: \(error.localizedDescription)\n", stderr)
124+
exit(1)
125+
}
126+
127+
if let data = try? JSONSerialization.data(withJSONObject: contacts),
128+
let json = String(data: data, encoding: .utf8)
129+
{
130+
print(json)
131+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const { defineConfig } = require("eslint/config");
2+
const raycastConfig = require("@raycast/eslint-config");
3+
4+
module.exports = defineConfig([
5+
...raycastConfig,
6+
]);

0 commit comments

Comments
 (0)