From 29054dc709c276971e5e69bc06dce067bcb12cba Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:00:42 -0800 Subject: [PATCH] Add sidebar help menu to footer (#958) * Add sidebar help menu * Fix help menu test wiring * Fix help menu accessibility * Use native popup for help menu * Use icon button for sidebar help * Add feedback composer and feedback API * Allow preview builds without feedback env * Tighten feedback upload limits * Adjust sidebar footer padding * Tighten sidebar footer spacing * Add link affordances to help menu * Polish sidebar feedback composer * Move feedback icon to trailing edge * Normalize help menu trailing icon sizes * Enlarge help menu trailing icons * Reduce help menu link icon size * Shrink help menu link arrow * Reduce help menu link arrow again * Fix feedback message editor focus * Add send feedback keyboard shortcut * Polish feedback launch and delivery --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 578 ++++++++ Sources/AppDelegate.swift | 22 +- Sources/ContentView.swift | 1195 ++++++++++++++++- Sources/KeyboardShortcutSettings.swift | 5 + Sources/TabManager.swift | 1 + Sources/cmuxApp.swift | 54 +- .../AppDelegateShortcutRoutingTests.swift | 31 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 29 + cmuxUITests/SidebarHelpMenuUITests.swift | 296 ++++ web/.env.example | 3 + web/app/api/feedback/route.ts | 340 +++++ web/app/env.ts | 18 + web/bun.lock | 24 + web/next.config.ts | 1 + web/package-lock.json | 192 ++- web/package.json | 6 +- 17 files changed, 2771 insertions(+), 28 deletions(-) create mode 100644 cmuxUITests/SidebarHelpMenuUITests.swift create mode 100644 web/.env.example create mode 100644 web/app/api/feedback/route.ts create mode 100644 web/app/env.ts diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9b977a9b..ece4ea09 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -73,6 +73,7 @@ A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; + B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; @@ -203,6 +204,7 @@ A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; + B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; @@ -433,6 +435,7 @@ B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, + B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, @@ -667,6 +670,7 @@ B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, + B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2139e387..d7943f25 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -567,6 +567,584 @@ } } }, + "debug.devBuildBanner.show": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Dev Build Banner" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開発ビルドバナーを表示" + } + } + } + }, + "debug.devBuildBanner.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "THIS IS A DEV BUILD" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これは開発ビルドです" + } + } + } + }, + "sidebar.help.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ヘルプ" + } + } + } + }, + "sidebar.help.changelog": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Changelog" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新履歴" + } + } + } + }, + "sidebar.help.githubIssues": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GitHub Issues" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GitHub Issues" + } + } + } + }, + "sidebar.help.sendFeedback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Feedback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信" + } + } + } + }, + "sidebar.help.feedback.attachImages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Images" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像を添付" + } + } + } + }, + "sidebar.help.feedback.attachImages.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "添付" + } + } + } + }, + "sidebar.help.feedback.attachImages.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach Images" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像を添付" + } + } + } + }, + "sidebar.help.feedback.attachmentsHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to 10 images. Large images will be optimized before sending." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像は最大10枚まで添付できます。大きな画像は送信前に最適化されます。" + } + } + } + }, + "sidebar.help.feedback.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "sidebar.help.feedback.connectionError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't send feedback. Check your connection and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信できませんでした。接続を確認して、もう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "sidebar.help.feedback.email": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Email" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メールアドレス" + } + } + } + }, + "sidebar.help.feedback.emailPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "you@example.com" + } + } + } + }, + "sidebar.help.feedback.emptyMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a message before sending." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信する前にメッセージを入力してください。" + } + } + } + }, + "sidebar.help.feedback.endpointError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feedback is unavailable right now. Email founders@manaflow.com instead." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在フィードバックを送信できません。代わりに founders@manaflow.com までメールしてください。" + } + } + } + }, + "sidebar.help.feedback.genericError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't send feedback. Please try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信できませんでした。もう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.imageTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Each image must be 4 MB or smaller." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各画像は 4 MB 以下にしてください。" + } + } + } + }, + "sidebar.help.feedback.invalidEmail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid email address." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効なメールアドレスを入力してください。" + } + } + } + }, + "sidebar.help.feedback.invalidImageSelection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One of the selected files could not be attached." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したファイルのうち1つを添付できませんでした。" + } + } + } + }, + "sidebar.help.feedback.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージ" + } + } + } + }, + "sidebar.help.feedback.messagePlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Share feedback, feature requests, or issues." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバック、機能要望、不具合をお知らせください。" + } + } + } + }, + "sidebar.help.feedback.messageTooLong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your message is too long." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージが長すぎます。" + } + } + } + }, + "sidebar.help.feedback.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A human will read this! You can also reach us at founders@manaflow.com." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "人間がこれを読みます。founders@manaflow.com 宛てに直接ご連絡いただくこともできます。" + } + } + } + }, + "sidebar.help.feedback.rateLimited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many feedback attempts. Please try again later." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックの送信回数が多すぎます。しばらくしてからもう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.removeAttachment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + } + } + }, + "sidebar.help.feedback.send": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信" + } + } + } + }, + "sidebar.help.feedback.successBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A human will read this! You can also reach us at founders@manaflow.com." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "人間がこれを読みます。founders@manaflow.com 宛てに直接ご連絡いただくこともできます。" + } + } + } + }, + "sidebar.help.feedback.successTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thanks for the feedback." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックありがとうございます。" + } + } + } + }, + "sidebar.help.feedback.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Feedback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィードバックを送信" + } + } + } + }, + "sidebar.help.feedback.tooManyImages": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can attach up to 10 images." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像は最大10枚まで添付できます。" + } + } + } + }, + "sidebar.help.feedback.totalImagesTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "These images are too large to send together. Remove a few and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これらの画像はまとめて送信するには大きすぎます。いくつか削除してもう一度お試しください。" + } + } + } + }, + "sidebar.help.feedback.validationError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your message and attachments, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージと添付ファイルを確認して、もう一度お試しください。" + } + } + } + }, "about.github": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b015a5ea..400b5d14 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4858,8 +4858,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @MainActor static func presentPreferencesWindow( - showFallbackSettingsWindow: @MainActor () -> Void = { - SettingsWindowController.shared.show() + navigationTarget: SettingsNavigationTarget? = nil, + showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in + SettingsWindowController.shared.show(navigationTarget: target) }, activateApplication: @MainActor () -> Void = { NSApp.activate(ignoringOtherApps: true) @@ -4868,7 +4869,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG dlog("settings.open.present path=customWindowDirect") #endif - showFallbackSettingsWindow() + showFallbackSettingsWindow(navigationTarget) activateApplication() #if DEBUG dlog("settings.open.present activate=1") @@ -4876,11 +4877,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @MainActor - func openPreferencesWindow(debugSource: String) { + func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { #if DEBUG dlog("settings.open.request source=\(debugSource)") #endif - Self.presentPreferencesWindow() + Self.presentPreferencesWindow(navigationTarget: navigationTarget) } @objc func openPreferencesWindow() { @@ -6610,6 +6611,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) { + guard let targetContext = preferredMainWindowContextForShortcuts(event: event), + let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { + return false + } + setActiveMainWindow(targetWindow) + bringToFront(targetWindow) + NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) + return true + } + // Check Jump to Unread shortcut if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) { #if DEBUG diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a289f0eb..418e023a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import ImageIO import SwiftUI import ObjectiveC import UniformTypeIdentifiers @@ -1262,6 +1263,7 @@ struct ContentView: View { @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] + @State private var isFeedbackComposerPresented = false @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) @@ -1780,6 +1782,7 @@ struct ContentView: View { private var sidebarView: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, + onSendFeedback: presentFeedbackComposer, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex @@ -2376,6 +2379,17 @@ struct ContentView: View { _ = handleCommandPaletteRenameDeleteBackward(modifiers: []) }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .feedbackComposerRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + presentFeedbackComposer() + }) + view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in MainActor.assumeIsolated { let overlayController = commandPaletteWindowOverlayController(for: window) @@ -2443,6 +2457,9 @@ struct ContentView: View { }) view = AnyView(view.ignoresSafeArea()) + view = AnyView(view.sheet(isPresented: $isFeedbackComposerPresented) { + SidebarFeedbackComposerSheet() + }) view = AnyView(view.onDisappear { removeSidebarResizerPointerMonitor() @@ -4559,6 +4576,12 @@ struct ContentView: View { beginRenameWorkspaceFlow() } + private func presentFeedbackComposer() { + DispatchQueue.main.async { + isFeedbackComposerPresented = true + } + } + static func shouldHandleCommandPaletteRequest( observedWindow: NSWindow?, requestedWindow: NSWindow?, @@ -5628,6 +5651,7 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @@ -5701,15 +5725,8 @@ struct VerticalTabsSidebar: View { .background(Color.clear) .modifier(ClearScrollBackground()) } -#if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel) + SidebarFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) .frame(maxWidth: .infinity, alignment: .leading) -#else - UpdatePill(model: updateViewModel) - .padding(.horizontal, 10) - .padding(.bottom, 10) - .frame(maxWidth: .infinity, alignment: .leading) -#endif } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() @@ -5857,6 +5874,343 @@ enum ShortcutHintDebugSettings { } } +enum DevBuildBannerDebugSettings { + static let sidebarBannerVisibleKey = "showSidebarDevBuildBanner" + static let defaultShowSidebarBanner = true + + static func showSidebarBanner(defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: sidebarBannerVisibleKey) != nil else { + return defaultShowSidebarBanner + } + return defaults.bool(forKey: sidebarBannerVisibleKey) + } +} + +private enum FeedbackComposerSettings { + static let storedEmailKey = "sidebarHelpFeedbackEmail" + static let endpointEnvironmentKey = "CMUX_FEEDBACK_API_URL" + static let defaultEndpoint = "https://cmux.dev/api/feedback" + static let foundersEmail = "founders@manaflow.com" + static let maxMessageLength = 4_000 + static let maxAttachmentCount = 10 + // Keep the multipart body below Vercel's 4.5 MB request limit. + static let maxTotalAttachmentBytes = 4 * 1_024 * 1_024 + static let targetTotalAttachmentUploadBytes = 3_500_000 + + static func endpointURL() -> URL? { + let env = ProcessInfo.processInfo.environment + if let override = env[endpointEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return URL(string: override) + } + return URL(string: defaultEndpoint) + } +} + +private struct FeedbackComposerAttachment: Identifiable { + let id = UUID() + let url: URL + let fileName: String + let fileSize: Int64 + let mimeType: String + + var standardizedPath: String { + url.standardizedFileURL.path + } + + var displaySize: String { + ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) + } + + init(url: URL) throws { + let resourceValues = try url.resourceValues(forKeys: [ + .contentTypeKey, + .fileSizeKey, + .isRegularFileKey, + .nameKey, + ]) + guard resourceValues.isRegularFile != false else { + throw CocoaError(.fileReadUnknown) + } + + self.url = url + self.fileName = resourceValues.name ?? url.lastPathComponent + self.fileSize = Int64(resourceValues.fileSize ?? 0) + self.mimeType = resourceValues.contentType?.preferredMIMEType ?? "application/octet-stream" + } +} + +private struct PreparedFeedbackComposerAttachment { + let fileName: String + let mimeType: String + let data: Data +} + +private struct FeedbackComposerAppMetadata { + let appVersion: String + let appBuild: String + let appCommit: String + let bundleIdentifier: String + let osVersion: String + let localeIdentifier: String + + static var current: FeedbackComposerAppMetadata { + let infoDictionary = Bundle.main.infoDictionary ?? [:] + let env = ProcessInfo.processInfo.environment + let commit = (infoDictionary["CMUXCommit"] as? String).flatMap { value in + value.isEmpty ? nil : value + } ?? env["CMUX_COMMIT"] + + return FeedbackComposerAppMetadata( + appVersion: infoDictionary["CFBundleShortVersionString"] as? String ?? "", + appBuild: infoDictionary["CFBundleVersion"] as? String ?? "", + appCommit: commit ?? "", + bundleIdentifier: Bundle.main.bundleIdentifier ?? "", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier + ) + } +} + +private enum FeedbackComposerSubmissionError: Error { + case invalidEndpoint + case invalidResponse + case rejected(statusCode: Int) + case attachmentReadFailed + case attachmentPreparationFailed + case transport(URLError) +} + +private enum FeedbackComposerClient { + private static let passthroughAttachmentMIMETypes: Set = [ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", + ] + private static let optimizedAttachmentDimensions: [Int] = [2800, 2400, 2000, 1600, 1280, 1024, 768, 640, 512] + private static let optimizedAttachmentQualities: [CGFloat] = [0.82, 0.72, 0.62, 0.52, 0.42, 0.32] + private static let optimizedAttachmentMIMEType = "image/jpeg" + + static func submit( + email: String, + message: String, + attachments: [FeedbackComposerAttachment] + ) async throws { + guard let endpointURL = FeedbackComposerSettings.endpointURL() else { + throw FeedbackComposerSubmissionError.invalidEndpoint + } + + let metadata = FeedbackComposerAppMetadata.current + let boundary = "Boundary-\(UUID().uuidString)" + let preparedAttachments = try prepareAttachmentsForUpload(attachments) + + var request = URLRequest(url: endpointURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + var body = Data() + appendField("email", value: email, to: &body, boundary: boundary) + appendField("message", value: message, to: &body, boundary: boundary) + appendField("appVersion", value: metadata.appVersion, to: &body, boundary: boundary) + appendField("appBuild", value: metadata.appBuild, to: &body, boundary: boundary) + appendField("appCommit", value: metadata.appCommit, to: &body, boundary: boundary) + appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary) + appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary) + appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary) + + for attachment in preparedAttachments { + appendFile( + named: "attachments", + attachment: attachment, + to: &body, + boundary: boundary + ) + } + + body.append(Data("--\(boundary)--\r\n".utf8)) + request.httpBody = body + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch let error as URLError { + throw FeedbackComposerSubmissionError.transport(error) + } catch { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw FeedbackComposerSubmissionError.invalidResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + if let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorMessage = payload["error"] as? String, + errorMessage.isEmpty == false { + NSLog("feedback.submit.rejected status=%@ error=%@", String(httpResponse.statusCode), errorMessage) + } + throw FeedbackComposerSubmissionError.rejected(statusCode: httpResponse.statusCode) + } + } + + private static func appendField( + _ name: String, + value: String, + to body: inout Data, + boundary: String + ) { + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) + body.append(Data(value.utf8)) + body.append(Data("\r\n".utf8)) + } + + private static func prepareAttachmentsForUpload( + _ attachments: [FeedbackComposerAttachment] + ) throws -> [PreparedFeedbackComposerAttachment] { + guard attachments.isEmpty == false else { return [] } + + struct IndexedAttachment { + let index: Int + let attachment: FeedbackComposerAttachment + } + + let sortedAttachments = attachments.enumerated() + .map { IndexedAttachment(index: $0.offset, attachment: $0.element) } + .sorted { lhs, rhs in + lhs.attachment.fileSize > rhs.attachment.fileSize + } + + var preparedByIndex: [Int: PreparedFeedbackComposerAttachment] = [:] + var remainingBudget = FeedbackComposerSettings.targetTotalAttachmentUploadBytes + var remainingCount = sortedAttachments.count + + for item in sortedAttachments { + let perAttachmentBudget = max(1, remainingBudget / max(remainingCount, 1)) + let preparedAttachment = try prepareAttachmentForUpload( + item.attachment, + maximumByteCount: perAttachmentBudget + ) + preparedByIndex[item.index] = preparedAttachment + remainingBudget -= preparedAttachment.data.count + remainingCount -= 1 + } + + let preparedAttachments = attachments.indices.compactMap { preparedByIndex[$0] } + let totalBytes = preparedAttachments.reduce(0) { $0 + $1.data.count } + guard totalBytes <= FeedbackComposerSettings.targetTotalAttachmentUploadBytes else { + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + return preparedAttachments + } + + private static func prepareAttachmentForUpload( + _ attachment: FeedbackComposerAttachment, + maximumByteCount: Int + ) throws -> PreparedFeedbackComposerAttachment { + if attachment.fileSize > 0, + attachment.fileSize <= Int64(maximumByteCount), + passthroughAttachmentMIMETypes.contains(attachment.mimeType), + let fileData = try? Data(contentsOf: attachment.url, options: .mappedIfSafe) { + return PreparedFeedbackComposerAttachment( + fileName: attachment.fileName, + mimeType: attachment.mimeType, + data: fileData + ) + } + + guard let imageSource = CGImageSourceCreateWithURL(attachment.url as CFURL, nil) else { + throw FeedbackComposerSubmissionError.attachmentReadFailed + } + + for maxPixelDimension in optimizedAttachmentDimensions { + guard let cgImage = downsampledImage( + from: imageSource, + maxPixelDimension: maxPixelDimension + ) else { continue } + + for compressionQuality in optimizedAttachmentQualities { + guard let jpegData = jpegData( + from: cgImage, + compressionQuality: compressionQuality + ) else { continue } + guard jpegData.count <= maximumByteCount else { continue } + + return PreparedFeedbackComposerAttachment( + fileName: optimizedFileName(for: attachment), + mimeType: optimizedAttachmentMIMEType, + data: jpegData + ) + } + } + + throw FeedbackComposerSubmissionError.attachmentPreparationFailed + } + + private static func downsampledImage( + from imageSource: CGImageSource, + maxPixelDimension: Int + ) -> CGImage? { + CGImageSourceCreateThumbnailAtIndex( + imageSource, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceThumbnailMaxPixelSize: maxPixelDimension, + ] as CFDictionary + ) + } + + private static func jpegData( + from image: CGImage, + compressionQuality: CGFloat + ) -> Data? { + let bitmap = NSBitmapImageRep(cgImage: image) + return bitmap.representation( + using: .jpeg, + properties: [ + .compressionFactor: compressionQuality, + ] + ) + } + + private static func optimizedFileName( + for attachment: FeedbackComposerAttachment + ) -> String { + let baseName = (attachment.fileName as NSString).deletingPathExtension + return "\(baseName.isEmpty ? "feedback-image" : baseName).jpg" + } + + private static func appendFile( + named fieldName: String, + attachment: PreparedFeedbackComposerAttachment, + to body: inout Data, + boundary: String + ) { + let sanitizedFileName = attachment.fileName.replacingOccurrences(of: "\"", with: "") + + body.append(Data("--\(boundary)\r\n".utf8)) + body.append( + Data( + "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(sanitizedFileName)\"\r\n".utf8 + ) + ) + body.append(Data("Content-Type: \(attachment.mimeType)\r\n\r\n".utf8)) + body.append(attachment.data) + body.append(Data("\r\n".utf8)) + } +} + enum SidebarDragLifecycleNotification { static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") @@ -6231,19 +6585,832 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } +private struct SidebarFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { +#if DEBUG + SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) +#else + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) +#endif + } +} + +private struct SidebarFooterButtons: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { + HStack(spacing: 4) { + SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + UpdatePill(model: updateViewModel) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct FeedbackComposerMessageEditor: NSViewRepresentable { + @Binding var text: String + let placeholder: String + let accessibilityLabel: String + let accessibilityIdentifier: String + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> FeedbackComposerMessageEditorView { + let view = FeedbackComposerMessageEditorView() + view.placeholder = placeholder + view.textView.string = text + view.textView.delegate = context.coordinator + view.textView.setAccessibilityLabel(accessibilityLabel) + view.textView.setAccessibilityIdentifier(accessibilityIdentifier) + view.setAccessibilityIdentifier(accessibilityIdentifier) + return view + } + + func updateNSView(_ nsView: FeedbackComposerMessageEditorView, context: Context) { + if nsView.textView.string != text { + nsView.textView.string = text + } + nsView.placeholder = placeholder + nsView.textView.setAccessibilityLabel(accessibilityLabel) + nsView.textView.setAccessibilityIdentifier(accessibilityIdentifier) + nsView.setAccessibilityIdentifier(accessibilityIdentifier) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: FeedbackComposerMessageEditor + + init(parent: FeedbackComposerMessageEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + } + } +} + +private final class FeedbackComposerPassthroughLabel: NSTextField { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +private final class FeedbackComposerMessageScrollView: NSScrollView { + weak var focusTextView: NSTextView? + + override func mouseDown(with event: NSEvent) { + if let focusTextView { + _ = window?.makeFirstResponder(focusTextView) + } + super.mouseDown(with: event) + } +} + +private final class FeedbackComposerMessageEditorView: NSView { + private static let textInset = NSSize(width: 10, height: 10) + + let scrollView = FeedbackComposerMessageScrollView() + let textView = NSTextView() + private let placeholderField = FeedbackComposerPassthroughLabel(labelWithString: "") + + var placeholder: String = "" { + didSet { + placeholderField.stringValue = placeholder + updatePlaceholderVisibility() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + wantsLayer = true + layer?.cornerRadius = 8 + layer?.borderWidth = 1 + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.automaticallyAdjustsContentInsets = false + scrollView.hasVerticalScroller = true + scrollView.focusTextView = textView + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = true + textView.isSelectable = true + textView.isRichText = false + textView.importsGraphics = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.font = .systemFont(ofSize: 12) + textView.textColor = .labelColor + textView.insertionPointColor = .labelColor + textView.textContainerInset = Self.textInset + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + + scrollView.documentView = textView + addSubview(scrollView) + + placeholderField.translatesAutoresizingMaskIntoConstraints = false + placeholderField.font = .systemFont(ofSize: 12) + placeholderField.textColor = .secondaryLabelColor + placeholderField.lineBreakMode = .byWordWrapping + placeholderField.maximumNumberOfLines = 0 + scrollView.contentView.addSubview(placeholderField) + + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange(_:)), + name: NSText.didChangeNotification, + object: textView + ) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + + placeholderField.topAnchor.constraint( + equalTo: scrollView.contentView.topAnchor, + constant: Self.textInset.height + ), + placeholderField.leadingAnchor.constraint( + equalTo: scrollView.contentView.leadingAnchor, + constant: Self.textInset.width + ), + placeholderField.trailingAnchor.constraint( + lessThanOrEqualTo: scrollView.contentView.trailingAnchor, + constant: -Self.textInset.width + ), + ]) + + updatePlaceholderVisibility() + } + + override func layout() { + super.layout() + syncTextViewFrameToContentSize() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc + private func textDidChange(_ notification: Notification) { + updatePlaceholderVisibility() + } + + private func updatePlaceholderVisibility() { + placeholderField.isHidden = textView.string.isEmpty == false + } + + private func syncTextViewFrameToContentSize() { + let contentSize = scrollView.contentSize + guard contentSize.width > 0, contentSize.height > 0 else { return } + + textView.minSize = NSSize(width: 0, height: contentSize.height) + textView.textContainer?.containerSize = NSSize( + width: contentSize.width, + height: CGFloat.greatestFiniteMagnitude + ) + + let targetSize = NSSize( + width: contentSize.width, + height: max(textView.frame.height, contentSize.height) + ) + if textView.frame.size != targetSize { + textView.frame = NSRect(origin: .zero, size: targetSize) + } + } +} + +private enum SidebarHelpMenuAction { + case keyboardShortcuts + case docs + case changelog + case github + case githubIssues + case checkForUpdates + case sendFeedback +} + +private struct SidebarFeedbackComposerSheet: View { + @AppStorage(FeedbackComposerSettings.storedEmailKey) private var email = "" + @Environment(\.dismiss) private var dismiss + + @State private var message = "" + @State private var attachments: [FeedbackComposerAttachment] = [] + @State private var isSubmitting = false + @State private var submissionErrorMessage: String? + @State private var didSend = false + + private var trimmedMessage: String { + message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmit: Bool { + isValidEmail(email) && + !trimmedMessage.isEmpty && + message.count <= FeedbackComposerSettings.maxMessageLength && + !isSubmitting && + !didSend + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: "sidebar.help.feedback.title", defaultValue: "Send Feedback")) + .font(.title3.weight(.semibold)) + + if didSend { + successView + } else { + formView + } + } + .padding(20) + .frame(width: 520) + .accessibilityIdentifier("SidebarFeedbackDialog") + } + + private var successView: some View { + VStack(alignment: .leading, spacing: 12) { + Text(String(localized: "sidebar.help.feedback.successTitle", defaultValue: "Thanks for the feedback.")) + .font(.headline) + Text( + String( + localized: "sidebar.help.feedback.successBody", + defaultValue: "A human will read this! You can also reach us at founders@manaflow.com." + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.done", defaultValue: "Done")) { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + } + + private var formView: some View { + VStack(alignment: .leading, spacing: 14) { + Text( + String( + localized: "sidebar.help.feedback.note", + defaultValue: "A human will read this! You can also reach us at founders@manaflow.com." + ) + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) + .font(.system(size: 12, weight: .medium)) + TextField( + String(localized: "sidebar.help.feedback.emailPlaceholder", defaultValue: "you@example.com"), + text: $email + ) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "sidebar.help.feedback.email", defaultValue: "Your Email")) + .accessibilityIdentifier("SidebarFeedbackEmailField") + } + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(String(localized: "sidebar.help.feedback.message", defaultValue: "Message")) + .font(.system(size: 12, weight: .medium)) + Spacer(minLength: 0) + Text("\(message.count)/\(FeedbackComposerSettings.maxMessageLength)") + .font(.system(size: 11)) + .foregroundStyle( + message.count > FeedbackComposerSettings.maxMessageLength + ? Color.red + : Color.secondary + ) + } + + FeedbackComposerMessageEditor( + text: $message, + placeholder: String( + localized: "sidebar.help.feedback.messagePlaceholder", + defaultValue: "Share feedback, feature requests, or issues." + ), + accessibilityLabel: String(localized: "sidebar.help.feedback.message", defaultValue: "Message"), + accessibilityIdentifier: "SidebarFeedbackMessageEditor" + ) + .frame(minHeight: 180) + } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button { + chooseAttachments() + } label: { + Label( + String(localized: "sidebar.help.feedback.attachImages", defaultValue: "Attach Images"), + systemImage: "paperclip" + ) + } + .accessibilityIdentifier("SidebarFeedbackAttachButton") + + Text( + String( + localized: "sidebar.help.feedback.attachmentsHint", + defaultValue: "Up to 10 images. Large images will be optimized before sending." + ) + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + if attachments.isEmpty == false { + VStack(alignment: .leading, spacing: 6) { + ForEach(attachments) { attachment in + HStack(spacing: 8) { + Image(systemName: "photo") + .foregroundStyle(.secondary) + Text(attachment.fileName) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(attachment.displaySize) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Button( + String(localized: "sidebar.help.feedback.removeAttachment", defaultValue: "Remove") + ) { + removeAttachment(attachment) + } + .buttonStyle(.link) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } + } + + if let submissionErrorMessage, submissionErrorMessage.isEmpty == false { + Text(submissionErrorMessage) + .font(.system(size: 12)) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button(String(localized: "sidebar.help.feedback.cancel", defaultValue: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button { + Task { await submitFeedback() } + } label: { + if isSubmitting { + ProgressView() + .controlSize(.small) + } else { + Text(String(localized: "sidebar.help.feedback.send", defaultValue: "Send")) + } + } + .keyboardShortcut(.defaultAction) + .disabled(!canSubmit) + .accessibilityIdentifier("SidebarFeedbackSendButton") + } + } + } + + private func chooseAttachments() { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.allowedContentTypes = [.image] + panel.title = String( + localized: "sidebar.help.feedback.attachImages.title", + defaultValue: "Attach Images" + ) + panel.prompt = String( + localized: "sidebar.help.feedback.attachImages.prompt", + defaultValue: "Attach" + ) + + guard panel.runModal() == .OK else { return } + + var updatedAttachments = attachments + var knownPaths = Set(updatedAttachments.map(\.standardizedPath)) + var firstIssue: String? + + for url in panel.urls { + let normalizedPath = url.standardizedFileURL.path + if knownPaths.contains(normalizedPath) { + continue + } + if updatedAttachments.count >= FeedbackComposerSettings.maxAttachmentCount { + firstIssue = String( + localized: "sidebar.help.feedback.tooManyImages", + defaultValue: "You can attach up to 10 images." + ) + break + } + + guard let attachment = try? FeedbackComposerAttachment(url: url) else { + firstIssue = String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached." + ) + continue + } + updatedAttachments.append(attachment) + knownPaths.insert(normalizedPath) + } + + attachments = updatedAttachments + submissionErrorMessage = firstIssue + } + + private func removeAttachment(_ attachment: FeedbackComposerAttachment) { + attachments.removeAll { $0.id == attachment.id } + submissionErrorMessage = nil + } + + private func submitFeedback() async { + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMessage = trimmedMessage + + guard isValidEmail(trimmedEmail) else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.invalidEmail", + defaultValue: "Enter a valid email address." + ) + return + } + + guard normalizedMessage.isEmpty == false else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.emptyMessage", + defaultValue: "Enter a message before sending." + ) + return + } + + guard message.count <= FeedbackComposerSettings.maxMessageLength else { + submissionErrorMessage = String( + localized: "sidebar.help.feedback.messageTooLong", + defaultValue: "Your message is too long." + ) + return + } + + await MainActor.run { + email = trimmedEmail + submissionErrorMessage = nil + isSubmitting = true + } + + do { + try await FeedbackComposerClient.submit( + email: trimmedEmail, + message: normalizedMessage, + attachments: attachments + ) + await MainActor.run { + isSubmitting = false + didSend = true + attachments = [] + } + } catch { + await MainActor.run { + isSubmitting = false + submissionErrorMessage = userFacingErrorMessage(for: error) + } + } + } + + private func isValidEmail(_ rawValue: String) -> Bool { + let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { return false } + let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"# + return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email) + } + + private func userFacingErrorMessage(for error: Error) -> String { + guard let submissionError = error as? FeedbackComposerSubmissionError else { + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + } + + switch submissionError { + case .invalidEndpoint: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." + ) + case .invalidResponse: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + case .attachmentReadFailed: + return String( + localized: "sidebar.help.feedback.invalidImageSelection", + defaultValue: "One of the selected files could not be attached." + ) + case .attachmentPreparationFailed: + return String( + localized: "sidebar.help.feedback.totalImagesTooLarge", + defaultValue: "These images are too large to send together. Remove a few and try again." + ) + case .transport(let transportError): + if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost { + return String( + localized: "sidebar.help.feedback.connectionError", + defaultValue: "Couldn't send feedback. Check your connection and try again." + ) + } + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + case .rejected(let statusCode): + switch statusCode { + case 400, 413, 415: + return String( + localized: "sidebar.help.feedback.validationError", + defaultValue: "Check your message and attachments, then try again." + ) + case 429: + return String( + localized: "sidebar.help.feedback.rateLimited", + defaultValue: "Too many feedback attempts. Please try again later." + ) + case 503: + return String( + localized: "sidebar.help.feedback.endpointError", + defaultValue: "Feedback is unavailable right now. Email founders@manaflow.com instead." + ) + default: + return String( + localized: "sidebar.help.feedback.genericError", + defaultValue: "Couldn't send feedback. Please try again." + ) + } + } + } +} + +private struct SidebarHelpMenuButton: View { + private let docsURL = URL(string: "https://cmux.dev/docs") + private let changelogURL = URL(string: "https://cmux.dev/docs/changelog") + private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux") + private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues") + private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") + private let buttonSize: CGFloat = 22 + private let iconSize: CGFloat = 11 + + let onSendFeedback: () -> Void + + @State private var isPopoverPresented = false + + var body: some View { + Button { + isPopoverPresented.toggle() + } label: { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + helpPopover + } + .accessibilityElement(children: .ignore) + .help(helpTitle) + .accessibilityLabel(helpTitle) + .accessibilityIdentifier("SidebarHelpMenuButton") + } + + private var helpPopover: some View { + VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), + action: .sendFeedback, + accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", + isExternalLink: false, + trailingSystemImage: "bubble.left.and.text.bubble.right" + ) + helpOptionButton( + title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), + action: .keyboardShortcuts, + accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", + isExternalLink: false + ) + if docsURL != nil { + helpOptionButton( + title: String(localized: "about.docs", defaultValue: "Docs"), + action: .docs, + accessibilityIdentifier: "SidebarHelpMenuOptionDocs", + isExternalLink: true + ) + } + if changelogURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), + action: .changelog, + accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", + isExternalLink: true + ) + } + if githubURL != nil { + helpOptionButton( + title: String(localized: "about.github", defaultValue: "GitHub"), + action: .github, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", + isExternalLink: true + ) + } + if githubIssuesURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), + action: .githubIssues, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", + isExternalLink: true + ) + } + helpOptionButton( + title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), + action: .checkForUpdates, + accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", + isExternalLink: false + ) + } + .padding(8) + .frame(minWidth: 200) + } + + private func helpOptionButton( + title: String, + action: SidebarHelpMenuAction, + accessibilityIdentifier: String, + isExternalLink: Bool, + trailingSystemImage: String? = nil + ) -> some View { + Button { + isPopoverPresented = false + perform(action) + } label: { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 12)) + Spacer(minLength: 0) + if let trailingSystemImage { + helpOptionTrailingIcon(systemName: trailingSystemImage) + } + if isExternalLink { + helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { + Image(systemName: systemName) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func perform(_ action: SidebarHelpMenuAction) { + switch action { + case .keyboardShortcuts: + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } + } + case .docs: + guard let docsURL else { return } + NSWorkspace.shared.open(docsURL) + case .changelog: + guard let changelogURL else { return } + NSWorkspace.shared.open(changelogURL) + case .github: + guard let githubURL else { return } + NSWorkspace.shared.open(githubURL) + case .githubIssues: + guard let githubIssuesURL else { return } + NSWorkspace.shared.open(githubIssuesURL) + case .checkForUpdates: + Task { @MainActor in + AppDelegate.shared?.checkForUpdates(nil) + } + case .sendFeedback: + isPopoverPresented = false + onSendFeedback() + } + } +} + +private struct SidebarFooterIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + SidebarFooterIconButtonStyleBody(configuration: configuration) + } +} + +private struct SidebarFooterIconButtonStyleBody: View { + let configuration: SidebarFooterIconButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + #if DEBUG private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner var body: some View { VStack(alignment: .leading, spacing: 6) { - UpdatePill(model: updateViewModel) - Text("THIS IS A DEV BUILD") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.red) + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + if showSidebarDevBuildBanner { + Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + } } - .padding(.horizontal, 10) - .padding(.bottom, 10) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) } } #endif diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index dae65482..f06c255b 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -10,6 +10,7 @@ enum KeyboardShortcutSettings { case newWindow case closeWindow case openFolder + case sendFeedback case showNotifications case jumpToUnread case triggerFlash @@ -50,6 +51,7 @@ enum KeyboardShortcutSettings { case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window") case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window") case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder") + case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback") case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications") case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread") case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel") @@ -84,6 +86,7 @@ enum KeyboardShortcutSettings { case .newWindow: return "shortcut.newWindow" case .closeWindow: return "shortcut.closeWindow" case .openFolder: return "shortcut.openFolder" + case .sendFeedback: return "shortcut.sendFeedback" case .showNotifications: return "shortcut.showNotifications" case .jumpToUnread: return "shortcut.jumpToUnread" case .triggerFlash: return "shortcut.triggerFlash" @@ -123,6 +126,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true) case .openFolder: return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false) + case .sendFeedback: + return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false) case .showNotifications: return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) case .jumpToUnread: diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 528669a6..27a23f3f 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3676,6 +3676,7 @@ extension Notification.Name { static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested") static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested") + static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested") static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 00cd360b..40645ea6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -14,6 +14,8 @@ struct cmuxApp: App { @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data() @@ -360,6 +362,10 @@ struct cmuxApp: App { } Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints) + Toggle( + String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"), + isOn: $showSidebarDevBuildBanner + ) Divider() @@ -1321,6 +1327,7 @@ private enum DebugWindowConfigSnapshot { sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout)) sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue)) + sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner)) shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) @@ -1775,7 +1782,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { fatalError("init(coder:) has not been implemented") } - func show() { + func show(navigationTarget: SettingsNavigationTarget? = nil) { guard let window else { return } #if DEBUG dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") @@ -1785,12 +1792,39 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { window.center() } window.makeKeyAndOrderFront(nil) + if let navigationTarget { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + SettingsNavigationRequest.post(navigationTarget) + } + } #if DEBUG dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") #endif } } +enum SettingsNavigationTarget: String { + case keyboardShortcuts +} + +enum SettingsNavigationRequest { + static let notificationName = Notification.Name("cmux.settings.navigate") + private static let targetKey = "target" + + static func post(_ target: SettingsNavigationTarget) { + NotificationCenter.default.post( + name: notificationName, + object: nil, + userInfo: [targetKey: target.rawValue] + ) + } + + static func target(from notification: Notification) -> SettingsNavigationTarget? { + guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil } + return SettingsNavigationTarget(rawValue: rawValue) + } +} + private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { static let shared = SidebarDebugWindowController() @@ -1931,6 +1965,8 @@ private struct SidebarDebugView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @@ -2154,6 +2190,7 @@ private struct SidebarDebugView: View { sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout) sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle) + sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner) shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) @@ -3168,7 +3205,8 @@ struct SettingsView: View { } var body: some View { - ZStack(alignment: .top) { + ScrollViewReader { proxy in + ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) @@ -3864,6 +3902,8 @@ struct SettingsView: View { } SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) + .id(SettingsNavigationTarget.keyboardShortcuts) + .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { SettingsCardRow( String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), @@ -3894,6 +3934,7 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) .padding(.leading, 2) + .accessibilityIdentifier("ShortcutRecordingHint") SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset")) SettingsCard { @@ -4013,6 +4054,14 @@ struct SettingsView: View { .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in reloadWorkspaceTabColorSettings() } + .onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in + guard let target = SettingsNavigationRequest.target(from: notification) else { return } + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(target, anchor: .top) + } + } + } .confirmationDialog( String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"), isPresented: $showClearBrowserHistoryConfirmation, @@ -4061,6 +4110,7 @@ struct SettingsView: View { } message: { Text(notificationCustomSoundErrorAlertMessage) } + } } private func relaunchApp() { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 22b09b39..63ff111f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -2071,9 +2071,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() { var showFallbackSettingsWindowCallCount = 0 var activateApplicationCallCount = 0 + var receivedNavigationTargets: [SettingsNavigationTarget?] = [] AppDelegate.presentPreferencesWindow( - showFallbackSettingsWindow: { + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) showFallbackSettingsWindowCallCount += 1 }, activateApplication: { @@ -2083,14 +2085,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(showFallbackSettingsWindowCallCount, 1) XCTAssertEqual(activateApplicationCallCount, 1) + XCTAssertEqual(receivedNavigationTargets, [nil]) } func testPresentPreferencesWindowSupportsRepeatedCalls() { var showFallbackSettingsWindowCallCount = 0 var activateApplicationCallCount = 0 + var receivedNavigationTargets: [SettingsNavigationTarget?] = [] AppDelegate.presentPreferencesWindow( - showFallbackSettingsWindow: { + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) showFallbackSettingsWindowCallCount += 1 }, activateApplication: { @@ -2099,7 +2104,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) AppDelegate.presentPreferencesWindow( - showFallbackSettingsWindow: { + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTargets.append(navigationTarget) showFallbackSettingsWindowCallCount += 1 }, activateApplication: { @@ -2109,6 +2115,25 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(showFallbackSettingsWindowCallCount, 2) XCTAssertEqual(activateApplicationCallCount, 2) + XCTAssertEqual(receivedNavigationTargets, [nil, nil]) + } + + func testPresentPreferencesWindowForwardsNavigationTarget() { + var receivedNavigationTarget: SettingsNavigationTarget? + var activateApplicationCallCount = 0 + + AppDelegate.presentPreferencesWindow( + navigationTarget: .keyboardShortcuts, + showFallbackSettingsWindow: { navigationTarget in + receivedNavigationTarget = navigationTarget + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(receivedNavigationTarget, .keyboardShortcuts) + XCTAssertEqual(activateApplicationCallCount, 1) } private func makeKeyDownEvent( diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9e34690f..fd4b699b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3590,6 +3590,35 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { } } +final class DevBuildBannerDebugSettingsTests: XCTestCase { + func testShowSidebarBannerDefaultsToVisible() { + let suiteName = "DevBuildBannerDebugSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + } + + func testShowSidebarBannerRespectsStoredValue() { + let suiteName = "DevBuildBannerDebugSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + + defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) + } +} + final class ShortcutHintLanePlannerTests: XCTestCase { func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { let intervals: [ClosedRange] = [0...20, 28...40, 48...64] diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift new file mode 100644 index 00000000..9ba6cb4a --- /dev/null +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -0,0 +1,296 @@ +import XCTest + +private func sidebarHelpPollUntil( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + condition: () -> Bool +) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } +} + +final class SidebarHelpMenuUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testHelpMenuOpensKeyboardShortcutsSection() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let helpButton = requireElement( + candidates: helpButtonCandidates(in: app), + timeout: 6.0, + description: "sidebar help button" + ) + helpButton.click() + + let keyboardShortcutsItem = requireElement( + candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionKeyboardShortcuts", title: "Keyboard Shortcuts"), + timeout: 3.0, + description: "Keyboard Shortcuts help menu item" + ) + keyboardShortcutsItem.click() + + XCTAssertTrue(app.staticTexts["ShortcutRecordingHint"].waitForExistence(timeout: 6.0)) + } + + func testHelpMenuCheckForUpdatesTriggersSidebarUpdatePill() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml" + app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available" + app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9" + app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let helpButton = requireElement( + candidates: helpButtonCandidates(in: app), + timeout: 6.0, + description: "sidebar help button" + ) + helpButton.click() + + let checkForUpdatesItem = requireElement( + candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionCheckForUpdates", title: "Check for Updates"), + timeout: 3.0, + description: "Check for Updates help menu item" + ) + checkForUpdatesItem.click() + + let updatePill = app.buttons["UpdatePill"] + XCTAssertTrue(updatePill.waitForExistence(timeout: 6.0)) + XCTAssertEqual(updatePill.label, "Update Available: 9.9.9") + } + + func testHelpMenuSendFeedbackOpensComposerSheet() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + launchAndActivate(app) + + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0)) + + let helpButton = requireElement( + candidates: helpButtonCandidates(in: app), + timeout: 6.0, + description: "sidebar help button" + ) + helpButton.click() + + let sendFeedbackItem = requireElement( + candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionSendFeedback", title: "Send Feedback"), + timeout: 3.0, + description: "Send Feedback help menu item" + ) + sendFeedbackItem.click() + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + XCTAssertTrue( + firstExistingElement( + candidates: [ + app.textFields["SidebarFeedbackEmailField"], + app.textFields["Your Email"], + ], + timeout: 2.0 + ) != nil + ) + XCTAssertTrue( + firstExistingElement( + candidates: [ + app.buttons["SidebarFeedbackAttachButton"], + app.buttons["Attach Images"], + ], + timeout: 2.0 + ) != nil + ) + XCTAssertTrue( + firstExistingElement( + candidates: [ + app.buttons["SidebarFeedbackSendButton"], + app.buttons["Send"], + ], + timeout: 2.0 + ) != nil + ) + XCTAssertTrue( + app.staticTexts[ + "A human will read this! You can also reach us at founders@manaflow.com." + ].waitForExistence(timeout: 2.0) + ) + + let messageEditor = requireElement( + candidates: [ + app.textViews["SidebarFeedbackMessageEditor"], + app.scrollViews["SidebarFeedbackMessageEditor"], + app.otherElements["SidebarFeedbackMessageEditor"], + app.textViews["Message"], + ], + timeout: 2.0, + description: "feedback message editor" + ) + messageEditor.click() + app.typeText("hello") + XCTAssertTrue(app.staticTexts["5/4000"].waitForExistence(timeout: 2.0)) + } + + private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { + sidebarHelpPollUntil(timeout: timeout) { + app.windows.count >= count + } + } + + private func helpButtonCandidates(in app: XCUIApplication) -> [XCUIElement] { + let sidebar = app.otherElements["Sidebar"] + return [ + app.buttons["SidebarHelpMenuButton"], + app.buttons["Help"], + sidebar.buttons["SidebarHelpMenuButton"], + sidebar.buttons["Help"], + ] + } + + private func helpMenuItemCandidates( + in app: XCUIApplication, + identifier: String, + title: String + ) -> [XCUIElement] { + [ + app.buttons[identifier], + app.buttons[title], + ] + } + + private func firstExistingElement( + candidates: [XCUIElement], + timeout: TimeInterval + ) -> XCUIElement? { + var match: XCUIElement? + let found = sidebarHelpPollUntil(timeout: timeout) { + for candidate in candidates where candidate.exists { + match = candidate + return true + } + return false + } + return found ? match : nil + } + + private func requireElement( + candidates: [XCUIElement], + timeout: TimeInterval, + description: String + ) -> XCUIElement { + guard let element = firstExistingElement(candidates: candidates, timeout: timeout) else { + XCTFail("Expected \(description) to exist") + return candidates[0] + } + return element + } + + private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { + app.launch() + let activated = sidebarHelpPollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } + app.activate() + return app.state == .runningForeground + } + if !activated { + app.activate() + } + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 2.0) { app.state == .runningForeground }, + "App did not reach runningForeground before UI interactions" + ) + } +} + +final class FeedbackComposerShortcutUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testCmdOptionFOpensFeedbackComposer() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launch() + app.activate() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 6.0) { + app.windows.count >= 1 + } + ) + + app.typeKey("f", modifierFlags: [.command, .option]) + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + XCTAssertTrue( + app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0) + || app.textFields["Your Email"].waitForExistence(timeout: 2.0) + ) + } + + func testCmdOptionFWorksWithHiddenSidebar() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launch() + app.activate() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 6.0) { + app.windows.count >= 1 + } + ) + + app.typeKey("b", modifierFlags: [.command]) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + !app.buttons["SidebarHelpMenuButton"].exists && !app.buttons["Help"].exists + } + ) + + app.typeKey("f", modifierFlags: [.command, .option]) + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + } + + func testCmdOptionFWorksFromSettingsWindow() { + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1" + app.launch() + app.activate() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 6.0) { + app.windows.count >= 2 + } + ) + + app.typeKey("f", modifierFlags: [.command, .option]) + + XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0)) + XCTAssertTrue( + app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0) + || app.textFields["Your Email"].waitForExistence(timeout: 2.0) + ) + } +} diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..389f57d6 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,3 @@ +RESEND_API_KEY= +CMUX_FEEDBACK_FROM_EMAIL= +CMUX_FEEDBACK_RATE_LIMIT_ID= diff --git a/web/app/api/feedback/route.ts b/web/app/api/feedback/route.ts new file mode 100644 index 00000000..9b3d85b3 --- /dev/null +++ b/web/app/api/feedback/route.ts @@ -0,0 +1,340 @@ +import { checkRateLimit } from "@vercel/firewall"; +import { NextResponse } from "next/server"; +import { Resend } from "resend"; +import { z } from "zod"; + +import { env } from "@/app/env"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const feedbackRecipient = "founders@manaflow.com"; +const maxAttachmentCount = 10; +const maxAttachmentBytes = 4 * 1024 * 1024; +// Keep multipart requests below Vercel Functions' 4.5 MB request-body limit. +const maxTotalAttachmentBytes = 4 * 1024 * 1024; +const allowedImageTypes = new Set([ + "image/gif", + "image/heic", + "image/heif", + "image/jpeg", + "image/png", + "image/tiff", + "image/webp", +]); + +const feedbackSchema = z.object({ + email: z.string().trim().email().max(320), + message: z.string().trim().min(1).max(4000), + appVersion: z.string().trim().max(120).optional().default(""), + appBuild: z.string().trim().max(120).optional().default(""), + appCommit: z.string().trim().max(120).optional().default(""), + bundleIdentifier: z.string().trim().max(200).optional().default(""), + osVersion: z.string().trim().max(200).optional().default(""), + locale: z.string().trim().max(120).optional().default(""), +}); + +type PreparedAttachment = { + content: Buffer; + contentType: string; + filename: string; + size: number; +}; + +export async function POST(request: Request) { + const feedbackConfig = resolveFeedbackConfig(); + if (!feedbackConfig) { + return jsonError("Feedback endpoint is not configured", 503); + } + + if (process.env.VERCEL === "1") { + const { error, rateLimited } = await checkRateLimit( + feedbackConfig.rateLimitId, + { request }, + ); + + if (rateLimited || error === "blocked") { + return jsonError("Rate limit exceeded", 429); + } + + if (error === "not-found") { + console.error( + "feedback.route.rate_limit_not_found", + feedbackConfig.rateLimitId, + ); + } else if (error) { + console.error("feedback.route.rate_limit_error", error); + } + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return jsonError("Invalid multipart payload", 400); + } + + const parsed = feedbackSchema.safeParse({ + email: getString(formData, "email"), + message: getString(formData, "message"), + appVersion: getString(formData, "appVersion"), + appBuild: getString(formData, "appBuild"), + appCommit: getString(formData, "appCommit"), + bundleIdentifier: getString(formData, "bundleIdentifier"), + osVersion: getString(formData, "osVersion"), + locale: getString(formData, "locale"), + }); + + if (!parsed.success) { + return jsonError("Invalid feedback payload", 400); + } + + const attachmentsResult = await prepareAttachments( + formData.getAll("attachments"), + ); + if ("errorResponse" in attachmentsResult) { + return attachmentsResult.errorResponse; + } + + const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } = + parsed.data; + const subject = buildSubject(email, message, appVersion); + const attachments = attachmentsResult.attachments; + const resend = new Resend(feedbackConfig.resendApiKey); + + const { error } = await resend.emails.send({ + from: `cmux feedback <${feedbackConfig.fromEmail}>`, + to: [feedbackRecipient], + replyTo: email, + subject, + text: buildTextBody({ + email, + message, + appVersion, + appBuild, + appCommit, + bundleIdentifier, + osVersion, + locale, + attachments, + }), + html: buildHtmlBody({ + email, + message, + appVersion, + appBuild, + appCommit, + bundleIdentifier, + osVersion, + locale, + attachments, + }), + attachments: attachments.map((attachment) => ({ + content: attachment.content, + contentType: attachment.contentType, + filename: attachment.filename, + })), + }); + + if (error) { + console.error("feedback.route.resend_failed", error); + return jsonError("Failed to send feedback", 502); + } + + return NextResponse.json( + { ok: true }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); +} + +function resolveFeedbackConfig() { + const resendApiKey = env.RESEND_API_KEY; + const fromEmail = env.CMUX_FEEDBACK_FROM_EMAIL; + const rateLimitId = env.CMUX_FEEDBACK_RATE_LIMIT_ID; + + if (!resendApiKey || !fromEmail || !rateLimitId) { + return null; + } + + return { + resendApiKey, + fromEmail, + rateLimitId, + }; +} + +function getString(formData: FormData, key: string) { + const value = formData.get(key); + return typeof value === "string" ? value.trim() : ""; +} + +async function prepareAttachments(values: FormDataEntryValue[]) { + const files = values.filter( + (value): value is File => value instanceof File && value.name.length > 0, + ); + + if (files.length > maxAttachmentCount) { + return { + errorResponse: jsonError("Too many images attached", 400), + }; + } + + let totalSize = 0; + const attachments: PreparedAttachment[] = []; + + for (const file of files) { + if (!allowedImageTypes.has(file.type)) { + return { + errorResponse: jsonError("Unsupported image attachment type", 415), + }; + } + + if (file.size > maxAttachmentBytes) { + return { + errorResponse: jsonError("Image attachment is too large", 413), + }; + } + + totalSize += file.size; + if (totalSize > maxTotalAttachmentBytes) { + return { + errorResponse: jsonError("Total image attachment size is too large", 413), + }; + } + + attachments.push({ + content: Buffer.from(await file.arrayBuffer()), + contentType: file.type, + filename: sanitizeFilename(file.name), + size: file.size, + }); + } + + return { attachments }; +} + +function buildSubject(email: string, message: string, appVersion: string) { + const firstNonEmptyLine = + message + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "Feedback"; + const summary = + firstNonEmptyLine.length > 72 + ? `${firstNonEmptyLine.slice(0, 69)}...` + : firstNonEmptyLine; + const versionSuffix = appVersion ? ` (v${appVersion})` : ""; + + return `cmux feedback from ${email}${versionSuffix}: ${summary}`; +} + +function buildTextBody(input: { + email: string; + message: string; + appVersion: string; + appBuild: string; + appCommit: string; + bundleIdentifier: string; + osVersion: string; + locale: string; + attachments: PreparedAttachment[]; +}) { + const attachmentLines = + input.attachments.length === 0 + ? "Attachments: none" + : [ + "Attachments:", + ...input.attachments.map( + (attachment) => + `- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`, + ), + ].join("\n"); + + return [ + `From: ${input.email}`, + `App version: ${input.appVersion || "unknown"}`, + `App build: ${input.appBuild || "unknown"}`, + `App commit: ${input.appCommit || "unknown"}`, + `Bundle identifier: ${input.bundleIdentifier || "unknown"}`, + `macOS: ${input.osVersion || "unknown"}`, + `Locale: ${input.locale || "unknown"}`, + attachmentLines, + "", + "Message:", + input.message, + ].join("\n"); +} + +function buildHtmlBody(input: { + email: string; + message: string; + appVersion: string; + appBuild: string; + appCommit: string; + bundleIdentifier: string; + osVersion: string; + locale: string; + attachments: PreparedAttachment[]; +}) { + const attachmentMarkup = + input.attachments.length === 0 + ? "

Attachments: none

" + : `

Attachments:

    ${input.attachments + .map( + (attachment) => + `
  • ${escapeHtml(attachment.filename)} (${escapeHtml( + attachment.contentType, + )}, ${attachment.size} bytes)
  • `, + ) + .join("")}
`; + + return ` +
+

cmux feedback

+

From: ${escapeHtml(input.email)}

+

App version: ${escapeHtml(input.appVersion || "unknown")}

+

App build: ${escapeHtml(input.appBuild || "unknown")}

+

App commit: ${escapeHtml(input.appCommit || "unknown")}

+

Bundle identifier: ${escapeHtml( + input.bundleIdentifier || "unknown", + )}

+

macOS: ${escapeHtml(input.osVersion || "unknown")}

+

Locale: ${escapeHtml(input.locale || "unknown")}

+ ${attachmentMarkup} +

Message

+
${escapeHtml(
+        input.message,
+      )}
+
+ `.trim(); +} + +function sanitizeFilename(fileName: string) { + const cleaned = fileName.replace(/[\r\n"]/g, "").trim(); + return cleaned.length > 0 ? cleaned : "attachment"; +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function jsonError(message: string, status: number) { + return NextResponse.json( + { error: message }, + { + status, + headers: { + "Cache-Control": "no-store", + }, + }, + ); +} diff --git a/web/app/env.ts b/web/app/env.ts new file mode 100644 index 00000000..18ba7bcf --- /dev/null +++ b/web/app/env.ts @@ -0,0 +1,18 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + RESEND_API_KEY: z.string().min(1), + CMUX_FEEDBACK_FROM_EMAIL: z.string().email(), + CMUX_FEEDBACK_RATE_LIMIT_ID: z.string().min(1), + }, + runtimeEnv: { + RESEND_API_KEY: process.env.RESEND_API_KEY, + CMUX_FEEDBACK_FROM_EMAIL: process.env.CMUX_FEEDBACK_FROM_EMAIL, + CMUX_FEEDBACK_RATE_LIMIT_ID: process.env.CMUX_FEEDBACK_RATE_LIMIT_ID, + }, + skipValidation: + process.env.SKIP_ENV_VALIDATION === "1" || + process.env.VERCEL_ENV === "preview", +}); diff --git a/web/bun.lock b/web/bun.lock index abaa7c3f..d466e4f1 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,6 +5,8 @@ "": { "name": "web", "dependencies": { + "@t3-oss/env-nextjs": "^0.13.10", + "@vercel/firewall": "^1.1.2", "next": "16.1.6", "next-themes": "^0.4.6", "posthog-js": "^1.350.0", @@ -12,7 +14,9 @@ "react-dom": "19.2.3", "react-tweet": "^3.3.0", "react-wrap-balancer": "^1.1.1", + "resend": "^6.9.3", "shiki": "^3.22.0", + "zod": "^4.3.6", }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -249,8 +253,14 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="], + + "@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.10", "", { "dependencies": { "@t3-oss/env-core": "0.13.10" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -363,6 +373,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@vercel/firewall": ["@vercel/firewall@1.1.2", "", {}, "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -543,6 +555,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -819,6 +833,8 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "posthog-js": ["posthog-js@1.350.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.23.0", "@posthog/types": "1.350.0", "core-js": "^3.38.1", "dompurify": "^3.3.1", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-Ab+dyQdlKUTrfUZ12+fvcBo75S4jw/3o2gMleDga21B1v9c15yybiX4S3JrX66uh5L1DYG1H8sxtd4BXIIodjQ=="], @@ -859,6 +875,8 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "resend": ["resend@6.9.3", "", { "dependencies": { "postal-mime": "2.7.3", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -907,6 +925,8 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], @@ -933,6 +953,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="], + "swr": ["swr@2.4.0", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -987,6 +1009,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], diff --git a/web/next.config.ts b/web/next.config.ts index 52213e80..06164a93 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,3 +1,4 @@ +import "./app/env"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { diff --git a/web/package-lock.json b/web/package-lock.json index a7b86b58..9f3d7da0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,13 +9,18 @@ "version": "0.1.0", "license": "AGPL-3.0-or-later", "dependencies": { + "@t3-oss/env-nextjs": "^0.13.10", + "@vercel/firewall": "^1.1.2", "next": "16.1.6", "next-themes": "^0.4.6", "posthog-js": "^1.350.0", "react": "19.2.3", "react-dom": "19.2.3", + "react-tweet": "^3.3.0", "react-wrap-balancer": "^1.1.1", - "shiki": "^3.22.0" + "resend": "^6.9.3", + "shiki": "^3.22.0", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1630,6 +1635,12 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1639,6 +1650,61 @@ "tslib": "^2.8.0" } }, + "node_modules/@t3-oss/env-core": { + "version": "0.13.10", + "resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.10.tgz", + "integrity": "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==", + "license": "MIT", + "peerDependencies": { + "arktype": "^2.1.0", + "typescript": ">=5.0.0", + "valibot": "^1.0.0-beta.7 || ^1.0.0", + "zod": "^3.24.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@t3-oss/env-nextjs": { + "version": "0.13.10", + "resolved": "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.13.10.tgz", + "integrity": "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ==", + "license": "MIT", + "dependencies": { + "@t3-oss/env-core": "0.13.10" + }, + "peerDependencies": { + "arktype": "^2.1.0", + "typescript": ">=5.0.0", + "valibot": "^1.0.0-beta.7 || ^1.0.0", + "zod": "^3.24.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -2559,6 +2625,15 @@ "win32" ] }, + "node_modules/@vercel/firewall": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@vercel/firewall/-/firewall-1.1.2.tgz", + "integrity": "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3055,6 +3130,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4031,6 +4115,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6044,6 +6134,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6225,6 +6321,21 @@ "dev": true, "license": "MIT" }, + "node_modules/react-tweet": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-tweet/-/react-tweet-3.3.0.tgz", + "integrity": "sha512-gSIG2169ZK7UH6rBzuU+j1xnQbH3IlOTLEkuGrRiJJTMgETik+h+26yHyyVKrLkzwrOaYPk4K3OtEKycqKgNLw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.3", + "clsx": "^2.0.0", + "swr": "^2.2.4" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-wrap-balancer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/react-wrap-balancer/-/react-wrap-balancer-1.1.1.tgz", @@ -6302,6 +6413,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resend": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz", + "integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6695,6 +6827,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6908,6 +7050,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", @@ -7140,7 +7305,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7343,6 +7508,28 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -7515,7 +7702,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/web/package.json b/web/package.json index c69800e5..4540ac42 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,8 @@ "lint": "eslint" }, "dependencies": { + "@t3-oss/env-nextjs": "^0.13.10", + "@vercel/firewall": "^1.1.2", "next": "16.1.6", "next-themes": "^0.4.6", "posthog-js": "^1.350.0", @@ -17,7 +19,9 @@ "react-dom": "19.2.3", "react-tweet": "^3.3.0", "react-wrap-balancer": "^1.1.1", - "shiki": "^3.22.0" + "resend": "^6.9.3", + "shiki": "^3.22.0", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4",