@pylonsync/create-pylon 0.3.54 → 0.3.55

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 (35) hide show
  1. package/package.json +1 -1
  2. package/templates/expo/chat/apps/expo/App.tsx +126 -156
  3. package/templates/expo/consumer/apps/expo/App.tsx +211 -179
  4. package/templates/ios/chat/apps/ios/Package.swift +1 -11
  5. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +26 -30
  6. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  7. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +27 -4
  8. package/templates/ios/chat/apps/ios/project.yml +2 -0
  9. package/templates/ios/consumer/apps/ios/Package.swift +1 -0
  10. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  11. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  12. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  13. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  14. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +33 -5
  15. package/templates/ios/consumer/apps/ios/project.yml +2 -0
  16. package/templates/mac/chat/apps/mac/Package.swift +1 -0
  17. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +30 -27
  18. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +35 -36
  19. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +26 -5
  20. package/templates/mac/chat/apps/mac/project.yml +2 -0
  21. package/templates/mac/consumer/apps/mac/Package.swift +1 -0
  22. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +81 -52
  23. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +6 -14
  24. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +14 -6
  25. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +25 -15
  26. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +34 -6
  27. package/templates/mac/consumer/apps/mac/project.yml +2 -0
  28. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +52 -71
  29. package/templates/web/chat/apps/web/src/app/layout.tsx +3 -2
  30. package/templates/web/chat/apps/web/src/app/page.tsx +5 -44
  31. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  32. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +139 -119
  33. package/templates/web/consumer/apps/web/src/app/layout.tsx +3 -2
  34. package/templates/web/consumer/apps/web/src/app/page.tsx +8 -42
  35. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -9,14 +9,18 @@ import {
9
9
  StyleSheet,
10
10
  Platform,
11
11
  Alert,
12
+ SafeAreaView,
12
13
  } from "react-native";
13
14
  import { StatusBar } from "expo-status-bar";
14
- import { init, callFn } from "@pylonsync/react-native";
15
+ import { init, db, callFn } from "@pylonsync/react-native";
16
+ import AsyncStorage from "@react-native-async-storage/async-storage";
15
17
 
16
18
  const PYLON_BASE_URL =
17
19
  process.env.EXPO_PUBLIC_PYLON_BASE_URL ??
18
20
  (Platform.OS === "android" ? "http://10.0.2.2:4321" : "http://localhost:4321");
19
21
 
22
+ const PROFILE_KEY = "__APP_NAME_SNAKE___profile_id";
23
+
20
24
  type Profile = {
21
25
  id: string;
22
26
  userId: string;
@@ -26,13 +30,18 @@ type Profile = {
26
30
  createdAt: string;
27
31
  };
28
32
 
29
- type FeedItem = {
33
+ type Post = {
30
34
  id: string;
35
+ authorId: string;
31
36
  body: string;
32
37
  createdAt: string;
33
- author: { id: string; handle: string; displayName: string } | null;
34
- likeCount: number;
35
- likedByMe: boolean;
38
+ };
39
+
40
+ type Like = {
41
+ id: string;
42
+ postId: string;
43
+ profileId: string;
44
+ createdAt: string;
36
45
  };
37
46
 
38
47
  let initPromise: Promise<void> | null = null;
@@ -48,20 +57,15 @@ function ensureInit() {
48
57
 
49
58
  export default function App() {
50
59
  const [ready, setReady] = useState(false);
51
- const [me, setMe] = useState<Profile | null>(null);
52
-
60
+ const [profileId, setProfileId] = useState<string | null>(null);
53
61
  useEffect(() => {
54
- ensureInit().then(async () => {
55
- try {
56
- const profile = await callFn<Profile | null>("myProfile", {});
57
- setMe(profile);
58
- } catch {
59
- setMe(null);
60
- }
62
+ (async () => {
63
+ await ensureInit();
64
+ const stored = await AsyncStorage.getItem(PROFILE_KEY);
65
+ setProfileId(stored);
61
66
  setReady(true);
62
- });
67
+ })();
63
68
  }, []);
64
-
65
69
  if (!ready) {
66
70
  return (
67
71
  <View style={[styles.screen, styles.center]}>
@@ -69,156 +73,86 @@ export default function App() {
69
73
  </View>
70
74
  );
71
75
  }
76
+ return (
77
+ <Root
78
+ profileId={profileId}
79
+ onProfileChange={async (id) => {
80
+ if (id) await AsyncStorage.setItem(PROFILE_KEY, id);
81
+ else await AsyncStorage.removeItem(PROFILE_KEY);
82
+ setProfileId(id);
83
+ }}
84
+ />
85
+ );
86
+ }
87
+
88
+ function Root({
89
+ profileId,
90
+ onProfileChange,
91
+ }: {
92
+ profileId: string | null;
93
+ onProfileChange: (id: string | null) => void;
94
+ }) {
95
+ const { data: profiles = [] } = db.useQuery<Profile>("Profile", {});
96
+ const me = useMemo(
97
+ () => (profileId ? (profiles.find((p) => p.id === profileId) ?? null) : null),
98
+ [profileId, profiles],
99
+ );
100
+
72
101
  if (!me) {
73
- return <ProfileSetup onSaved={setMe} />;
102
+ return (
103
+ <ProfileSetup
104
+ existingHandles={profiles.map((p) => p.handle)}
105
+ onSaved={(p) => onProfileChange(p.id)}
106
+ />
107
+ );
74
108
  }
75
- return <Feed me={me} />;
109
+ return <Feed me={me} profiles={profiles} />;
76
110
  }
77
111
 
78
- function ProfileSetup({ onSaved }: { onSaved: (p: Profile) => void }) {
79
- const [handle, setHandle] = useState("");
80
- const [displayName, setDisplayName] = useState("");
81
- const [bio, setBio] = useState("");
82
- const [saving, setSaving] = useState(false);
112
+ function Feed({ me, profiles }: { me: Profile; profiles: Profile[] }) {
113
+ const { data: posts = [] } = db.useQuery<Post>("Post", {
114
+ orderBy: { createdAt: "desc" },
115
+ limit: 100,
116
+ });
117
+ const { data: likes = [] } = db.useQuery<Like>("Like", {});
83
118
 
84
- async function save() {
85
- setSaving(true);
86
- try {
87
- const p = await callFn<Profile>("upsertProfile", {
88
- handle: handle.trim().toLowerCase(),
89
- displayName: displayName.trim(),
90
- bio: bio.trim(),
91
- });
92
- onSaved(p);
93
- } catch (e) {
94
- Alert.alert("Save failed", String(e));
95
- } finally {
96
- setSaving(false);
97
- }
98
- }
119
+ const profilesById = useMemo(() => {
120
+ const map = new Map<string, Profile>();
121
+ for (const p of profiles) map.set(p.id, p);
122
+ return map;
123
+ }, [profiles]);
99
124
 
100
- return (
101
- <View style={styles.screen}>
102
- <StatusBar style="auto" />
103
- <Text style={styles.title}>__APP_NAME__</Text>
104
- <Text style={styles.subtitle}>Set up your profile</Text>
105
- <TextInput
106
- style={styles.input}
107
- placeholder="handle (lowercase, 2–20)"
108
- autoCapitalize="none"
109
- autoCorrect={false}
110
- value={handle}
111
- onChangeText={setHandle}
112
- />
113
- <TextInput
114
- style={styles.input}
115
- placeholder="Display name"
116
- value={displayName}
117
- onChangeText={setDisplayName}
118
- />
119
- <TextInput
120
- style={styles.input}
121
- placeholder="Bio (optional)"
122
- value={bio}
123
- onChangeText={setBio}
124
- />
125
- <Pressable
126
- onPress={save}
127
- disabled={saving || !handle.trim() || !displayName.trim()}
128
- style={({ pressed }) => [
129
- styles.button,
130
- (saving || !handle.trim() || !displayName.trim()) && styles.buttonDisabled,
131
- pressed && styles.buttonPressed,
132
- ]}
133
- >
134
- <Text style={styles.buttonLabel}>{saving ? "Saving…" : "Save"}</Text>
135
- </Pressable>
136
- </View>
125
+ const items = useMemo(
126
+ () =>
127
+ posts.map((post) => {
128
+ const author = profilesById.get(post.authorId) ?? null;
129
+ const postLikes = likes.filter((l) => l.postId === post.id);
130
+ const likedByMe = postLikes.some((l) => l.profileId === me.id);
131
+ return { post, author, likeCount: postLikes.length, likedByMe };
132
+ }),
133
+ [posts, profilesById, likes, me.id],
137
134
  );
138
- }
139
135
 
140
- function Feed({ me }: { me: Profile }) {
141
- const [feed, setFeed] = useState<FeedItem[]>([]);
142
- const [loading, setLoading] = useState(true);
143
136
  const [draft, setDraft] = useState("");
144
137
  const [posting, setPosting] = useState(false);
145
138
 
146
- useEffect(() => {
147
- void load();
148
- }, []);
149
-
150
- async function load() {
151
- setLoading(true);
152
- try {
153
- setFeed(await callFn<FeedItem[]>("feed", {}));
154
- } catch (e) {
155
- Alert.alert("Load failed", String(e));
156
- } finally {
157
- setLoading(false);
158
- }
159
- }
160
-
161
139
  async function post() {
162
140
  const body = draft.trim();
163
141
  if (!body) return;
142
+ setDraft("");
164
143
  setPosting(true);
165
144
  try {
166
- const item = await callFn<FeedItem>("createPost", { body });
167
- setFeed((prev) => [item, ...prev]);
168
- setDraft("");
145
+ await callFn("createPost", { body });
169
146
  } catch (e) {
170
147
  Alert.alert("Post failed", String(e));
148
+ setDraft(body);
171
149
  } finally {
172
150
  setPosting(false);
173
151
  }
174
152
  }
175
153
 
176
- async function toggleLike(item: FeedItem) {
177
- setFeed((prev) =>
178
- prev.map((p) =>
179
- p.id === item.id
180
- ? {
181
- ...p,
182
- likedByMe: !p.likedByMe,
183
- likeCount: p.likeCount + (p.likedByMe ? -1 : 1),
184
- }
185
- : p,
186
- ),
187
- );
188
- try {
189
- const result = await callFn<{ liked: boolean; likeCount: number }>(
190
- "toggleLike",
191
- { postId: item.id },
192
- );
193
- setFeed((prev) =>
194
- prev.map((p) =>
195
- p.id === item.id
196
- ? { ...p, likedByMe: result.liked, likeCount: result.likeCount }
197
- : p,
198
- ),
199
- );
200
- } catch {
201
- setFeed((prev) =>
202
- prev.map((p) =>
203
- p.id === item.id
204
- ? { ...p, likedByMe: item.likedByMe, likeCount: item.likeCount }
205
- : p,
206
- ),
207
- );
208
- }
209
- }
210
-
211
- async function remove(item: FeedItem) {
212
- setFeed((prev) => prev.filter((p) => p.id !== item.id));
213
- try {
214
- await callFn("deletePost", { id: item.id });
215
- } catch {
216
- setFeed((prev) => [...prev, item]);
217
- }
218
- }
219
-
220
154
  return (
221
- <View style={styles.screen}>
155
+ <SafeAreaView style={styles.screen}>
222
156
  <StatusBar style="auto" />
223
157
  <View style={styles.headerRow}>
224
158
  <View>
@@ -252,57 +186,155 @@ function Feed({ me }: { me: Profile }) {
252
186
  </View>
253
187
  </View>
254
188
 
255
- {loading ? (
256
- <ActivityIndicator style={{ marginTop: 24 }} />
257
- ) : feed.length === 0 ? (
189
+ {items.length === 0 ? (
258
190
  <Text style={styles.empty}>No posts yet.</Text>
259
191
  ) : (
260
192
  <FlatList
261
- data={feed}
262
- keyExtractor={(p) => p.id}
193
+ data={items}
194
+ keyExtractor={(it) => it.post.id}
263
195
  contentContainerStyle={{ paddingTop: 8, paddingBottom: 32 }}
264
196
  ItemSeparatorComponent={() => <View style={styles.separator} />}
265
- renderItem={({ item }) => (
266
- <View style={styles.post}>
267
- <View style={styles.postHead}>
268
- <Text style={styles.postName}>
269
- {item.author?.displayName ?? "Unknown"}
270
- </Text>
271
- <Text style={styles.postHandle}>
272
- @{item.author?.handle ?? "?"}
273
- </Text>
274
- </View>
275
- <Text style={styles.postBody}>{item.body}</Text>
276
- <View style={styles.postFoot}>
277
- <Pressable onPress={() => toggleLike(item)}>
278
- <Text
279
- style={[
280
- styles.likeBtn,
281
- item.likedByMe && styles.likeBtnActive,
282
- ]}
283
- >
284
- {item.likedByMe ? "♥" : "♡"} {item.likeCount}
285
- </Text>
286
- </Pressable>
287
- {item.author?.id === me.id && (
288
- <Pressable onPress={() => remove(item)}>
289
- <Text style={styles.deleteBtn}>Delete</Text>
290
- </Pressable>
291
- )}
292
- </View>
293
- </View>
294
- )}
197
+ renderItem={({ item }) => <Row item={item} myId={me.id} />}
295
198
  />
296
199
  )}
200
+ </SafeAreaView>
201
+ );
202
+ }
203
+
204
+ function Row({
205
+ item,
206
+ myId,
207
+ }: {
208
+ item: {
209
+ post: Post;
210
+ author: Profile | null;
211
+ likeCount: number;
212
+ likedByMe: boolean;
213
+ };
214
+ myId: string;
215
+ }) {
216
+ async function toggleLike() {
217
+ try {
218
+ await callFn("toggleLike", { postId: item.post.id });
219
+ } catch (e) {
220
+ Alert.alert("Like failed", String(e));
221
+ }
222
+ }
223
+ async function remove() {
224
+ try {
225
+ await callFn("deletePost", { id: item.post.id });
226
+ } catch (e) {
227
+ Alert.alert("Delete failed", String(e));
228
+ }
229
+ }
230
+ return (
231
+ <View style={styles.post}>
232
+ <View style={styles.postHead}>
233
+ <Text style={styles.postName}>
234
+ {item.author?.displayName ?? "Unknown"}
235
+ </Text>
236
+ <Text style={styles.postHandle}>@{item.author?.handle ?? "?"}</Text>
237
+ </View>
238
+ <Text style={styles.postBody}>{item.post.body}</Text>
239
+ <View style={styles.postFoot}>
240
+ <Pressable onPress={toggleLike}>
241
+ <Text
242
+ style={[styles.likeBtn, item.likedByMe && styles.likeBtnActive]}
243
+ >
244
+ {item.likedByMe ? "♥" : "♡"} {item.likeCount}
245
+ </Text>
246
+ </Pressable>
247
+ {item.author?.id === myId && (
248
+ <Pressable onPress={remove}>
249
+ <Text style={styles.deleteBtn}>Delete</Text>
250
+ </Pressable>
251
+ )}
252
+ </View>
297
253
  </View>
298
254
  );
299
255
  }
300
256
 
257
+ function ProfileSetup({
258
+ existingHandles,
259
+ onSaved,
260
+ }: {
261
+ existingHandles: string[];
262
+ onSaved: (p: Profile) => void;
263
+ }) {
264
+ const [handle, setHandle] = useState("");
265
+ const [displayName, setDisplayName] = useState("");
266
+ const [bio, setBio] = useState("");
267
+ const [saving, setSaving] = useState(false);
268
+
269
+ async function save() {
270
+ const lower = handle.trim().toLowerCase();
271
+ if (existingHandles.includes(lower)) {
272
+ Alert.alert("Handle taken", `@${lower} is already in use.`);
273
+ return;
274
+ }
275
+ setSaving(true);
276
+ try {
277
+ const profile = (await callFn("upsertProfile", {
278
+ handle: lower,
279
+ displayName: displayName.trim(),
280
+ bio: bio.trim(),
281
+ })) as Profile;
282
+ onSaved(profile);
283
+ } catch (e) {
284
+ Alert.alert("Save failed", String(e));
285
+ } finally {
286
+ setSaving(false);
287
+ }
288
+ }
289
+
290
+ return (
291
+ <SafeAreaView style={styles.screen}>
292
+ <StatusBar style="auto" />
293
+ <View style={styles.content}>
294
+ <Text style={styles.title}>__APP_NAME__</Text>
295
+ <Text style={styles.subtitle}>Set up your profile</Text>
296
+ <TextInput
297
+ style={styles.input}
298
+ placeholder="handle (lowercase, 2–20)"
299
+ autoCapitalize="none"
300
+ autoCorrect={false}
301
+ value={handle}
302
+ onChangeText={setHandle}
303
+ />
304
+ <TextInput
305
+ style={styles.input}
306
+ placeholder="Display name"
307
+ value={displayName}
308
+ onChangeText={setDisplayName}
309
+ />
310
+ <TextInput
311
+ style={styles.input}
312
+ placeholder="Bio (optional)"
313
+ value={bio}
314
+ onChangeText={setBio}
315
+ />
316
+ <Pressable
317
+ onPress={save}
318
+ disabled={saving || !handle.trim() || !displayName.trim()}
319
+ style={({ pressed }) => [
320
+ styles.button,
321
+ (saving || !handle.trim() || !displayName.trim()) && styles.buttonDisabled,
322
+ pressed && styles.buttonPressed,
323
+ ]}
324
+ >
325
+ <Text style={styles.buttonLabel}>{saving ? "Saving…" : "Save"}</Text>
326
+ </Pressable>
327
+ </View>
328
+ </SafeAreaView>
329
+ );
330
+ }
331
+
301
332
  const styles = StyleSheet.create({
302
333
  screen: { flex: 1, paddingTop: 64, paddingHorizontal: 20, backgroundColor: "#fff" },
303
334
  center: { alignItems: "center", justifyContent: "center" },
335
+ content: { padding: 20, gap: 12 },
304
336
  title: { fontSize: 28, fontWeight: "600" },
305
- subtitle: { color: "#666", marginTop: 4, marginBottom: 20 },
337
+ subtitle: { color: "#666", marginBottom: 12 },
306
338
  handle: { fontFamily: "Menlo", fontSize: 12, color: "#999" },
307
339
  headerRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 16 },
308
340
  input: {
@@ -1,17 +1,6 @@
1
1
  // swift-tools-version:5.9
2
2
  import PackageDescription
3
3
 
4
- // SwiftPM package for the __APP_NAME__ mobile app.
5
- //
6
- // The executable target runs on macOS via `swift run`. For an iOS
7
- // build, generate an Xcode project from `project.yml`:
8
- //
9
- // brew install xcodegen
10
- // xcodegen generate
11
- // open __APP_NAME_PASCAL__.xcodeproj
12
- //
13
- // The Xcode project pulls the same Sources/__APP_NAME_PASCAL__/ tree
14
- // as `swift build`, so iOS + macOS share one source set.
15
4
  let package = Package(
16
5
  name: "__APP_NAME_PASCAL__",
17
6
  platforms: [
@@ -26,6 +15,7 @@ let package = Package(
26
15
  name: "__APP_NAME_PASCAL__",
27
16
  dependencies: [
28
17
  .product(name: "PylonClient", package: "pylon"),
18
+ .product(name: "PylonSync", package: "pylon"),
29
19
  .product(name: "PylonSwiftUI", package: "pylon"),
30
20
  ],
31
21
  path: "Sources/__APP_NAME_PASCAL__"
@@ -1,15 +1,24 @@
1
1
  import SwiftUI
2
2
  import PylonClient
3
+ import PylonSync
4
+ import PylonSwiftUI
3
5
 
4
- /// Two-pane chat: rooms list → room view. Polls the active room
5
- /// every 1.5s. For realtime, swap `pollMessages()` out for a
6
- /// PylonQuery<Message> from PylonSwiftUI subscribed by roomId.
6
+ /// Two-pane chat: rooms list → room view. Subscribes to Room
7
+ /// inserts/deletes via PylonQuery every new room created on any
8
+ /// device shows up here within ~ms over the WebSocket sync channel.
7
9
  struct ChatRootView: View {
8
10
  @EnvironmentObject var session: AppSession
9
- @State private var rooms: [Room] = []
10
- @State private var loadingRooms = true
11
+ let engine: SyncEngine
12
+ @StateObject private var rooms: PylonQuery<Room>
11
13
  @State private var errorMessage: String?
12
14
 
15
+ init(engine: SyncEngine) {
16
+ self.engine = engine
17
+ _rooms = StateObject(
18
+ wrappedValue: PylonQuery<Room>(engine: engine, entity: "Room"),
19
+ )
20
+ }
21
+
13
22
  var body: some View {
14
23
  NavigationStack {
15
24
  List {
@@ -21,13 +30,11 @@ struct ChatRootView: View {
21
30
  .autocorrectionDisabled()
22
31
  }
23
32
  Section("Rooms") {
24
- if loadingRooms {
25
- ProgressView()
26
- } else if rooms.isEmpty {
27
- Text("No rooms yet. Create one below.")
33
+ if rooms.rows.isEmpty {
34
+ Text("No rooms yet — create one below.")
28
35
  .foregroundStyle(.secondary)
29
36
  } else {
30
- ForEach(rooms) { r in
37
+ ForEach(sortedRooms) { r in
31
38
  NavigationLink(value: r) {
32
39
  VStack(alignment: .leading) {
33
40
  Text(r.name)
@@ -54,38 +61,31 @@ struct ChatRootView: View {
54
61
  }
55
62
  .navigationTitle("__APP_NAME__")
56
63
  .navigationDestination(for: Room.self) { room in
57
- RoomView(room: room)
64
+ RoomView(room: room, engine: engine)
58
65
  }
59
- .task { await loadRooms() }
60
- .refreshable { await loadRooms() }
61
66
  }
62
67
  }
63
68
 
64
- private func loadRooms() async {
65
- loadingRooms = true
66
- defer { loadingRooms = false }
67
- do {
68
- rooms = try await session.pylon.callFn("listRooms", args: EmptyArgs())
69
- errorMessage = nil
70
- } catch {
71
- errorMessage = "Load failed: \(error.localizedDescription)"
72
- }
69
+ private var sortedRooms: [Room] {
70
+ rooms.rows.sorted { $0.createdAt < $1.createdAt }
73
71
  }
74
72
 
75
73
  private func createRoom() async {
76
- let alert = await prompt(title: "Create room", message: "Room name?")
77
- guard let name = alert?.trimmingCharacters(in: .whitespaces),
74
+ let name = await prompt(title: "Create room", message: "Room name?")
75
+ guard let name = name?.trimmingCharacters(in: .whitespaces),
78
76
  !name.isEmpty
79
77
  else { return }
80
78
  let slug = name.lowercased()
81
79
  .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression)
82
80
  .trimmingCharacters(in: CharacterSet(charactersIn: "-"))
83
81
  do {
84
- let room: Room = try await session.pylon.callFn(
82
+ // Server validates + writes via ctx.db.insert. The change_event
83
+ // flows back through the SyncEngine and PylonQuery picks up
84
+ // the new Room without us touching local state.
85
+ let _: Room = try await session.client.callFn(
85
86
  "createRoom",
86
87
  args: CreateRoomArgs(slug: slug, name: name),
87
88
  )
88
- rooms.append(room)
89
89
  } catch {
90
90
  errorMessage = "Create failed: \(error.localizedDescription)"
91
91
  }
@@ -93,10 +93,6 @@ struct ChatRootView: View {
93
93
 
94
94
  @MainActor
95
95
  private func prompt(title: String, message: String) async -> String? {
96
- // Minimal SwiftUI prompt — for production replace with an
97
- // in-tree alert + TextField sheet. The scaffold uses a tiny
98
- // UIKit detour on iOS so the demo works without a custom
99
- // modal implementation.
100
96
  #if canImport(UIKit)
101
97
  await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in
102
98
  let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)