@pylonsync/create-pylon 0.3.53 → 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 (129) hide show
  1. package/bin/create-pylon.js +98 -42
  2. package/package.json +1 -1
  3. package/templates/backend/b2b/apps/api/functions/archiveProject.ts +15 -0
  4. package/templates/backend/b2b/apps/api/functions/createOrg.ts +43 -0
  5. package/templates/backend/b2b/apps/api/functions/createProject.ts +25 -0
  6. package/templates/backend/b2b/apps/api/functions/inviteMember.ts +49 -0
  7. package/templates/backend/b2b/apps/api/functions/myOrgs.ts +37 -0
  8. package/templates/backend/b2b/apps/api/functions/orgMembers.ts +13 -0
  9. package/templates/backend/b2b/apps/api/functions/orgProjects.ts +18 -0
  10. package/templates/backend/b2b/apps/api/functions/removeMember.ts +29 -0
  11. package/templates/backend/b2b/apps/api/functions/setMemberRole.ts +38 -0
  12. package/templates/backend/b2b/apps/api/package.json +20 -0
  13. package/templates/backend/b2b/apps/api/schema.ts +171 -0
  14. package/templates/backend/b2b/apps/api/tsconfig.json +13 -0
  15. package/templates/backend/chat/apps/api/functions/createRoom.ts +32 -0
  16. package/templates/backend/chat/apps/api/functions/listRooms.ts +15 -0
  17. package/templates/backend/chat/apps/api/functions/roomMessages.ts +20 -0
  18. package/templates/backend/chat/apps/api/functions/sendMessage.ts +37 -0
  19. package/templates/backend/chat/apps/api/package.json +20 -0
  20. package/templates/backend/chat/apps/api/schema.ts +93 -0
  21. package/templates/backend/chat/apps/api/tsconfig.json +13 -0
  22. package/templates/backend/consumer/apps/api/functions/createPost.ts +48 -0
  23. package/templates/backend/consumer/apps/api/functions/deletePost.ts +21 -0
  24. package/templates/backend/consumer/apps/api/functions/feed.ts +57 -0
  25. package/templates/backend/consumer/apps/api/functions/myProfile.ts +17 -0
  26. package/templates/backend/consumer/apps/api/functions/profilePosts.ts +17 -0
  27. package/templates/backend/consumer/apps/api/functions/toggleLike.ts +48 -0
  28. package/templates/backend/consumer/apps/api/functions/upsertProfile.ts +70 -0
  29. package/templates/backend/consumer/apps/api/package.json +20 -0
  30. package/templates/backend/consumer/apps/api/schema.ts +130 -0
  31. package/templates/backend/consumer/apps/api/tsconfig.json +13 -0
  32. package/templates/expo/chat/apps/expo/App.tsx +384 -0
  33. package/templates/expo/chat/apps/expo/app.json +25 -0
  34. package/templates/expo/chat/apps/expo/babel.config.js +6 -0
  35. package/templates/expo/chat/apps/expo/package.json +30 -0
  36. package/templates/expo/chat/apps/expo/tsconfig.json +16 -0
  37. package/templates/expo/consumer/apps/expo/App.tsx +392 -0
  38. package/templates/expo/consumer/apps/expo/app.json +25 -0
  39. package/templates/expo/consumer/apps/expo/babel.config.js +6 -0
  40. package/templates/expo/consumer/apps/expo/package.json +30 -0
  41. package/templates/expo/consumer/apps/expo/tsconfig.json +16 -0
  42. package/templates/ios/chat/apps/ios/Package.swift +24 -0
  43. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +116 -0
  44. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  45. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  46. package/templates/ios/chat/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  47. package/templates/ios/chat/apps/ios/project.yml +44 -0
  48. package/templates/ios/consumer/apps/ios/Package.swift +24 -0
  49. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  50. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  51. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  52. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  53. package/templates/ios/consumer/apps/ios/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +57 -0
  54. package/templates/ios/consumer/apps/ios/project.yml +44 -0
  55. package/templates/mac/b2b/apps/mac/Package.swift +22 -0
  56. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +15 -0
  57. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/OrgPickerView.swift +178 -0
  58. package/templates/mac/b2b/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  59. package/templates/mac/b2b/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  60. package/templates/mac/b2b/apps/mac/project.yml +34 -0
  61. package/templates/mac/barebones/apps/mac/Package.swift +33 -0
  62. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/ContentView.swift +104 -0
  63. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +17 -0
  64. package/templates/mac/barebones/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  65. package/templates/mac/barebones/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  66. package/templates/mac/barebones/apps/mac/project.yml +34 -0
  67. package/templates/mac/chat/apps/mac/Package.swift +34 -0
  68. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/ChatRootView.swift +143 -0
  69. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +26 -0
  70. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/RoomView.swift +136 -0
  71. package/templates/mac/chat/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +58 -0
  72. package/templates/mac/chat/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  73. package/templates/mac/chat/apps/mac/project.yml +36 -0
  74. package/templates/mac/consumer/apps/mac/Package.swift +34 -0
  75. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/FeedView.swift +199 -0
  76. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +34 -0
  77. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/ProfileSetupView.swift +68 -0
  78. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/RootView.swift +40 -0
  79. package/templates/mac/consumer/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +59 -0
  80. package/templates/mac/consumer/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  81. package/templates/mac/consumer/apps/mac/project.yml +36 -0
  82. package/templates/mac/todo/apps/mac/Package.swift +33 -0
  83. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/Models.swift +19 -0
  84. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/TodoListView.swift +244 -0
  85. package/templates/mac/todo/apps/mac/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +30 -0
  86. package/templates/mac/todo/apps/mac/__APP_NAME_PASCAL__.entitlements +13 -0
  87. package/templates/mac/todo/apps/mac/project.yml +34 -0
  88. package/templates/web/b2b/apps/web/next-env.d.ts +2 -0
  89. package/templates/web/b2b/apps/web/next.config.ts +24 -0
  90. package/templates/web/b2b/apps/web/package.json +29 -0
  91. package/templates/web/b2b/apps/web/postcss.config.mjs +3 -0
  92. package/templates/web/b2b/apps/web/src/app/components/OrgPicker.tsx +171 -0
  93. package/templates/web/b2b/apps/web/src/app/globals.css +6 -0
  94. package/templates/web/b2b/apps/web/src/app/layout.tsx +21 -0
  95. package/templates/web/b2b/apps/web/src/app/page.tsx +39 -0
  96. package/templates/web/b2b/apps/web/src/lib/pylon.ts +5 -0
  97. package/templates/web/b2b/apps/web/tsconfig.json +26 -0
  98. package/templates/web/chat/apps/web/next-env.d.ts +2 -0
  99. package/templates/web/chat/apps/web/next.config.ts +24 -0
  100. package/templates/web/chat/apps/web/package.json +29 -0
  101. package/templates/web/chat/apps/web/postcss.config.mjs +3 -0
  102. package/templates/web/chat/apps/web/src/app/components/ChatRoom.tsx +231 -0
  103. package/templates/web/chat/apps/web/src/app/globals.css +6 -0
  104. package/templates/web/chat/apps/web/src/app/layout.tsx +22 -0
  105. package/templates/web/chat/apps/web/src/app/page.tsx +12 -0
  106. package/templates/web/chat/apps/web/src/app/providers.tsx +25 -0
  107. package/templates/web/chat/apps/web/src/lib/pylon.ts +5 -0
  108. package/templates/web/chat/apps/web/tsconfig.json +26 -0
  109. package/templates/web/consumer/apps/web/next-env.d.ts +2 -0
  110. package/templates/web/consumer/apps/web/next.config.ts +24 -0
  111. package/templates/web/consumer/apps/web/package.json +29 -0
  112. package/templates/web/consumer/apps/web/postcss.config.mjs +3 -0
  113. package/templates/web/consumer/apps/web/src/app/components/Feed.tsx +315 -0
  114. package/templates/web/consumer/apps/web/src/app/globals.css +6 -0
  115. package/templates/web/consumer/apps/web/src/app/layout.tsx +22 -0
  116. package/templates/web/consumer/apps/web/src/app/page.tsx +21 -0
  117. package/templates/web/consumer/apps/web/src/app/providers.tsx +25 -0
  118. package/templates/web/consumer/apps/web/src/lib/pylon.ts +5 -0
  119. package/templates/web/consumer/apps/web/tsconfig.json +26 -0
  120. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Package.swift +0 -0
  121. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/ContentView.swift +0 -0
  122. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  123. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  124. /package/templates/{mobile/barebones/apps/mobile → ios/barebones/apps/ios}/project.yml +0 -0
  125. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Package.swift +0 -0
  126. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/Models.swift +0 -0
  127. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/TodoListView.swift +0 -0
  128. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/Sources/__APP_NAME_PASCAL__/__APP_NAME_PASCAL__App.swift +0 -0
  129. /package/templates/{mobile/todo/apps/mobile → ios/todo/apps/ios}/project.yml +0 -0
@@ -0,0 +1,70 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ /**
4
+ * Create-or-update the caller's Profile. Handle is unique (case-
5
+ * insensitive); we lowercase + check before inserting. On update
6
+ * we don't re-check the handle if it didn't change.
7
+ */
8
+ export default mutation({
9
+ args: {
10
+ handle: v.string(),
11
+ displayName: v.string(),
12
+ bio: v.string(),
13
+ },
14
+ async handler(
15
+ ctx,
16
+ args: { handle: string; displayName: string; bio: string },
17
+ ) {
18
+ if (!ctx.auth.userId) {
19
+ throw ctx.error("UNAUTHENTICATED", "log in first");
20
+ }
21
+ const handle = args.handle.trim().toLowerCase();
22
+ if (!/^[a-z0-9_]{2,20}$/.test(handle)) {
23
+ throw ctx.error(
24
+ "INVALID_HANDLE",
25
+ "handle must be 2–20 chars: lowercase letters, digits, underscore",
26
+ );
27
+ }
28
+ const displayName = args.displayName.trim();
29
+ if (!displayName) {
30
+ throw ctx.error("EMPTY_NAME", "displayName cannot be empty");
31
+ }
32
+
33
+ const existing = (await ctx.db.query("Profile", {
34
+ userId: ctx.auth.userId,
35
+ })) as any[];
36
+
37
+ if (existing.length === 0) {
38
+ // New profile — handle must be unique
39
+ const dup = (await ctx.db.query("Profile", { handle })) as any[];
40
+ if (dup.length > 0) {
41
+ throw ctx.error("HANDLE_TAKEN", `@${handle} is taken`);
42
+ }
43
+ const id = await ctx.db.insert("Profile", {
44
+ userId: ctx.auth.userId,
45
+ handle,
46
+ displayName,
47
+ bio: args.bio.trim() || null,
48
+ createdAt: new Date().toISOString(),
49
+ });
50
+ return await ctx.db.get("Profile", id);
51
+ }
52
+
53
+ const profile = existing[0];
54
+ // Update — re-check handle uniqueness only if changing
55
+ if (profile.handle !== handle) {
56
+ const dup = ((await ctx.db.query("Profile", { handle })) as any[]).filter(
57
+ (p) => p.id !== profile.id,
58
+ );
59
+ if (dup.length > 0) {
60
+ throw ctx.error("HANDLE_TAKEN", `@${handle} is taken`);
61
+ }
62
+ }
63
+ await ctx.db.update("Profile", profile.id, {
64
+ handle,
65
+ displayName,
66
+ bio: args.bio.trim() || null,
67
+ });
68
+ return await ctx.db.get("Profile", profile.id);
69
+ },
70
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@__APP_NAME_KEBAB__/api",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "pylon dev schema.ts --port 4321",
8
+ "build": "pylon codegen schema.ts --out pylon.manifest.json && pylon codegen client pylon.manifest.json --out pylon.client.ts",
9
+ "schema:push": "pylon schema push pylon.manifest.json --sqlite dev.db",
10
+ "schema:inspect": "pylon schema inspect --sqlite dev.db"
11
+ },
12
+ "dependencies": {
13
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
14
+ "@pylonsync/functions": "^__PYLON_VERSION__"
15
+ },
16
+ "devDependencies": {
17
+ "@pylonsync/cli": "^__PYLON_VERSION__",
18
+ "typescript": "^5.5.0"
19
+ }
20
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ query,
5
+ action,
6
+ policy,
7
+ buildManifest,
8
+ } from "@pylonsync/sdk";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Consumer feed schema. The shape:
12
+ //
13
+ // Profile — public-facing card per User. Handle, display name, bio.
14
+ // Post — one row per post. authorId references the Profile that
15
+ // created it; body holds the text.
16
+ // Like — pivot row: which Profile liked which Post. Composite
17
+ // uniqueness enforced via the ordering of inserts (one
18
+ // row per (profileId, postId) pair).
19
+ //
20
+ // Reads are wide-open by design — feeds are public. Writes require
21
+ // the caller to own the Profile in question (auth.userId == profileId
22
+ // for Profile updates; auth.userId must own a Profile to post).
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const Profile = entity("Profile", {
26
+ userId: field.id("User"),
27
+ handle: field.string(),
28
+ displayName: field.string(),
29
+ bio: field.string().optional(),
30
+ createdAt: field.datetime(),
31
+ });
32
+
33
+ const Post = entity("Post", {
34
+ authorId: field.id("Profile"),
35
+ body: field.string(),
36
+ createdAt: field.datetime(),
37
+ });
38
+
39
+ const Like = entity("Like", {
40
+ profileId: field.id("Profile"),
41
+ postId: field.id("Post"),
42
+ createdAt: field.datetime(),
43
+ });
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Function declarations
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const myProfile = query("myProfile");
50
+ const feed = query("feed");
51
+ const profilePosts = query("profilePosts");
52
+
53
+ const upsertProfile = action("upsertProfile", {
54
+ input: [
55
+ { name: "handle", type: "string" },
56
+ { name: "displayName", type: "string" },
57
+ { name: "bio", type: "string" },
58
+ ],
59
+ });
60
+
61
+ const createPost = action("createPost", {
62
+ input: [{ name: "body", type: "string" }],
63
+ });
64
+
65
+ const deletePost = action("deletePost", {
66
+ input: [{ name: "id", type: "id(Post)" }],
67
+ });
68
+
69
+ const toggleLike = action("toggleLike", {
70
+ input: [{ name: "postId", type: "id(Post)" }],
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Policies — public reads, owner-only writes.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const profilePolicy = policy({
78
+ name: "profile_public",
79
+ entity: "Profile",
80
+ allowRead: "true",
81
+ allowInsert: "auth.userId == data.userId",
82
+ allowUpdate: "auth.userId == data.userId",
83
+ allowDelete: "auth.userId == data.userId",
84
+ });
85
+
86
+ const postPolicy = policy({
87
+ name: "post_public",
88
+ entity: "Post",
89
+ allowRead: "true",
90
+ // Caller must own a Profile that's being claimed as the author.
91
+ allowInsert:
92
+ "exists(Profile where id = data.authorId and userId = auth.userId)",
93
+ allowUpdate:
94
+ "exists(Profile where id = data.authorId and userId = auth.userId)",
95
+ allowDelete:
96
+ "exists(Profile where id = data.authorId and userId = auth.userId)",
97
+ });
98
+
99
+ const likePolicy = policy({
100
+ name: "like_public",
101
+ entity: "Like",
102
+ allowRead: "true",
103
+ // Caller must own the Profile doing the liking.
104
+ allowInsert:
105
+ "exists(Profile where id = data.profileId and userId = auth.userId)",
106
+ allowUpdate: "false",
107
+ allowDelete:
108
+ "exists(Profile where id = data.profileId and userId = auth.userId)",
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Manifest
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const manifest = buildManifest({
116
+ name: "__APP_NAME_SNAKE__",
117
+ version: "0.0.1",
118
+ entities: [Profile, Post, Like],
119
+ queries: [myProfile, feed, profilePosts],
120
+ actions: [
121
+ upsertProfile,
122
+ createPost,
123
+ deletePost,
124
+ toggleLike,
125
+ ],
126
+ policies: [profilePolicy, postPolicy, likePolicy],
127
+ routes: [],
128
+ });
129
+
130
+ console.log(JSON.stringify(manifest));
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "allowSyntheticDefaultImports": true
11
+ },
12
+ "include": ["schema.ts", "functions/**/*.ts"]
13
+ }
@@ -0,0 +1,384 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ Pressable,
7
+ FlatList,
8
+ ActivityIndicator,
9
+ StyleSheet,
10
+ Platform,
11
+ Alert,
12
+ KeyboardAvoidingView,
13
+ SafeAreaView,
14
+ } from "react-native";
15
+ import { StatusBar } from "expo-status-bar";
16
+ import { init, db, callFn } from "@pylonsync/react-native";
17
+
18
+ const PYLON_BASE_URL =
19
+ process.env.EXPO_PUBLIC_PYLON_BASE_URL ??
20
+ (Platform.OS === "android" ? "http://10.0.2.2:4321" : "http://localhost:4321");
21
+
22
+ type Room = {
23
+ id: string;
24
+ slug: string;
25
+ name: string;
26
+ createdAt: string;
27
+ };
28
+
29
+ type Message = {
30
+ id: string;
31
+ roomId: string;
32
+ authorId: string;
33
+ authorName: string;
34
+ body: string;
35
+ createdAt: string;
36
+ };
37
+
38
+ let initPromise: Promise<void> | null = null;
39
+ function ensureInit() {
40
+ if (!initPromise) {
41
+ initPromise = init({
42
+ baseUrl: PYLON_BASE_URL,
43
+ appName: "__APP_NAME_SNAKE__",
44
+ });
45
+ }
46
+ return initPromise;
47
+ }
48
+
49
+ export default function App() {
50
+ const [ready, setReady] = useState(false);
51
+ useEffect(() => {
52
+ ensureInit().then(() => setReady(true));
53
+ }, []);
54
+ if (!ready) {
55
+ return (
56
+ <View style={[styles.screen, styles.center]}>
57
+ <ActivityIndicator />
58
+ </View>
59
+ );
60
+ }
61
+ return <Chat />;
62
+ }
63
+
64
+ function Chat() {
65
+ // Live subscriptions — sync engine pushes diffs over WebSocket.
66
+ // New rooms / new messages from any other device or device update
67
+ // re-render this component without polling.
68
+ const { data: rooms = [] } = db.useQuery<Room>("Room", {
69
+ orderBy: { createdAt: "asc" },
70
+ });
71
+ const [activeId, setActiveId] = useState<string | null>(null);
72
+ useEffect(() => {
73
+ if (!activeId && rooms.length > 0) setActiveId(rooms[0].id);
74
+ }, [rooms, activeId]);
75
+
76
+ const active = rooms.find((r) => r.id === activeId) ?? null;
77
+
78
+ const { data: messages = [] } = db.useQuery<Message>("Message", {
79
+ where: activeId ? { roomId: activeId } : undefined,
80
+ orderBy: { createdAt: "asc" },
81
+ limit: 200,
82
+ });
83
+
84
+ const [draft, setDraft] = useState("");
85
+ const [sending, setSending] = useState(false);
86
+ const [authorName, setAuthorName] = useState("anonymous");
87
+ const listRef = useRef<FlatList<Message>>(null);
88
+
89
+ useEffect(() => {
90
+ if (messages.length > 0) {
91
+ listRef.current?.scrollToEnd({ animated: true });
92
+ }
93
+ }, [messages.length, activeId]);
94
+
95
+ if (!active) {
96
+ return (
97
+ <RoomCreate
98
+ authorName={authorName}
99
+ onAuthorNameChange={setAuthorName}
100
+ onCreated={(r) => setActiveId(r.id)}
101
+ />
102
+ );
103
+ }
104
+
105
+ async function send() {
106
+ const body = draft.trim();
107
+ if (!body) return;
108
+ setDraft("");
109
+ setSending(true);
110
+ try {
111
+ await callFn("sendMessage", {
112
+ roomId: active.id,
113
+ body,
114
+ authorName,
115
+ });
116
+ } catch (e) {
117
+ setDraft(body);
118
+ Alert.alert("Send failed", String(e));
119
+ } finally {
120
+ setSending(false);
121
+ }
122
+ }
123
+
124
+ async function createRoom() {
125
+ const name = window?.prompt?.("Room name?") ?? null;
126
+ if (!name) return;
127
+ const slug = name
128
+ .toLowerCase()
129
+ .replace(/[^a-z0-9]+/g, "-")
130
+ .replace(/^-|-$/g, "");
131
+ try {
132
+ const r = (await callFn("createRoom", { slug, name })) as Room;
133
+ setActiveId(r.id);
134
+ } catch (e) {
135
+ Alert.alert("Create failed", String(e));
136
+ }
137
+ }
138
+
139
+ return (
140
+ <SafeAreaView style={styles.screen}>
141
+ <StatusBar style="auto" />
142
+ <KeyboardAvoidingView
143
+ style={styles.flex}
144
+ behavior={Platform.OS === "ios" ? "padding" : undefined}
145
+ keyboardVerticalOffset={64}
146
+ >
147
+ <View style={styles.header}>
148
+ <View>
149
+ <Text style={styles.title}>{active.name}</Text>
150
+ <Text style={styles.handle}>#{active.slug}</Text>
151
+ </View>
152
+ {rooms.length > 1 && (
153
+ <Pressable
154
+ onPress={() => {
155
+ const idx = rooms.findIndex((r) => r.id === active.id);
156
+ setActiveId(rooms[(idx + 1) % rooms.length].id);
157
+ }}
158
+ >
159
+ <Text style={styles.switchBtn}>Next room →</Text>
160
+ </Pressable>
161
+ )}
162
+ </View>
163
+
164
+ <View style={styles.namebar}>
165
+ <Text style={styles.namelabel}>You:</Text>
166
+ <TextInput
167
+ style={styles.nameinput}
168
+ value={authorName}
169
+ onChangeText={setAuthorName}
170
+ autoCapitalize="none"
171
+ />
172
+ </View>
173
+
174
+ <FlatList
175
+ ref={listRef}
176
+ data={messages}
177
+ keyExtractor={(m) => m.id}
178
+ contentContainerStyle={{ padding: 16 }}
179
+ ListEmptyComponent={() => (
180
+ <Text style={styles.empty}>No messages yet. Say hi.</Text>
181
+ )}
182
+ renderItem={({ item }) => (
183
+ <View style={styles.msg}>
184
+ <View style={styles.msgHead}>
185
+ <Text style={styles.msgName}>{item.authorName}</Text>
186
+ <Text style={styles.msgTime}>
187
+ {new Date(item.createdAt).toLocaleTimeString(undefined, {
188
+ hour: "numeric",
189
+ minute: "2-digit",
190
+ })}
191
+ </Text>
192
+ </View>
193
+ <Text style={styles.msgBody}>{item.body}</Text>
194
+ </View>
195
+ )}
196
+ />
197
+
198
+ <View style={styles.composer}>
199
+ <TextInput
200
+ style={styles.composerInput}
201
+ value={draft}
202
+ onChangeText={setDraft}
203
+ placeholder={`Message ${active.name}…`}
204
+ multiline
205
+ editable={!sending}
206
+ />
207
+ <Pressable
208
+ onPress={send}
209
+ disabled={sending || !draft.trim()}
210
+ style={({ pressed }) => [
211
+ styles.button,
212
+ (sending || !draft.trim()) && styles.buttonDisabled,
213
+ pressed && styles.buttonPressed,
214
+ ]}
215
+ >
216
+ <Text style={styles.buttonLabel}>Send</Text>
217
+ </Pressable>
218
+ </View>
219
+
220
+ <Pressable onPress={createRoom} style={styles.newRoomBar}>
221
+ <Text style={styles.newRoomLabel}>+ New room</Text>
222
+ </Pressable>
223
+ </KeyboardAvoidingView>
224
+ </SafeAreaView>
225
+ );
226
+ }
227
+
228
+ function RoomCreate({
229
+ authorName,
230
+ onAuthorNameChange,
231
+ onCreated,
232
+ }: {
233
+ authorName: string;
234
+ onAuthorNameChange: (s: string) => void;
235
+ onCreated: (r: Room) => void;
236
+ }) {
237
+ const [name, setName] = useState("General");
238
+ const [slug, setSlug] = useState("general");
239
+ const [creating, setCreating] = useState(false);
240
+
241
+ async function create() {
242
+ setCreating(true);
243
+ try {
244
+ const r = (await callFn("createRoom", {
245
+ slug: slug.toLowerCase(),
246
+ name,
247
+ })) as Room;
248
+ onCreated(r);
249
+ } catch (e) {
250
+ Alert.alert("Create failed", String(e));
251
+ } finally {
252
+ setCreating(false);
253
+ }
254
+ }
255
+
256
+ return (
257
+ <SafeAreaView style={styles.screen}>
258
+ <StatusBar style="auto" />
259
+ <View style={styles.content}>
260
+ <Text style={styles.title}>__APP_NAME__</Text>
261
+ <Text style={styles.subtitle}>No rooms yet — create one.</Text>
262
+ <TextInput
263
+ style={styles.input}
264
+ placeholder="Your display name"
265
+ value={authorName}
266
+ onChangeText={onAuthorNameChange}
267
+ />
268
+ <TextInput
269
+ style={styles.input}
270
+ placeholder="Room name"
271
+ value={name}
272
+ onChangeText={setName}
273
+ />
274
+ <TextInput
275
+ style={styles.input}
276
+ placeholder="Slug"
277
+ value={slug}
278
+ onChangeText={(s) => setSlug(s.toLowerCase())}
279
+ autoCapitalize="none"
280
+ autoCorrect={false}
281
+ />
282
+ <Pressable
283
+ onPress={create}
284
+ disabled={creating || !name.trim() || !slug.trim()}
285
+ style={({ pressed }) => [
286
+ styles.button,
287
+ (creating || !name.trim() || !slug.trim()) && styles.buttonDisabled,
288
+ pressed && styles.buttonPressed,
289
+ ]}
290
+ >
291
+ <Text style={styles.buttonLabel}>
292
+ {creating ? "Creating…" : "Create room"}
293
+ </Text>
294
+ </Pressable>
295
+ </View>
296
+ </SafeAreaView>
297
+ );
298
+ }
299
+
300
+ const styles = StyleSheet.create({
301
+ screen: { flex: 1, backgroundColor: "#fff" },
302
+ flex: { flex: 1 },
303
+ center: { alignItems: "center", justifyContent: "center" },
304
+ content: { padding: 20, gap: 12 },
305
+ title: { fontSize: 24, fontWeight: "600" },
306
+ subtitle: { color: "#666" },
307
+ handle: { fontFamily: "Menlo", fontSize: 12, color: "#999" },
308
+ header: {
309
+ paddingHorizontal: 20,
310
+ paddingTop: 12,
311
+ paddingBottom: 8,
312
+ flexDirection: "row",
313
+ justifyContent: "space-between",
314
+ alignItems: "flex-end",
315
+ borderBottomWidth: 1,
316
+ borderColor: "#e5e5e5",
317
+ },
318
+ switchBtn: { color: "#3b82f6", fontSize: 13 },
319
+ namebar: {
320
+ flexDirection: "row",
321
+ alignItems: "center",
322
+ gap: 6,
323
+ paddingHorizontal: 20,
324
+ paddingVertical: 6,
325
+ backgroundColor: "#fafafa",
326
+ },
327
+ namelabel: { fontSize: 12, color: "#666" },
328
+ nameinput: {
329
+ flex: 1,
330
+ borderBottomWidth: 1,
331
+ borderColor: "#d4d4d8",
332
+ fontSize: 13,
333
+ paddingVertical: 2,
334
+ },
335
+ input: {
336
+ borderWidth: 1,
337
+ borderColor: "#d4d4d8",
338
+ borderRadius: 6,
339
+ paddingHorizontal: 12,
340
+ paddingVertical: 8,
341
+ fontSize: 14,
342
+ },
343
+ button: {
344
+ backgroundColor: "#171717",
345
+ borderRadius: 6,
346
+ paddingHorizontal: 16,
347
+ paddingVertical: 12,
348
+ alignItems: "center",
349
+ },
350
+ buttonDisabled: { opacity: 0.5 },
351
+ buttonPressed: { opacity: 0.8 },
352
+ buttonLabel: { color: "#fff", fontWeight: "600", fontSize: 13 },
353
+ empty: { textAlign: "center", color: "#999", marginTop: 32 },
354
+ msg: { marginBottom: 16 },
355
+ msgHead: { flexDirection: "row", alignItems: "baseline", gap: 6 },
356
+ msgName: { fontSize: 13, fontWeight: "500" },
357
+ msgTime: { fontSize: 11, color: "#999" },
358
+ msgBody: { fontSize: 14, marginTop: 2 },
359
+ composer: {
360
+ flexDirection: "row",
361
+ gap: 8,
362
+ padding: 12,
363
+ borderTopWidth: 1,
364
+ borderColor: "#e5e5e5",
365
+ },
366
+ composerInput: {
367
+ flex: 1,
368
+ minHeight: 36,
369
+ maxHeight: 120,
370
+ borderWidth: 1,
371
+ borderColor: "#d4d4d8",
372
+ borderRadius: 6,
373
+ paddingHorizontal: 12,
374
+ paddingVertical: 8,
375
+ fontSize: 14,
376
+ },
377
+ newRoomBar: {
378
+ paddingVertical: 10,
379
+ alignItems: "center",
380
+ borderTopWidth: 1,
381
+ borderColor: "#e5e5e5",
382
+ },
383
+ newRoomLabel: { color: "#3b82f6", fontSize: 13 },
384
+ });
@@ -0,0 +1,25 @@
1
+ {
2
+ "expo": {
3
+ "name": "__APP_NAME__",
4
+ "slug": "__APP_NAME_KEBAB__",
5
+ "version": "0.0.1",
6
+ "orientation": "portrait",
7
+ "userInterfaceStyle": "automatic",
8
+ "ios": {
9
+ "supportsTablet": true,
10
+ "bundleIdentifier": "com.example.__APP_NAME_SNAKE__",
11
+ "infoPlist": {
12
+ "NSAppTransportSecurity": {
13
+ "NSAllowsLocalNetworking": true
14
+ }
15
+ }
16
+ },
17
+ "android": {
18
+ "package": "com.example.__APP_NAME_SNAKE__",
19
+ "usesCleartextTraffic": true
20
+ },
21
+ "web": {
22
+ "bundler": "metro"
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = function (api) {
2
+ api.cache(true);
3
+ return {
4
+ presets: ["babel-preset-expo"],
5
+ };
6
+ };
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@__APP_NAME_KEBAB__/expo",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "main": "node_modules/expo/AppEntry.js",
6
+ "scripts": {
7
+ "start": "expo start",
8
+ "android": "expo start --android",
9
+ "ios": "expo start --ios",
10
+ "web": "expo start --web"
11
+ },
12
+ "dependencies": {
13
+ "@pylonsync/sdk": "^__PYLON_VERSION__",
14
+ "@pylonsync/react": "^__PYLON_VERSION__",
15
+ "@pylonsync/react-native": "^__PYLON_VERSION__",
16
+ "@pylonsync/sync": "^__PYLON_VERSION__",
17
+ "@react-native-async-storage/async-storage": "1.23.1",
18
+ "@react-native-community/netinfo": "11.3.1",
19
+ "expo": "~51.0.0",
20
+ "expo-status-bar": "~1.12.1",
21
+ "react": "19.0.0",
22
+ "react-dom": "19.0.0",
23
+ "react-native": "0.74.5"
24
+ },
25
+ "devDependencies": {
26
+ "@babel/core": "^7.20.0",
27
+ "@types/react": "^19.0.0",
28
+ "typescript": "^5.5.0"
29
+ }
30
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["esnext", "dom"],
7
+ "jsx": "react-native",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "isolatedModules": true,
12
+ "resolveJsonModule": true,
13
+ "noEmit": true
14
+ },
15
+ "include": ["App.tsx", "src/**/*.ts", "src/**/*.tsx"]
16
+ }