Skip to content

Move application entry to AppKit #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Feb 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apple/InlineKit/Sources/InlineKit/ApiClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,13 @@ struct SessionInfo: Codable, Sendable {

public enum ApiComposeAction: String, Codable, Sendable {
case typing

public func toHumanReadable() -> String {
switch self {
case .typing:
"typing..."
}
}
}

public struct LinearAuthUrl: Codable, Sendable {
Expand Down
115 changes: 115 additions & 0 deletions apple/InlineKit/Sources/InlineKit/ObjectCache/ObjectCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Combine
import Foundation
import GRDB
import Logger

/// In memory cache for common nodes to not refetch
@MainActor
public class ObjectCache {
public static let shared = ObjectCache()

private init() {}

private var log = Log.scoped("ObjectCache", enableTracing: true)
private var db = AppDatabase.shared
private var observingUsers: Set<Int64> = []
private var users: [Int64: UserInfo] = [:]
private var chats: [Int64: Chat] = [:]
private var cancellables: Set<AnyCancellable> = []
private var userPublishers: [Int64: PassthroughSubject<UserInfo?, Never>] = [:]

public func getUser(id userId: Int64) -> UserInfo? {
if observingUsers.contains(userId) == false {
// fill in the cache
observeUser(id: userId)
}

let user = users[userId]

log.trace("User \(userId) returned: \(user?.user.fullName ?? "nil")")
return user
}

public func getUserPublisher(id userId: Int64) -> PassthroughSubject<UserInfo?, Never> {
if userPublishers[userId] == nil {
userPublishers[userId] = PassthroughSubject<UserInfo?, Never>()
// fill in the cache
let _ = getUser(id: userId)
}

return userPublishers[userId]!
}

public func getChat(id: Int64) -> Chat? {
if chats[id] == nil {
// fill in the cache
observeChat(id: id)
}

return chats[id]
}
}

// User
public extension ObjectCache {
func observeUser(id userId: Int64) {
log.trace("Observing user \(userId)")
observingUsers.insert(userId)
ValueObservation.tracking { db in
try User
.filter(id: userId)
.including(all: User.photos.forKey(UserInfo.CodingKeys.profilePhoto))
.asRequest(of: UserInfo.self)
.fetchOne(db)
}
.publisher(in: db.dbWriter, scheduling: .immediate)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
Log.shared.error("Failed to observe user \(userId): \(error)")
}
},
receiveValue: { [weak self] user in
if let user {
self?.log.trace("User \(userId) updated")
self?.users[userId] = user
} else {
self?.log.trace("User \(userId) not found")
self?.users[userId] = nil
}

// update publishers
self?.userPublishers[userId]?.send(user)
}
).store(in: &cancellables)
}
}

// Chats
public extension ObjectCache {
func observeChat(id chatId: Int64) {
log.trace("Observing chat \(chatId)")
ValueObservation.tracking { db in
try Chat
.filter(id: chatId)
.fetchOne(db)
}
.publisher(in: db.dbWriter, scheduling: .immediate)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
Log.shared.error("Failed to observe chat \(chatId): \(error)")
}
},
receiveValue: { [weak self] user in
if let user {
self?.log.trace("Chat \(chatId) updated")
self?.chats[chatId] = user
} else {
self?.log.trace("Chat \(chatId) not found")
self?.chats[chatId] = nil
}
}
).store(in: &cancellables)
}
}
24 changes: 24 additions & 0 deletions apple/InlineKit/Sources/InlineKit/Utils/ScreenMetrics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@


#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

@MainActor
enum ScreenMetrics {
static var height: CGFloat? {
#if os(iOS) || os(tvOS)
// Get main screen bounds for iOS/tvOS
return UIScreen.main.bounds.height
#elseif os(macOS)
// Get main window height for macOS
if let window = NSApplication.shared.mainWindow {
return window.frame.height
}
// Fallback to screen height if no window is available
return NSScreen.main?.frame.height
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ public class MessagesProgressiveViewModel {
private var maxDate: Date = .init()

// internals
private let initialLimit = 80
// was 80
private lazy var initialLimit: Int = // divide window height by 25
if let height = ScreenMetrics.height {
(Int(height.rounded()) / 24) + 30
} else {
60
}

private let log = Log.scoped("MessagesViewModel", enableTracing: true)
private let db = AppDatabase.shared
private var cancellable = Set<AnyCancellable>()
Expand Down Expand Up @@ -74,7 +81,7 @@ public class MessagesProgressiveViewModel {
// top id as cursor?
// firs try lets use date as cursor
let cursor = direction == .older ? minDate : maxDate
let limit = messages.count > 300 ? 400 : messages.count > 200 ? 300 : 100
let limit = messages.count > 200 ? 200 : 100
let prepend = direction == (reversed ? .newer : .older)
// log.debug("Loading next batch at \(direction) \(cursor)")
loadAdditionalMessages(limit: limit, cursor: cursor, prepend: prepend)
Expand Down
203 changes: 203 additions & 0 deletions apple/InlineMac/App/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import AppKit
import InlineConfig
import InlineKit
import Logger
import RealtimeAPI
import Sentry
import SwiftUI
import UserNotifications

class AppDelegate: NSObject, NSApplicationDelegate {
// Main Window
private var mainWindowController: MainWindowController?

// Common Dependencies
@MainActor private var dependencies = AppDependencies()

// --
let notifications = NotificationsManager()
let navigation: NavigationModel = .shared
let log = Log.scoped("AppDelegate")

func applicationWillFinishLaunching(_ notification: Notification) {
// Disable native tabbing
NSWindow.allowsAutomaticWindowTabbing = false

// Setup Notifications Delegate
setupNotifications()

dependencies.logOut = logOut
}

func applicationDidFinishLaunching(_ aNotification: Notification) {
initializeServices()
setupMainWindow()
setupMainMenu()
}

func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
true
}

func applicationDidResignActive(_ notification: Notification) {
Task {
// Mark offline
try? await DataManager.shared.updateStatus(online: false)
}
}

func applicationDidBecomeActive(_ notification: Notification) {
Task {
// Mark online
try? await DataManager.shared.updateStatus(online: true)
}
}

@MainActor private func setupMainWindow() {
let controller = MainWindowController(dependencies: dependencies)
controller.showWindow(nil)
mainWindowController = controller
}

private func initializeServices() {
// Setup Sentry
SentrySDK.start { options in
options.dsn = InlineConfig.SentryDSN
options.debug = false
options.tracesSampleRate = 0.1
}

// Register for notifications
notifications.setup()
}
}

// MARK: - Notifications

extension AppDelegate {
func setupNotifications() {
notifications.setup()
notifications.onNotificationReceived { response in
self.handleNotification(response)
}
UNUserNotificationCenter.current().delegate = notifications
}

func application(
_ application: NSApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
log.debug("Registered for remote notifications: \(deviceToken)")

notifications.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}

func application(
_ application: NSApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
log.error("Failed to register for remote notifications \(error)")
}

func handleNotification(_ response: UNNotificationResponse) {
log.debug("Received notification: \(response)")

// TODO: Navigate
guard let userInfo = response.notification.request.content.userInfo as? [String: Any] else {
return
}

if let peerId = getPeerFromNotification(userInfo) {
navigation.select(.chat(peer: peerId))
// TODO: Handle spaceId
}
}

func getPeerFromNotification(_ userInfo: [String: Any]) -> Peer? {
if let peerUserId = userInfo["userId"] as? Int64 {
.user(id: peerUserId)
} else {
nil
}
}

@MainActor private func setupMainMenu() {
AppMenu.shared.setupMainMenu(dependencies: dependencies)
}

private func logOut() async {
_ = try? await ApiClient.shared.logout()

// Clear creds
Auth.shared.logOut()

// Stop WebSocket
await dependencies.ws.loggedOut()

// Clear database
try? AppDatabase.loggedOut()

// Navigate outside of the app
DispatchQueue.main.async {
self.dependencies.viewModel.navigate(.onboarding)

// Reset internal navigation
self.dependencies.navigation.reset()
self.dependencies.nav.reset()
}

// Re-open windows
// if let mainWindowController {
// await mainWindowController.close()
// // re-open
// setupMainWindow()
// }
}
}

// MARK: - Dependency Container

@MainActor
struct AppDependencies {
let auth = Auth.shared
let ws = WebSocketManager()
let viewModel = MainWindowViewModel()
let overlay = OverlayManager()
let navigation = NavigationModel.shared
let transactions = Transactions.shared
let realtime = Realtime.shared
let database = AppDatabase.shared
let data = DataManager(database: AppDatabase.shared)

// Per window
let nav: Nav = .main

// Optional
var rootData: RootData?
var logOut: (() async -> Void) = {}
// Per window nav?
// var nav =
}

extension View {
func environment(dependencies deps: AppDependencies) -> AnyView {
var result = environment(\.auth, deps.auth)
.environmentObject(deps.ws)
.environmentObject(deps.viewModel)
.environmentObject(deps.overlay)
.environmentObject(deps.navigation)
.environmentObject(deps.nav)
.environmentObject(deps.data)
.environment(\.transactions, deps.transactions)
.environment(\.realtime, deps.realtime)
.appDatabase(deps.database)
.environment(\.logOut, deps.logOut)
.eraseToAnyView()

if let rootData = deps.rootData {
result = result.environmentObject(rootData).eraseToAnyView()
}

return result
}
}
Loading