Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
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
443 changes: 423 additions & 20 deletions Sources/Halos/Models/LazuliEvent.swift

Large diffs are not rendered by default.

1,904 changes: 1,801 additions & 103 deletions Sources/Halos/Stores/MissionControlStore.swift

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions Sources/Halos/Support/HalosStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

enum HalosStorage {
static let rootURL: URL = {
if let override = ProcessInfo.processInfo.environment["HALOS_STORAGE_ROOT"], !override.isEmpty {
return URL(fileURLWithPath: override, isDirectory: true)
}
return URL(fileURLWithPath: "/Volumes/Thorium/Storage/Halos", isDirectory: true)
}()

static var cacheURL: URL {
rootURL.appending(path: "Cache", directoryHint: .isDirectory)
}

static var attachmentsURL: URL {
cacheURL.appending(path: "Attachments", directoryHint: .isDirectory)
}

static var stateURL: URL {
rootURL.appending(path: "State", directoryHint: .isDirectory)
}

static var gatewayURL: URL {
stateURL.appending(path: "Gateway", directoryHint: .isDirectory)
}

static var gatewayDeviceURL: URL {
gatewayURL.appending(path: "gateway-device.json")
}

static func ensureLayout() throws {
let fileManager = FileManager.default
for directory in [cacheURL, attachmentsURL, stateURL, gatewayURL] {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
}

static func migrateFile(from legacyURL: URL, to targetURL: URL) {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: legacyURL.path),
!fileManager.fileExists(atPath: targetURL.path)
else {
return
}
do {
try fileManager.createDirectory(at: targetURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try fileManager.moveItem(at: legacyURL, to: targetURL)
} catch {
try? fileManager.copyItem(at: legacyURL, to: targetURL)
}
}
}
107 changes: 100 additions & 7 deletions Sources/Halos/Views/ActivityListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import SwiftUI

struct ActivityListView: View {
let messages: [ActivityMessage]
let resolveVeyraApproval: (String, VeyraApprovalDecision) -> Void
@State private var emptyCopy = EmptyComposerCopy.today()
@State private var isPinnedToBottom = true
@State private var viewportHeight: CGFloat = 0

private let bottomAnchorID = "activity-list-bottom-anchor"
private let bottomFollowThreshold: CGFloat = 48

var body: some View {
ScrollViewReader { proxy in
Expand All @@ -12,26 +18,93 @@ struct ActivityListView: View {
emptyState
} else {
ForEach(messages) { message in
ActivityRowView(message: message)
ActivityRowView(message: message, resolveVeyraApproval: resolveVeyraApproval)
.id(message.id)
}
}

Color.clear
.frame(height: 1)
.id(bottomAnchorID)
.background {
GeometryReader { geometry in
Color.clear.preference(
key: ActivityListBottomOffsetPreferenceKey.self,
value: geometry.frame(in: .named(ActivityListCoordinateSpace.name)).maxY
)
}
}
}
.padding(.vertical, 1)
.padding(.top, 52)
.padding(.bottom, 1)
.padding(.horizontal, 18)
}
.coordinateSpace(name: ActivityListCoordinateSpace.name)
.background {
GeometryReader { geometry in
Color.clear.preference(
key: ActivityListViewportHeightPreferenceKey.self,
value: geometry.size.height
)
}
}
.scrollIndicators(.hidden)
.transaction { transaction in
transaction.animation = nil
}
.mask {
VStack(spacing: 0) {
LinearGradient(
colors: [.clear, .black],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 42)

Color.black
}
}
.onAppear {
if messages.isEmpty {
emptyCopy = EmptyComposerCopy.today()
}
scrollToBottom(proxy, animated: false)
}
.onChange(of: messages.last?.id) { _, id in
guard let id else { return }
withAnimation(.smooth(duration: 0.22)) {
proxy.scrollTo(id, anchor: .bottom)
}
.onPreferenceChange(ActivityListViewportHeightPreferenceKey.self) { height in
viewportHeight = height
}
.onPreferenceChange(ActivityListBottomOffsetPreferenceKey.self) { bottomOffset in
guard viewportHeight > 0 else { return }
isPinnedToBottom = bottomOffset <= viewportHeight + bottomFollowThreshold
}
.onChange(of: contentFingerprint) { _, _ in
guard isPinnedToBottom else { return }
scrollToBottom(proxy, animated: true)
}
}
}

private var contentFingerprint: String {
messages
.map { message in
[
message.id,
message.title,
message.body,
message.streamState.rawValue,
].joined(separator: "\u{1F}")
}
.joined(separator: "\u{1E}")
}

private func scrollToBottom(_ proxy: ScrollViewProxy, animated: Bool) {
guard !messages.isEmpty else { return }
if animated {
withAnimation(.smooth(duration: 0.18)) {
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
}
} else {
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
}
}

Expand All @@ -51,6 +124,26 @@ struct ActivityListView: View {
}
}

private enum ActivityListCoordinateSpace {
static let name = "activity-list-scroll"
}

private struct ActivityListBottomOffsetPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0

static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}

private struct ActivityListViewportHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0

static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}

private struct EmptyComposerCopy: Equatable {
let title: String
let subtitle: String
Expand Down
Loading
Loading