Something went wrong. Try again.
A lightweight macOS menu bar app for quickly composing and queuing posts to Buffer.
Something went wrong. Try again.
16 kB · 433 lines
Swift
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434import SwiftUIimport UniformTypeIdentifiersimport AppKit
struct MenuBarContentView: View { @EnvironmentObject var store: AppStore @State private var showingSettings = false
var body: some View { Group { if store.isAuthenticated { if showingSettings { SettingsView(showingSettings: $showingSettings) } else { ComposeView(showingSettings: $showingSettings) } } else { SetupView() } } .frame(width: 320) .padding(16) }}
// MARK: - Setup (API key entry)
struct SetupView: View { @EnvironmentObject var store: AppStore @FocusState private var keyFieldFocused: Bool
var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Connect Buffit for Mac to your Buffer Account") .font(.headline) Text("Paste your API key generated by Buffer") .font(.caption) Text("Buffer -> Settings -> API -> Personal Keys") .font(.caption) SecureField("Buffer API key", text: $store.apiKey) .textFieldStyle(.roundedBorder) .focused($keyFieldFocused) .onSubmit { store.connect() }
if let error = store.loadError { Text(error) .font(.caption) .foregroundStyle(.red) } HStack(alignment: .firstTextBaseline) { Text("Your API key is stored on this device, no one can see it.") .font(.caption) .foregroundStyle(.secondary) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .layoutPriority(2) Spacer(minLength: 16) Button { store.connect() } label: { if store.isValidatingKey { ProgressView() .controlSize(.small) .frame(width: 55) } else { HStack(spacing: 6) { Text("Connect") .frame(width: 55) Image(systemName: "key.fill") } } } .layoutPriority(1) .keyboardShortcut(.defaultAction) .disabled(store.apiKey.trimmingCharacters(in: .whitespaces).isEmpty || store.isValidatingKey) } Divider()
Button("Quit Buffit for Mac") { NSApplication.shared.terminate(nil) } .buttonStyle(.plain) .foregroundStyle(.secondary) .font(.caption)
} .onAppear { keyFieldFocused = true } }}
// MARK: - Settings
struct SettingsView: View { @EnvironmentObject var store: AppStore @Binding var showingSettings: Bool
var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { Button { showingSettings = false } label: { Image(systemName: "chevron.left") } .buttonStyle(.plain)
Text("Settings") .font(.headline) Spacer() }
VStack(alignment: .leading, spacing: 6) { Text("Default for new posts") .font(.subheadline) .fontWeight(.medium)
Picker("", selection: $store.defaultMode) { ForEach(DefaultPostMode.allCases) { mode in Text(mode.label).tag(mode) } } .pickerStyle(.radioGroup) .labelsHidden()
Text("If a channel's queue is full (10 posts on the free plan), that post is saved as a draft automatically, even when the default above is set to queue.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) }
Divider() Link("Support and website", destination: URL(string: "https://danielmorrisey.com/buffit")!) Link("Privacy policy", destination: URL(string: "https://danielmorrisey.com/buffit/privacy")!) Divider() HStack(spacing: 6) { Image(systemName: "hand.raised.fill") Text("How your data is handled") } Text("Posts and your Buffer API key are sent directly to Buffer. If you attach an image, it is uploaded to Buffit's media service so Buffer can retrieve it when publishing. Your API key stays in your Mac's Keychain.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Divider() Button(action: { store.disconnect() }) { HStack(spacing: 6) { Image(systemName: "rectangle.portrait.and.arrow.right") Text("Sign Out") } } .buttonStyle(.plain) .foregroundStyle(.secondary) .font(.caption)
Button(action: { NSApplication.shared.terminate(nil) }) { HStack(spacing: 6) { Image(systemName: "power") Text("Quit Buffit") } } .buttonStyle(.plain) .foregroundStyle(.secondary) .font(.caption) } }}
// MARK: - Compose (write + queue)
struct ComposeView: View { @EnvironmentObject var store: AppStore @Binding var showingSettings: Bool @FocusState private var textFocused: Bool
var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Compose Post to add to Queue") .font(.headline) Spacer()
Menu { ForEach(store.channels) { channel in Button { store.toggleChannel(channel.id) } label: { HStack { Text(channel.name) if store.selectedChannelIds.contains(channel.id) { Image(systemName: "checkmark") } } } } Divider() Button("Select All") { store.selectAllChannels() } } label: { Image(systemName: "list.bullet") } .menuStyle(.borderlessButton) .fixedSize()
Button { showingSettings = true } label: { Image(systemName: "gearshape") } .buttonStyle(.plain)
Group { if store.isRefreshingChannels { ProgressView() .controlSize(.small) .frame(width: 24, height: 24) } else { Button { Task { await store.refreshChannels() } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) .disabled(store.isRefreshingChannels) } } .fixedSize() }
ZStack(alignment: .topLeading) { if store.postText.isEmpty { Text("Start typing here...") .foregroundStyle(.tertiary) .padding(.top, 8) .padding(.leading, 5) } TextEditor(text: $store.postText) .frame(height: 90) .font(.system(size: 13)) .scrollContentBackground(.hidden) .focused($textFocused) } .padding(4) .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .textBackgroundColor))) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25)))
if let image = store.attachedImage { HStack(spacing: 12) { Image(systemName: "photo.fill") .font(.title3) .foregroundStyle(.blue) VStack(alignment: .leading, spacing: 2) { Text("Image attached") .font(.caption) .fontWeight(.medium) Text("\(image.size) bytes") .font(.caption2) .foregroundStyle(.secondary) } Spacer() Button { store.removeAttachedImage() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) } .padding(8) .background(Color.blue.opacity(0.1)) .cornerRadius(6) } else { Button { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] panel.allowsMultipleSelection = false panel.canChooseFiles = true panel.canChooseDirectories = false
if panel.runModal() == .OK, let url = panel.url { store.attachImage(fileURL: url) } } label: { HStack(spacing: 6) { Image(systemName: "photo.badge.plus") Text("Upload Image") } .font(.caption) } .buttonStyle(.bordered) .frame(maxWidth: .infinity, alignment: .leading) .disabled(store.isUploadingImage) }
if let error = store.imageUploadError { Text(error) .font(.caption) .foregroundStyle(.red) }
ChannelChipRow()
statusView
HStack { Text("\(store.postText.count) characters") .font(.caption2) .foregroundStyle(.secondary) Spacer() Button { store.addToQueue() } label: { if store.isPosting { ProgressView().controlSize(.small) } else { Text(store.defaultMode == .queue ? "Add to Queue" : "Save as Draft") } } .keyboardShortcut(.defaultAction) .keyboardShortcut(.return, modifiers: [.command]) .disabled(!store.canPost) } } .onAppear { textFocused = true } }
@ViewBuilder var statusView: some View { switch store.status { case .idle, .posting: EmptyView() case .done(let queued, let drafted, let failed): Text(summary(queued: queued, drafted: drafted, failed: failed)) .font(.caption) .foregroundStyle(failed == 0 ? .green : .orange) case .error(let message): Text(message) .font(.caption) .foregroundStyle(.red) } }
private func summary(queued: Int, drafted: Int, failed: Int) -> String { var parts: [String] = [] if queued > 0 { parts.append("\(queued) queued") } if drafted > 0 { parts.append("\(drafted) drafted") } if failed > 0 { parts.append("\(failed) failed") } return parts.joined(separator: ", ") + "." }}
// MARK: - Channel selection chips
struct ChannelChipRow: View { @EnvironmentObject var store: AppStore
var body: some View { if store.isRefreshingChannels { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, 6) } else if store.channels.isEmpty { Text("No connected channels found on this Buffer account.") .font(.caption) .foregroundStyle(.secondary) } else { FlowLayout(spacing: 6) { ForEach(store.channels) { channel in let selected = store.selectedChannelIds.contains(channel.id) Button { store.toggleChannel(channel.id) } label: { Text(channel.name) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) .background( Capsule().fill(selected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12)) ) .overlay( Capsule().stroke(selected ? Color.accentColor : Color.clear, lineWidth: 1) ) .foregroundStyle(selected ? Color.accentColor : Color.secondary) } .buttonStyle(.plain) } } } }}
/// Simple wrapping layout for the channel chips.struct FlowLayout: Layout { var spacing: CGFloat = 6
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let width = proposal.width ?? 300 var x: CGFloat = 0 var y: CGFloat = 0 var rowHeight: CGFloat = 0
for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x + size.width > width, x > 0 { x = 0 y += rowHeight + spacing rowHeight = 0 } x += size.width + spacing rowHeight = max(rowHeight, size.height) } return CGSize(width: width, height: y + rowHeight) }
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var x: CGFloat = bounds.minX var y: CGFloat = bounds.minY var rowHeight: CGFloat = 0
for subview in subviews { let size = subview.sizeThatFits(.unspecified) if x + size.width > bounds.maxX, x > bounds.minX { x = bounds.minX y += rowHeight + spacing rowHeight = 0 } subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) x += size.width + spacing rowHeight = max(rowHeight, size.height) } }}