@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.
Files changed (46) hide show
  1. package/bin/create-pylon.js +28 -51
  2. package/package.json +1 -1
  3. package/templates/_root/turbo.json +18 -0
  4. package/templates/backend/b2b/apps/api/package.json +3 -3
  5. package/templates/backend/barebones/apps/api/package.json +3 -3
  6. package/templates/backend/chat/apps/api/package.json +3 -3
  7. package/templates/backend/consumer/apps/api/package.json +3 -3
  8. package/templates/backend/todo/apps/api/package.json +3 -3
  9. package/templates/expo/barebones/apps/expo/package.json +1 -0
  10. package/templates/expo/chat/apps/expo/App.tsx +126 -156
  11. package/templates/expo/chat/apps/expo/package.json +1 -0
  12. package/templates/expo/consumer/apps/expo/App.tsx +211 -179
  13. package/templates/expo/consumer/apps/expo/package.json +1 -0
  14. package/templates/expo/todo/apps/expo/package.json +1 -0
  15. package/templates/ios/chat/apps/ios/Package.swift +1 -11
  16. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +26 -30
  17. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  18. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +27 -4
  19. package/templates/ios/chat/apps/ios/project.yml +2 -0
  20. package/templates/ios/consumer/apps/ios/Package.swift +1 -0
  21. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  22. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  23. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  24. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  25. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +33 -5
  26. package/templates/ios/consumer/apps/ios/project.yml +2 -0
  27. package/templates/mac/chat/apps/mac/Package.swift +1 -0
  28. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +30 -27
  29. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  30. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +26 -5
  31. package/templates/mac/chat/apps/mac/project.yml +2 -0
  32. package/templates/mac/consumer/apps/mac/Package.swift +1 -0
  33. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  34. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  35. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  36. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  37. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -6
  38. package/templates/mac/consumer/apps/mac/project.yml +2 -0
  39. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +52 -71
  40. package/templates/web/chat/apps/web/src/app/layout.tsx +3 -2
  41. package/templates/web/chat/apps/web/src/app/page.tsx +5 -44
  42. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  43. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +139 -119
  44. package/templates/web/consumer/apps/web/src/app/layout.tsx +3 -2
  45. package/templates/web/consumer/apps/web/src/app/page.tsx +8 -42
  46. 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
- /// Polls the active room every 1.5s. Replace with PylonQuery<Message>
6
- /// from PylonSwiftUI for realtime push.
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
- @State private var rooms: [Room] = []
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(rooms) { r in
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 = rooms.first(where: { $0.id == id }) {
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
- .task { await load() }
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 loading {
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.pylon.callFn(
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
- @State private var messages: [Message] = []
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
- @State private var pollTimer: Task<Void, Never>?
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(messages) { msg in
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 = messages.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
- .task {
61
- await loadMessages()
62
- startPolling()
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
- let msg: Message = try await session.pylon.callFn(
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 {
@@ -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
- ChatRootView()
11
- .environmentObject(session)
12
- .frame(minWidth: 700, minHeight: 500)
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 pylon: PylonClient
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.pylon = PylonClient(baseURL: url, appName: "__APP_NAME_SNAKE__")
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")
@@ -30,5 +30,7 @@ targets:
30
30
  dependencies:
31
31
  - package: pylon
32
32
  product: PylonClient
33
+ - package: pylon
34
+ product: PylonSync
33
35
  - package: pylon
34
36
  product: PylonSwiftUI
@@ -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
- @State private var feed: [FeedItem] = []
7
- @State private var loading = true
8
- @State private var posting = false
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 loading {
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(feed) { item in
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: FeedItem) -> some View {
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 == session.me?.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: - Network
103
-
104
- private func load() async {
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
- let item: FeedItem = try await session.pylon.callFn(
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(_ item: FeedItem) async {
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 result: ToggleLikeResult = try await session.pylon.callFn(
171
+ let _: ToggleLikeResponse = try await session.client.callFn(
140
172
  "toggleLike",
141
- args: ToggleLikeArgs(postId: item.id),
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(_ item: FeedItem) async {
158
- let snapshot = feed
159
- feed.removeAll { $0.id == item.id }
180
+ private func delete(_ postId: String) async {
160
181
  do {
161
- let _: FeedItem = try await session.pylon.callFn(
182
+ let _: Post = try await session.client.callFn(
162
183
  "deletePost",
163
- args: DeletePostArgs(id: item.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 FeedItem: Codable, Identifiable, Hashable {
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 AuthorCard: Codable, Hashable {
19
+ struct Like: Codable, Identifiable, Hashable {
22
20
  let id: String
23
- let handle: String
24
- let displayName: String
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 || handle.trimmingCharacters(in: .whitespaces).isEmpty
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.pylon.callFn(
55
+ let profile: Profile = try await session.client.callFn(
48
56
  "upsertProfile",
49
57
  args: UpsertProfileArgs(
50
- handle: handle.trimmingCharacters(in: .whitespaces).lowercased(),
58
+ handle: lower,
51
59
  displayName: displayName.trimmingCharacters(in: .whitespaces),
52
60
  bio: bio.trimmingCharacters(in: .whitespaces),
53
61
  ),
54
62
  )
55
- session.me = profile
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
- @State private var loading = true
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 loading {
11
- ProgressView()
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
- FeedView()
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 func load() async {
23
- do {
24
- session.me = try await session.pylon.callFn("myProfile", args: EmptyArgs())
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
  }