@pylonsync/create-pylon 0.3.54 → 0.3.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +28 -51
- package/package.json +1 -1
- package/templates/_root/turbo.json +18 -0
- package/templates/backend/b2b/apps/api/package.json +3 -3
- package/templates/backend/barebones/apps/api/package.json +3 -3
- package/templates/backend/chat/apps/api/package.json +3 -3
- package/templates/backend/consumer/apps/api/package.json +3 -3
- package/templates/backend/todo/apps/api/package.json +3 -3
- package/templates/expo/barebones/apps/expo/package.json +1 -0
- package/templates/expo/chat/apps/expo/App.tsx +126 -156
- package/templates/expo/chat/apps/expo/package.json +1 -0
- package/templates/expo/consumer/apps/expo/App.tsx +211 -179
- package/templates/expo/consumer/apps/expo/package.json +1 -0
- package/templates/expo/todo/apps/expo/package.json +1 -0
- package/templates/ios/chat/apps/ios/Package.swift +1 -11
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +26 -30
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
- package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +27 -4
- package/templates/ios/chat/apps/ios/project.yml +2 -0
- package/templates/ios/consumer/apps/ios/Package.swift +1 -0
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
- package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +33 -5
- package/templates/ios/consumer/apps/ios/project.yml +2 -0
- package/templates/mac/chat/apps/mac/Package.swift +1 -0
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +30 -27
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
- package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +26 -5
- package/templates/mac/chat/apps/mac/project.yml +2 -0
- package/templates/mac/consumer/apps/mac/Package.swift +1 -0
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
- package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -6
- package/templates/mac/consumer/apps/mac/project.yml +2 -0
- package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +52 -71
- package/templates/web/chat/apps/web/src/app/layout.tsx +3 -2
- package/templates/web/chat/apps/web/src/app/page.tsx +5 -44
- package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
- package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +139 -119
- package/templates/web/consumer/apps/web/src/app/layout.tsx +3 -2
- package/templates/web/consumer/apps/web/src/app/page.tsx +8 -42
- package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import PylonClient
|
|
3
|
+
import PylonSync
|
|
4
|
+
import PylonSwiftUI
|
|
3
5
|
|
|
4
6
|
/// macOS chat: rooms in a sidebar, room view in the detail pane.
|
|
5
|
-
///
|
|
6
|
-
///
|
|
7
|
+
/// PylonQuery<Room> subscribes via the SyncEngine — new rooms from
|
|
8
|
+
/// any client appear without polling. Same story inside RoomView for
|
|
9
|
+
/// messages.
|
|
7
10
|
struct ChatRootView: View {
|
|
8
11
|
@EnvironmentObject var session: AppSession
|
|
9
|
-
|
|
12
|
+
let engine: SyncEngine
|
|
13
|
+
@StateObject private var rooms: PylonQuery<Room>
|
|
10
14
|
@State private var selected: Room.ID?
|
|
11
15
|
@State private var showingCreate = false
|
|
12
16
|
@State private var draftSlug = ""
|
|
13
17
|
@State private var draftName = ""
|
|
14
|
-
@State private var loading = true
|
|
15
18
|
@State private var errorMessage: String?
|
|
16
19
|
|
|
20
|
+
init(engine: SyncEngine) {
|
|
21
|
+
self.engine = engine
|
|
22
|
+
_rooms = StateObject(
|
|
23
|
+
wrappedValue: PylonQuery<Room>(engine: engine, entity: "Room"),
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
var body: some View {
|
|
18
28
|
NavigationSplitView {
|
|
19
29
|
VStack(spacing: 0) {
|
|
20
30
|
List(selection: $selected) {
|
|
21
31
|
Section("Rooms") {
|
|
22
|
-
ForEach(
|
|
32
|
+
ForEach(sortedRooms) { r in
|
|
23
33
|
VStack(alignment: .leading, spacing: 2) {
|
|
24
34
|
Text(r.name)
|
|
25
35
|
Text("#\(r.slug)")
|
|
@@ -54,19 +64,27 @@ struct ChatRootView: View {
|
|
|
54
64
|
createSheet
|
|
55
65
|
}
|
|
56
66
|
} detail: {
|
|
57
|
-
if let id = selected, let room =
|
|
58
|
-
RoomView(room: room)
|
|
67
|
+
if let id = selected, let room = sortedRooms.first(where: { $0.id == id }) {
|
|
68
|
+
RoomView(room: room, engine: engine)
|
|
59
69
|
} else {
|
|
60
70
|
placeholder
|
|
61
71
|
}
|
|
62
72
|
}
|
|
63
|
-
.
|
|
73
|
+
.onAppear {
|
|
74
|
+
if selected == nil { selected = sortedRooms.first?.id }
|
|
75
|
+
}
|
|
76
|
+
.onChange(of: rooms.rows.count) { _, _ in
|
|
77
|
+
if selected == nil { selected = sortedRooms.first?.id }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private var sortedRooms: [Room] {
|
|
82
|
+
rooms.rows.sorted { $0.createdAt < $1.createdAt }
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
private var createSheet: some View {
|
|
67
86
|
VStack(alignment: .leading, spacing: 12) {
|
|
68
|
-
Text("Create a room")
|
|
69
|
-
.font(.headline)
|
|
87
|
+
Text("Create a room").font(.headline)
|
|
70
88
|
TextField("Name", text: $draftName)
|
|
71
89
|
.textFieldStyle(.roundedBorder)
|
|
72
90
|
TextField("Slug (e.g. general)", text: $draftSlug)
|
|
@@ -91,9 +109,7 @@ struct ChatRootView: View {
|
|
|
91
109
|
|
|
92
110
|
private var placeholder: some View {
|
|
93
111
|
VStack(spacing: 8) {
|
|
94
|
-
if
|
|
95
|
-
ProgressView()
|
|
96
|
-
} else if rooms.isEmpty {
|
|
112
|
+
if rooms.rows.isEmpty {
|
|
97
113
|
Text("No rooms yet.")
|
|
98
114
|
.foregroundStyle(.secondary)
|
|
99
115
|
Button("Create your first room") { showingCreate = true }
|
|
@@ -106,28 +122,15 @@ struct ChatRootView: View {
|
|
|
106
122
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
107
123
|
}
|
|
108
124
|
|
|
109
|
-
private func load() async {
|
|
110
|
-
loading = true
|
|
111
|
-
defer { loading = false }
|
|
112
|
-
do {
|
|
113
|
-
rooms = try await session.pylon.callFn("listRooms", args: EmptyArgs())
|
|
114
|
-
if selected == nil { selected = rooms.first?.id }
|
|
115
|
-
errorMessage = nil
|
|
116
|
-
} catch {
|
|
117
|
-
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
125
|
private func create() async {
|
|
122
126
|
let name = draftName.trimmingCharacters(in: .whitespaces)
|
|
123
127
|
let slug = draftSlug.trimmingCharacters(in: .whitespaces).lowercased()
|
|
124
128
|
guard !name.isEmpty, !slug.isEmpty else { return }
|
|
125
129
|
do {
|
|
126
|
-
let room: Room = try await session.
|
|
130
|
+
let room: Room = try await session.client.callFn(
|
|
127
131
|
"createRoom",
|
|
128
132
|
args: CreateRoomArgs(slug: slug, name: name),
|
|
129
133
|
)
|
|
130
|
-
rooms.append(room)
|
|
131
134
|
selected = room.id
|
|
132
135
|
showingCreate = false
|
|
133
136
|
draftName = ""
|
|
@@ -1,24 +1,45 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import PylonClient
|
|
3
|
+
import PylonSync
|
|
4
|
+
import PylonSwiftUI
|
|
3
5
|
|
|
6
|
+
/// Live room view. PylonQuery<Message> with a `where` predicate that
|
|
7
|
+
/// matches `roomId` to the active room. The sync engine pushes diffs
|
|
8
|
+
/// over WebSocket; new messages from any client land in `messages.rows`
|
|
9
|
+
/// without us polling.
|
|
4
10
|
struct RoomView: View {
|
|
5
11
|
@EnvironmentObject var session: AppSession
|
|
6
12
|
let room: Room
|
|
7
|
-
|
|
13
|
+
let engine: SyncEngine
|
|
14
|
+
@StateObject private var messages: PylonQuery<Message>
|
|
8
15
|
@State private var draft = ""
|
|
9
16
|
@State private var sending = false
|
|
10
17
|
@State private var errorMessage: String?
|
|
11
|
-
|
|
18
|
+
|
|
19
|
+
init(room: Room, engine: SyncEngine) {
|
|
20
|
+
self.room = room
|
|
21
|
+
self.engine = engine
|
|
22
|
+
let roomId = room.id
|
|
23
|
+
_messages = StateObject(
|
|
24
|
+
wrappedValue: PylonQuery<Message>(
|
|
25
|
+
engine: engine,
|
|
26
|
+
entity: "Message",
|
|
27
|
+
where: { row in
|
|
28
|
+
row["roomId"]?.stringValue == roomId
|
|
29
|
+
},
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
12
33
|
|
|
13
34
|
var body: some View {
|
|
14
35
|
VStack(spacing: 0) {
|
|
15
36
|
ScrollViewReader { proxy in
|
|
16
37
|
ScrollView {
|
|
17
38
|
LazyVStack(alignment: .leading, spacing: 12) {
|
|
18
|
-
ForEach(
|
|
39
|
+
ForEach(sortedMessages) { msg in
|
|
19
40
|
messageRow(msg).id(msg.id)
|
|
20
41
|
}
|
|
21
|
-
if messages.isEmpty {
|
|
42
|
+
if messages.rows.isEmpty {
|
|
22
43
|
Text("No messages yet. Say hi.")
|
|
23
44
|
.foregroundStyle(.secondary)
|
|
24
45
|
.padding(.top, 32)
|
|
@@ -27,8 +48,8 @@ struct RoomView: View {
|
|
|
27
48
|
}
|
|
28
49
|
.padding(16)
|
|
29
50
|
}
|
|
30
|
-
.onChange(of: messages.count) { _, _ in
|
|
31
|
-
if let last =
|
|
51
|
+
.onChange(of: messages.rows.count) { _, _ in
|
|
52
|
+
if let last = sortedMessages.last {
|
|
32
53
|
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
|
33
54
|
}
|
|
34
55
|
}
|
|
@@ -57,11 +78,10 @@ struct RoomView: View {
|
|
|
57
78
|
#if os(iOS)
|
|
58
79
|
.navigationBarTitleDisplayMode(.inline)
|
|
59
80
|
#endif
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
.onDisappear { pollTimer?.cancel() }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private var sortedMessages: [Message] {
|
|
84
|
+
messages.rows.sorted { $0.createdAt < $1.createdAt }
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
@ViewBuilder
|
|
@@ -90,36 +110,16 @@ struct RoomView: View {
|
|
|
90
110
|
return display.string(from: date)
|
|
91
111
|
}
|
|
92
112
|
|
|
93
|
-
private func loadMessages() async {
|
|
94
|
-
do {
|
|
95
|
-
messages = try await session.pylon.callFn(
|
|
96
|
-
"roomMessages",
|
|
97
|
-
args: RoomMessagesArgs(roomId: room.id),
|
|
98
|
-
)
|
|
99
|
-
errorMessage = nil
|
|
100
|
-
} catch {
|
|
101
|
-
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
private func startPolling() {
|
|
106
|
-
pollTimer?.cancel()
|
|
107
|
-
pollTimer = Task {
|
|
108
|
-
while !Task.isCancelled {
|
|
109
|
-
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
110
|
-
if Task.isCancelled { break }
|
|
111
|
-
await loadMessages()
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
113
|
private func send() async {
|
|
117
114
|
let body = draft.trimmingCharacters(in: .whitespaces)
|
|
118
115
|
guard !body.isEmpty else { return }
|
|
119
116
|
sending = true
|
|
120
117
|
defer { sending = false }
|
|
121
118
|
do {
|
|
122
|
-
|
|
119
|
+
// Server inserts via ctx.db.insert; the change_event flows
|
|
120
|
+
// back through the SyncEngine and our PylonQuery picks up
|
|
121
|
+
// the new row. We only clear the draft here.
|
|
122
|
+
let _: Message = try await session.client.callFn(
|
|
123
123
|
"sendMessage",
|
|
124
124
|
args: SendMessageArgs(
|
|
125
125
|
roomId: room.id,
|
|
@@ -127,7 +127,6 @@ struct RoomView: View {
|
|
|
127
127
|
authorName: session.authorName,
|
|
128
128
|
),
|
|
129
129
|
)
|
|
130
|
-
messages.append(msg)
|
|
131
130
|
draft = ""
|
|
132
131
|
errorMessage = nil
|
|
133
132
|
} catch {
|
package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import PylonClient
|
|
3
|
+
import PylonSync
|
|
3
4
|
|
|
4
5
|
@main
|
|
5
6
|
struct __APP_NAME_PASCAL__App: App {
|
|
@@ -7,9 +8,17 @@ struct __APP_NAME_PASCAL__App: App {
|
|
|
7
8
|
|
|
8
9
|
var body: some Scene {
|
|
9
10
|
Window("__APP_NAME__", id: "main") {
|
|
10
|
-
|
|
11
|
-
.
|
|
12
|
-
|
|
11
|
+
Group {
|
|
12
|
+
if let engine = session.engine {
|
|
13
|
+
ChatRootView(engine: engine)
|
|
14
|
+
.environmentObject(session)
|
|
15
|
+
} else {
|
|
16
|
+
ProgressView()
|
|
17
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
.task { await session.bootIfNeeded() }
|
|
21
|
+
.frame(minWidth: 700, minHeight: 500)
|
|
13
22
|
}
|
|
14
23
|
.windowResizability(.contentMinSize)
|
|
15
24
|
}
|
|
@@ -17,7 +26,8 @@ struct __APP_NAME_PASCAL__App: App {
|
|
|
17
26
|
|
|
18
27
|
@MainActor
|
|
19
28
|
final class AppSession: ObservableObject {
|
|
20
|
-
let
|
|
29
|
+
let client: PylonClient
|
|
30
|
+
@Published private(set) var engine: SyncEngine?
|
|
21
31
|
@Published var authorName: String
|
|
22
32
|
|
|
23
33
|
init() {
|
|
@@ -26,10 +36,21 @@ final class AppSession: ObservableObject {
|
|
|
26
36
|
guard let url = URL(string: baseURLString) else {
|
|
27
37
|
fatalError("Invalid PYLON_BASE_URL: \(baseURLString)")
|
|
28
38
|
}
|
|
29
|
-
self.
|
|
39
|
+
self.client = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
|
|
30
40
|
self.authorName = UserDefaults.standard.string(forKey: "authorName") ?? "anonymous"
|
|
31
41
|
}
|
|
32
42
|
|
|
43
|
+
func bootIfNeeded() async {
|
|
44
|
+
guard engine == nil else { return }
|
|
45
|
+
let baseURLString = ProcessInfo.processInfo.environment["PYLON_BASE_URL"]
|
|
46
|
+
?? "http://localhost:4321"
|
|
47
|
+
guard let url = URL(string: baseURLString) else { return }
|
|
48
|
+
let config = SyncEngineConfig(baseURL: url, appName: "__APP_NAME_SNAKE__")
|
|
49
|
+
let engine = await SyncEngine(config: config, client: client)
|
|
50
|
+
await engine.start()
|
|
51
|
+
self.engine = engine
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
func setAuthorName(_ name: String) {
|
|
34
55
|
authorName = name
|
|
35
56
|
UserDefaults.standard.set(name, forKey: "authorName")
|
|
@@ -25,6 +25,7 @@ let package = Package(
|
|
|
25
25
|
name: "__APP_NAME_PASCAL__",
|
|
26
26
|
dependencies: [
|
|
27
27
|
.product(name: "PylonClient", package: "pylon"),
|
|
28
|
+
.product(name: "PylonSync", package: "pylon"),
|
|
28
29
|
.product(name: "PylonSwiftUI", package: "pylon"),
|
|
29
30
|
],
|
|
30
31
|
path: "Sources/__APP_NAME_PASCAL__"
|
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import PylonClient
|
|
3
|
+
import PylonSync
|
|
4
|
+
import PylonSwiftUI
|
|
3
5
|
|
|
6
|
+
/// Live feed. Three subscriptions (Post / Like / Profile) joined
|
|
7
|
+
/// in-memory. Every new post + every like from any client renders
|
|
8
|
+
/// here without polling.
|
|
4
9
|
struct FeedView: View {
|
|
5
10
|
@EnvironmentObject var session: AppSession
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
let engine: SyncEngine
|
|
12
|
+
let me: Profile
|
|
13
|
+
let profiles: [Profile]
|
|
14
|
+
|
|
15
|
+
@StateObject private var posts: PylonQuery<Post>
|
|
16
|
+
@StateObject private var likes: PylonQuery<Like>
|
|
9
17
|
@State private var draft = ""
|
|
18
|
+
@State private var posting = false
|
|
10
19
|
@State private var errorMessage: String?
|
|
11
20
|
|
|
21
|
+
init(engine: SyncEngine, me: Profile, profiles: [Profile]) {
|
|
22
|
+
self.engine = engine
|
|
23
|
+
self.me = me
|
|
24
|
+
self.profiles = profiles
|
|
25
|
+
_posts = StateObject(
|
|
26
|
+
wrappedValue: PylonQuery<Post>(engine: engine, entity: "Post"),
|
|
27
|
+
)
|
|
28
|
+
_likes = StateObject(
|
|
29
|
+
wrappedValue: PylonQuery<Like>(engine: engine, entity: "Like"),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
var body: some View {
|
|
13
34
|
NavigationStack {
|
|
14
35
|
List {
|
|
@@ -31,13 +52,11 @@ struct FeedView: View {
|
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
Section("Feed") {
|
|
34
|
-
if
|
|
35
|
-
ProgressView()
|
|
36
|
-
} else if feed.isEmpty {
|
|
55
|
+
if items.isEmpty {
|
|
37
56
|
Text("No posts yet.")
|
|
38
57
|
.foregroundStyle(.secondary)
|
|
39
58
|
} else {
|
|
40
|
-
ForEach(
|
|
59
|
+
ForEach(items, id: \.post.id) { item in
|
|
41
60
|
row(item)
|
|
42
61
|
}
|
|
43
62
|
}
|
|
@@ -52,13 +71,37 @@ struct FeedView: View {
|
|
|
52
71
|
}
|
|
53
72
|
}
|
|
54
73
|
.navigationTitle("__APP_NAME__")
|
|
55
|
-
.task { await load() }
|
|
56
|
-
.refreshable { await load() }
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
76
|
|
|
77
|
+
private struct FeedRow: Hashable {
|
|
78
|
+
let post: Post
|
|
79
|
+
let author: Profile?
|
|
80
|
+
let likeCount: Int
|
|
81
|
+
let likedByMe: Bool
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private var profilesById: [String: Profile] {
|
|
85
|
+
Dictionary(uniqueKeysWithValues: profiles.map { ($0.id, $0) })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private var items: [FeedRow] {
|
|
89
|
+
posts.rows
|
|
90
|
+
.sorted { $0.createdAt > $1.createdAt }
|
|
91
|
+
.prefix(100)
|
|
92
|
+
.map { post in
|
|
93
|
+
let postLikes = likes.rows.filter { $0.postId == post.id }
|
|
94
|
+
return FeedRow(
|
|
95
|
+
post: post,
|
|
96
|
+
author: profilesById[post.authorId],
|
|
97
|
+
likeCount: postLikes.count,
|
|
98
|
+
likedByMe: postLikes.contains { $0.profileId == me.id },
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
60
103
|
@ViewBuilder
|
|
61
|
-
private func row(_ item:
|
|
104
|
+
private func row(_ item: FeedRow) -> some View {
|
|
62
105
|
VStack(alignment: .leading, spacing: 6) {
|
|
63
106
|
HStack(alignment: .firstTextBaseline) {
|
|
64
107
|
Text(item.author?.displayName ?? "Unknown")
|
|
@@ -67,16 +110,16 @@ struct FeedView: View {
|
|
|
67
110
|
.font(.system(.caption, design: .monospaced))
|
|
68
111
|
.foregroundStyle(.secondary)
|
|
69
112
|
Spacer()
|
|
70
|
-
Text(item.createdAt)
|
|
113
|
+
Text(item.post.createdAt)
|
|
71
114
|
.font(.caption2)
|
|
72
115
|
.foregroundStyle(.tertiary)
|
|
73
116
|
}
|
|
74
|
-
Text(item.body)
|
|
117
|
+
Text(item.post.body)
|
|
75
118
|
.font(.body)
|
|
76
119
|
.fixedSize(horizontal: false, vertical: true)
|
|
77
120
|
HStack {
|
|
78
121
|
Button {
|
|
79
|
-
Task { await toggleLike(item) }
|
|
122
|
+
Task { await toggleLike(item.post.id) }
|
|
80
123
|
} label: {
|
|
81
124
|
HStack(spacing: 4) {
|
|
82
125
|
Image(systemName: item.likedByMe ? "heart.fill" : "heart")
|
|
@@ -87,10 +130,10 @@ struct FeedView: View {
|
|
|
87
130
|
}
|
|
88
131
|
.buttonStyle(.plain)
|
|
89
132
|
|
|
90
|
-
if item.author?.id ==
|
|
133
|
+
if item.author?.id == me.id {
|
|
91
134
|
Spacer()
|
|
92
135
|
Button("Delete", role: .destructive) {
|
|
93
|
-
Task { await delete(item) }
|
|
136
|
+
Task { await delete(item.post.id) }
|
|
94
137
|
}
|
|
95
138
|
.font(.caption)
|
|
96
139
|
}
|
|
@@ -99,18 +142,9 @@ struct FeedView: View {
|
|
|
99
142
|
.padding(.vertical, 4)
|
|
100
143
|
}
|
|
101
144
|
|
|
102
|
-
// MARK: -
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
loading = true
|
|
106
|
-
defer { loading = false }
|
|
107
|
-
do {
|
|
108
|
-
feed = try await session.pylon.callFn("feed", args: EmptyArgs())
|
|
109
|
-
errorMessage = nil
|
|
110
|
-
} catch {
|
|
111
|
-
errorMessage = "Load failed: \(error.localizedDescription)"
|
|
112
|
-
}
|
|
113
|
-
}
|
|
145
|
+
// MARK: - Mutations (writes flow through callFn; the engine receives
|
|
146
|
+
// the change_event and updates the local store, which re-renders the
|
|
147
|
+
// PylonQuery rows above).
|
|
114
148
|
|
|
115
149
|
private func post() async {
|
|
116
150
|
let body = draft.trimmingCharacters(in: .whitespaces)
|
|
@@ -118,53 +152,48 @@ struct FeedView: View {
|
|
|
118
152
|
posting = true
|
|
119
153
|
defer { posting = false }
|
|
120
154
|
do {
|
|
121
|
-
|
|
155
|
+
// `createPost` returns the joined shape (Post + author),
|
|
156
|
+
// but we don't use the return value — the engine will
|
|
157
|
+
// surface the new Post row via the live subscription.
|
|
158
|
+
let _: PostCreatedResponse = try await session.client.callFn(
|
|
122
159
|
"createPost",
|
|
123
160
|
args: CreatePostArgs(body: body),
|
|
124
161
|
)
|
|
125
|
-
feed.insert(item, at: 0)
|
|
126
162
|
draft = ""
|
|
163
|
+
errorMessage = nil
|
|
127
164
|
} catch {
|
|
128
165
|
errorMessage = "Post failed: \(error.localizedDescription)"
|
|
129
166
|
}
|
|
130
167
|
}
|
|
131
168
|
|
|
132
|
-
private func toggleLike(_
|
|
133
|
-
// Optimistic
|
|
134
|
-
if let i = feed.firstIndex(where: { $0.id == item.id }) {
|
|
135
|
-
feed[i].likedByMe.toggle()
|
|
136
|
-
feed[i].likeCount += feed[i].likedByMe ? 1 : -1
|
|
137
|
-
}
|
|
169
|
+
private func toggleLike(_ postId: String) async {
|
|
138
170
|
do {
|
|
139
|
-
let
|
|
171
|
+
let _: ToggleLikeResponse = try await session.client.callFn(
|
|
140
172
|
"toggleLike",
|
|
141
|
-
args: ToggleLikeArgs(postId:
|
|
173
|
+
args: ToggleLikeArgs(postId: postId),
|
|
142
174
|
)
|
|
143
|
-
if let i = feed.firstIndex(where: { $0.id == item.id }) {
|
|
144
|
-
feed[i].likedByMe = result.liked
|
|
145
|
-
feed[i].likeCount = result.likeCount
|
|
146
|
-
}
|
|
147
175
|
} catch {
|
|
148
|
-
// Revert
|
|
149
|
-
if let i = feed.firstIndex(where: { $0.id == item.id }) {
|
|
150
|
-
feed[i].likedByMe = item.likedByMe
|
|
151
|
-
feed[i].likeCount = item.likeCount
|
|
152
|
-
}
|
|
153
176
|
errorMessage = "Like failed: \(error.localizedDescription)"
|
|
154
177
|
}
|
|
155
178
|
}
|
|
156
179
|
|
|
157
|
-
private func delete(_
|
|
158
|
-
let snapshot = feed
|
|
159
|
-
feed.removeAll { $0.id == item.id }
|
|
180
|
+
private func delete(_ postId: String) async {
|
|
160
181
|
do {
|
|
161
|
-
let _:
|
|
182
|
+
let _: Post = try await session.client.callFn(
|
|
162
183
|
"deletePost",
|
|
163
|
-
args: DeletePostArgs(id:
|
|
184
|
+
args: DeletePostArgs(id: postId),
|
|
164
185
|
)
|
|
165
186
|
} catch {
|
|
166
|
-
feed = snapshot
|
|
167
187
|
errorMessage = "Delete failed: \(error.localizedDescription)"
|
|
168
188
|
}
|
|
169
189
|
}
|
|
170
190
|
}
|
|
191
|
+
|
|
192
|
+
private struct PostCreatedResponse: Decodable {
|
|
193
|
+
let id: String
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private struct ToggleLikeResponse: Decodable {
|
|
197
|
+
let liked: Bool
|
|
198
|
+
let likeCount: Int
|
|
199
|
+
}
|
|
@@ -9,19 +9,18 @@ struct Profile: Codable, Identifiable, Hashable {
|
|
|
9
9
|
let createdAt: String
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
struct
|
|
12
|
+
struct Post: Codable, Identifiable, Hashable {
|
|
13
13
|
let id: String
|
|
14
|
+
let authorId: String
|
|
14
15
|
let body: String
|
|
15
16
|
let createdAt: String
|
|
16
|
-
let author: AuthorCard?
|
|
17
|
-
var likeCount: Int
|
|
18
|
-
var likedByMe: Bool
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
struct
|
|
19
|
+
struct Like: Codable, Identifiable, Hashable {
|
|
22
20
|
let id: String
|
|
23
|
-
let
|
|
24
|
-
let
|
|
21
|
+
let postId: String
|
|
22
|
+
let profileId: String
|
|
23
|
+
let createdAt: String
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
struct UpsertProfileArgs: Encodable {
|
|
@@ -29,14 +28,7 @@ struct UpsertProfileArgs: Encodable {
|
|
|
29
28
|
let displayName: String
|
|
30
29
|
let bio: String
|
|
31
30
|
}
|
|
32
|
-
|
|
33
31
|
struct CreatePostArgs: Encodable { let body: String }
|
|
34
32
|
struct DeletePostArgs: Encodable { let id: String }
|
|
35
33
|
struct ToggleLikeArgs: Encodable { let postId: String }
|
|
36
|
-
|
|
37
|
-
struct ToggleLikeResult: Codable {
|
|
38
|
-
let liked: Bool
|
|
39
|
-
let likeCount: Int
|
|
40
|
-
}
|
|
41
|
-
|
|
42
34
|
struct EmptyArgs: Encodable {}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import PylonClient
|
|
3
|
+
import PylonSync
|
|
3
4
|
|
|
4
5
|
struct ProfileSetupView: View {
|
|
5
6
|
@EnvironmentObject var session: AppSession
|
|
7
|
+
let engine: SyncEngine
|
|
8
|
+
let existingHandles: [String]
|
|
9
|
+
|
|
6
10
|
@State private var handle = ""
|
|
7
11
|
@State private var displayName = ""
|
|
8
12
|
@State private var bio = ""
|
|
@@ -19,7 +23,6 @@ struct ProfileSetupView: View {
|
|
|
19
23
|
TextField("Display name", text: $displayName)
|
|
20
24
|
TextField("Bio (optional)", text: $bio)
|
|
21
25
|
}
|
|
22
|
-
|
|
23
26
|
if let errorMessage {
|
|
24
27
|
Section {
|
|
25
28
|
Text(errorMessage)
|
|
@@ -27,12 +30,12 @@ struct ProfileSetupView: View {
|
|
|
27
30
|
.font(.caption)
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
|
-
|
|
31
33
|
Section {
|
|
32
34
|
Button(saving ? "Saving…" : "Save") {
|
|
33
35
|
Task { await save() }
|
|
34
36
|
}
|
|
35
|
-
.disabled(saving
|
|
37
|
+
.disabled(saving
|
|
38
|
+
|| handle.trimmingCharacters(in: .whitespaces).isEmpty
|
|
36
39
|
|| displayName.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
37
40
|
}
|
|
38
41
|
}
|
|
@@ -43,16 +46,21 @@ struct ProfileSetupView: View {
|
|
|
43
46
|
private func save() async {
|
|
44
47
|
saving = true
|
|
45
48
|
defer { saving = false }
|
|
49
|
+
let lower = handle.trimmingCharacters(in: .whitespaces).lowercased()
|
|
50
|
+
if existingHandles.contains(lower) {
|
|
51
|
+
errorMessage = "@\(lower) is taken"
|
|
52
|
+
return
|
|
53
|
+
}
|
|
46
54
|
do {
|
|
47
|
-
let profile: Profile = try await session.
|
|
55
|
+
let profile: Profile = try await session.client.callFn(
|
|
48
56
|
"upsertProfile",
|
|
49
57
|
args: UpsertProfileArgs(
|
|
50
|
-
handle:
|
|
58
|
+
handle: lower,
|
|
51
59
|
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
|
52
60
|
bio: bio.trimmingCharacters(in: .whitespaces),
|
|
53
61
|
),
|
|
54
62
|
)
|
|
55
|
-
session.
|
|
63
|
+
session.setMyProfileId(profile.id)
|
|
56
64
|
} catch {
|
|
57
65
|
errorMessage = "Save failed: \(error.localizedDescription)"
|
|
58
66
|
}
|
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
import PylonClient
|
|
3
|
+
import PylonSync
|
|
4
|
+
import PylonSwiftUI
|
|
3
5
|
|
|
6
|
+
/// Watches the live Profile collection. If we have a saved profileId
|
|
7
|
+
/// AND a Profile row in the local store with that id, render the
|
|
8
|
+
/// feed; otherwise, profile setup. The profile row appears in the
|
|
9
|
+
/// store via the SyncEngine, so a fresh device sees its own profile
|
|
10
|
+
/// pop in moments after `upsertProfile` returns.
|
|
4
11
|
struct RootView: View {
|
|
5
12
|
@EnvironmentObject var session: AppSession
|
|
6
|
-
|
|
13
|
+
let engine: SyncEngine
|
|
14
|
+
@StateObject private var profiles: PylonQuery<Profile>
|
|
15
|
+
|
|
16
|
+
init(engine: SyncEngine) {
|
|
17
|
+
self.engine = engine
|
|
18
|
+
_profiles = StateObject(
|
|
19
|
+
wrappedValue: PylonQuery<Profile>(engine: engine, entity: "Profile"),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
7
22
|
|
|
8
23
|
var body: some View {
|
|
9
24
|
Group {
|
|
10
|
-
if
|
|
11
|
-
|
|
12
|
-
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
13
|
-
} else if session.me == nil {
|
|
14
|
-
ProfileSetupView()
|
|
25
|
+
if let me = currentProfile {
|
|
26
|
+
FeedView(engine: engine, me: me, profiles: profiles.rows)
|
|
15
27
|
} else {
|
|
16
|
-
|
|
28
|
+
ProfileSetupView(
|
|
29
|
+
engine: engine,
|
|
30
|
+
existingHandles: profiles.rows.map { $0.handle },
|
|
31
|
+
)
|
|
17
32
|
}
|
|
18
33
|
}
|
|
19
|
-
.task { await load() }
|
|
20
34
|
}
|
|
21
35
|
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} catch {
|
|
26
|
-
session.me = nil
|
|
27
|
-
}
|
|
28
|
-
loading = false
|
|
36
|
+
private var currentProfile: Profile? {
|
|
37
|
+
guard let id = session.myProfileId else { return nil }
|
|
38
|
+
return profiles.rows.first { $0.id == id }
|
|
29
39
|
}
|
|
30
40
|
}
|