botschat 0.1.10 → 0.1.13

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 (56) hide show
  1. package/README.md +11 -15
  2. package/migrations/0012_push_tokens.sql +11 -0
  3. package/package.json +20 -1
  4. package/packages/api/src/do/connection-do.ts +142 -24
  5. package/packages/api/src/env.ts +6 -0
  6. package/packages/api/src/index.ts +7 -0
  7. package/packages/api/src/routes/auth.ts +85 -9
  8. package/packages/api/src/routes/channels.ts +3 -2
  9. package/packages/api/src/routes/dev-auth.ts +45 -0
  10. package/packages/api/src/routes/push.ts +52 -0
  11. package/packages/api/src/routes/upload.ts +73 -38
  12. package/packages/api/src/utils/fcm.ts +167 -0
  13. package/packages/api/src/utils/firebase.ts +218 -0
  14. package/packages/plugin/dist/src/channel.d.ts +6 -0
  15. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  16. package/packages/plugin/dist/src/channel.js +71 -15
  17. package/packages/plugin/dist/src/channel.js.map +1 -1
  18. package/packages/plugin/package.json +1 -1
  19. package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
  20. package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
  21. package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
  22. package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
  23. package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
  24. package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
  25. package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
  26. package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
  27. package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
  28. package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
  29. package/packages/web/dist/index.html +6 -4
  30. package/packages/web/dist/sw.js +158 -1
  31. package/packages/web/index.html +4 -2
  32. package/packages/web/package.json +4 -1
  33. package/packages/web/src/App.tsx +117 -1
  34. package/packages/web/src/api.ts +21 -1
  35. package/packages/web/src/components/AccountSettings.tsx +131 -0
  36. package/packages/web/src/components/ChatWindow.tsx +302 -70
  37. package/packages/web/src/components/CronSidebar.tsx +89 -24
  38. package/packages/web/src/components/DataConsentModal.tsx +249 -0
  39. package/packages/web/src/components/LoginPage.tsx +55 -7
  40. package/packages/web/src/components/MessageContent.tsx +71 -9
  41. package/packages/web/src/components/MobileLayout.tsx +28 -118
  42. package/packages/web/src/components/SessionTabs.tsx +41 -2
  43. package/packages/web/src/components/Sidebar.tsx +88 -66
  44. package/packages/web/src/e2e.ts +26 -5
  45. package/packages/web/src/firebase.ts +215 -3
  46. package/packages/web/src/foreground.ts +51 -0
  47. package/packages/web/src/index.css +10 -2
  48. package/packages/web/src/main.tsx +24 -2
  49. package/packages/web/src/push.ts +205 -0
  50. package/packages/web/src/ws.ts +20 -8
  51. package/scripts/dev.sh +158 -26
  52. package/scripts/mock-openclaw.mjs +382 -0
  53. package/scripts/test-e2e-chat.ts +2 -2
  54. package/scripts/test-e2e-live.ts +1 -1
  55. package/wrangler.toml +3 -0
  56. package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
@@ -1,5 +1,7 @@
1
- import React from "react";
1
+ import React, { useState } from "react";
2
2
  import { useAppState, useAppDispatch } from "../store";
3
+ import { tasksApi, channelsApi } from "../api";
4
+ import { dlog } from "../debug-log";
3
5
 
4
6
  function relativeTime(ts: number): string {
5
7
  const now = Date.now() / 1000;
@@ -10,49 +12,112 @@ function relativeTime(ts: number): string {
10
12
  return `${Math.floor(diff / 86400)}d ago`;
11
13
  }
12
14
 
13
- export function CronSidebar() {
15
+ export function CronSidebar({ onNavigate }: { onNavigate?: () => void } = {}) {
14
16
  const state = useAppState();
15
17
  const dispatch = useAppDispatch();
18
+ const [showCreate, setShowCreate] = useState(false);
19
+ const [newName, setNewName] = useState("");
16
20
 
17
21
  const handleSelect = (taskId: string) => {
22
+ // Ensure activeView is "automations" so cron data loads correctly
23
+ if (state.activeView !== "automations") {
24
+ dispatch({ type: "SET_ACTIVE_VIEW", view: "automations" });
25
+ }
18
26
  dispatch({ type: "SELECT_CRON_TASK", taskId });
27
+ onNavigate?.();
28
+ };
29
+
30
+ const handleCreateTask = async () => {
31
+ if (!newName.trim()) return;
32
+ // Find a suitable channel — prefer "Default", then first available
33
+ let channelId = state.channels.find((c) => c.name === "Default")?.id
34
+ ?? state.channels[0]?.id;
35
+ if (!channelId) {
36
+ // Auto-create a "Default" channel if none exist
37
+ try {
38
+ const ch = await channelsApi.create({ name: "Default" });
39
+ channelId = ch.id;
40
+ const { channels } = await channelsApi.list();
41
+ dispatch({ type: "SET_CHANNELS", channels });
42
+ } catch (err) {
43
+ dlog.error("Cron", `Failed to create default channel: ${err}`);
44
+ return;
45
+ }
46
+ }
47
+ try {
48
+ const task = await tasksApi.create(channelId, { name: newName.trim(), kind: "background" });
49
+ dlog.info("Cron", `Created automation: ${task.name} (${task.id})`);
50
+ // Reload cron tasks
51
+ const { tasks } = await tasksApi.listAll("background");
52
+ dispatch({ type: "SET_CRON_TASKS", cronTasks: tasks });
53
+ dispatch({ type: "SELECT_CRON_TASK", taskId: task.id });
54
+ setShowCreate(false);
55
+ setNewName("");
56
+ onNavigate?.();
57
+ } catch (err) {
58
+ dlog.error("Cron", `Failed to create automation: ${err}`);
59
+ }
19
60
  };
20
61
 
21
62
  return (
22
63
  <div
23
- className="flex flex-col h-full"
64
+ className="flex flex-col"
24
65
  style={{ background: "var(--bg-secondary)" }}
25
66
  >
26
- {/* Header */}
27
- <div className="px-4 py-3 flex items-center gap-2">
28
- <span className="text-[--text-sidebar-active] font-bold text-h2 truncate flex-1">
67
+ {/* Header with + button */}
68
+ <div className="w-full flex items-center px-4 py-1.5">
69
+ <button
70
+ className="flex items-center gap-1 text-tiny uppercase tracking-wider text-[--text-sidebar] hover:text-[--text-sidebar-active] transition-colors"
71
+ >
29
72
  Automations
73
+ </button>
74
+ <span className="ml-1.5 text-tiny text-[--text-muted]">
75
+ {state.cronTasks.length > 0 && `${state.cronTasks.length}`}
30
76
  </span>
77
+ <button
78
+ onClick={() => setShowCreate(!showCreate)}
79
+ className="ml-auto p-0.5 rounded transition-colors text-[--text-sidebar] hover:text-[--text-sidebar-active] hover:bg-[--sidebar-hover]"
80
+ title="New automation"
81
+ >
82
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
83
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
84
+ </svg>
85
+ </button>
31
86
  </div>
32
87
 
33
- {/* Connection status */}
34
- <div className="px-4 pb-2">
35
- <div className="flex items-center gap-1.5">
36
- <div
37
- className="w-2 h-2 rounded-full"
38
- style={{ background: state.openclawConnected ? "var(--accent-green)" : "var(--accent-red)" }}
88
+ {/* Inline create automation form */}
89
+ {showCreate && (
90
+ <div className="px-4 py-2 space-y-2">
91
+ <input
92
+ type="text"
93
+ placeholder="Automation name"
94
+ value={newName}
95
+ onChange={(e) => setNewName(e.target.value)}
96
+ onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreateTask()}
97
+ className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
98
+ style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
99
+ autoFocus
39
100
  />
40
- <span className="text-tiny text-[--text-muted]">
41
- {state.openclawConnected ? "OpenClaw connected" : "OpenClaw offline"}
42
- </span>
101
+ <div className="flex gap-2">
102
+ <button
103
+ onClick={handleCreateTask}
104
+ className="flex-1 px-3 py-1.5 text-caption bg-[--bg-active] text-white rounded-sm font-bold hover:brightness-110"
105
+ >
106
+ Create
107
+ </button>
108
+ <button
109
+ onClick={() => { setShowCreate(false); setNewName(""); }}
110
+ className="px-3 py-1.5 text-caption text-[--text-muted] hover:text-[--text-sidebar]"
111
+ >
112
+ Cancel
113
+ </button>
114
+ </div>
43
115
  </div>
44
- </div>
45
-
46
- {/* Task count */}
47
- <div className="px-4 pb-2">
48
- <span className="text-tiny text-[--text-muted]">
49
- {state.cronTasks.length} cron job{state.cronTasks.length !== 1 ? "s" : ""}
50
- </span>
51
- </div>
116
+ )}
52
117
 
53
118
  {/* Job list */}
54
119
  <div className="flex-1 overflow-y-auto sidebar-scroll">
55
- {state.cronTasks.length === 0 ? (
120
+ {state.cronTasks.length === 0 && !showCreate ? (
56
121
  <div className="px-4 py-8 text-center">
57
122
  <svg
58
123
  className="w-10 h-10 mx-auto mb-3"
@@ -0,0 +1,249 @@
1
+ import React, { useState, useEffect } from "react";
2
+
3
+ interface DataConsentModalProps {
4
+ onAccept: () => void;
5
+ }
6
+
7
+ export function DataConsentModal({ onAccept }: DataConsentModalProps) {
8
+ const [scrolledToBottom, setScrolledToBottom] = useState(false);
9
+ const [timerElapsed, setTimerElapsed] = useState(false);
10
+
11
+ useEffect(() => {
12
+ const timer = setTimeout(() => setTimerElapsed(true), 3000);
13
+ return () => clearTimeout(timer);
14
+ }, []);
15
+
16
+ const canAccept = scrolledToBottom || timerElapsed;
17
+
18
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
19
+ const el = e.currentTarget;
20
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 20) {
21
+ setScrolledToBottom(true);
22
+ }
23
+ };
24
+
25
+ return (
26
+ <div
27
+ className="fixed inset-0 flex items-center justify-center z-50 p-4"
28
+ style={{ background: "var(--bg-secondary)" }}
29
+ >
30
+ <div
31
+ className="w-full max-w-lg flex flex-col rounded-md"
32
+ style={{
33
+ background: "var(--bg-surface)",
34
+ boxShadow: "var(--shadow-lg)",
35
+ maxHeight: "90vh",
36
+ }}
37
+ >
38
+ {/* Header */}
39
+ <div className="px-6 pt-6 pb-4 shrink-0">
40
+ <div className="flex items-center gap-3 mb-1">
41
+ <svg className="w-6 h-6 shrink-0" viewBox="0 0 24 24" fill="none" stroke="var(--bg-active)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
42
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
43
+ </svg>
44
+ <h1 className="text-h1 font-bold" style={{ color: "var(--text-primary)" }}>
45
+ How Your Data Works in BotsChat
46
+ </h1>
47
+ </div>
48
+ <p className="text-caption" style={{ color: "var(--text-muted)" }}>
49
+ Please review before continuing
50
+ </p>
51
+ </div>
52
+
53
+ {/* Scrollable content */}
54
+ <div
55
+ className="flex-1 min-h-0 overflow-y-auto px-6"
56
+ onScroll={handleScroll}
57
+ >
58
+ <div className="space-y-5 pb-2">
59
+ {/* Message Relay */}
60
+ <Section
61
+ icon={
62
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
63
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
64
+ </svg>
65
+ }
66
+ title="Message Relay"
67
+ >
68
+ BotsChat acts as a WebSocket relay between your browser and your own
69
+ OpenClaw AI gateway. Messages you send are transmitted through
70
+ BotsChat Cloud to reach your gateway.
71
+ </Section>
72
+
73
+ {/* E2E Encryption */}
74
+ <Section
75
+ icon={
76
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
77
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
78
+ <path d="M7 11V7a5 5 0 0110 0v4" />
79
+ </svg>
80
+ }
81
+ title="End-to-End Encryption"
82
+ >
83
+ When E2E encryption is enabled, the server only stores ciphertext
84
+ it cannot read. Your encryption key never leaves your device.
85
+ </Section>
86
+
87
+ {/* AI Processing */}
88
+ <Section
89
+ icon={
90
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
91
+ <rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
92
+ <rect x="9" y="9" width="6" height="6" />
93
+ <line x1="9" y1="1" x2="9" y2="4" />
94
+ <line x1="15" y1="1" x2="15" y2="4" />
95
+ <line x1="9" y1="20" x2="9" y2="23" />
96
+ <line x1="15" y1="20" x2="15" y2="23" />
97
+ <line x1="20" y1="9" x2="23" y2="9" />
98
+ <line x1="20" y1="14" x2="23" y2="14" />
99
+ <line x1="1" y1="9" x2="4" y2="9" />
100
+ <line x1="1" y1="14" x2="4" y2="14" />
101
+ </svg>
102
+ }
103
+ title="AI Processing"
104
+ >
105
+ AI processing happens on your OpenClaw gateway using AI services
106
+ you configure (such as OpenAI, Anthropic, Google, Azure, etc.).
107
+ BotsChat does not choose or control which AI service processes your
108
+ data.
109
+ </Section>
110
+
111
+ {/* Your API Keys */}
112
+ <Section
113
+ icon={
114
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
115
+ <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
116
+ </svg>
117
+ }
118
+ title="Your API Keys"
119
+ >
120
+ Your API keys are stored on your OpenClaw gateway machine and never
121
+ pass through BotsChat Cloud.
122
+ </Section>
123
+
124
+ {/* Third-Party Services */}
125
+ <Section
126
+ icon={
127
+ <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
128
+ <circle cx="12" cy="12" r="10" />
129
+ <line x1="2" y1="12" x2="22" y2="12" />
130
+ <path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
131
+ </svg>
132
+ }
133
+ title="Third-Party Services"
134
+ >
135
+ BotsChat Cloud uses Cloudflare for hosting, database, and media
136
+ storage. Authentication is provided by Google and GitHub OAuth. No
137
+ data is sold or shared with advertisers.
138
+ </Section>
139
+
140
+ {/* What You Agree To */}
141
+ <div
142
+ className="rounded-md p-4"
143
+ style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
144
+ >
145
+ <h3 className="text-body font-bold mb-3" style={{ color: "var(--text-primary)" }}>
146
+ What You Agree To
147
+ </h3>
148
+ <ul className="space-y-2">
149
+ <AgreementItem>
150
+ Messages sent through BotsChat may be processed by third-party
151
+ AI services configured in your OpenClaw gateway
152
+ </AgreementItem>
153
+ <AgreementItem>
154
+ Your chat data is stored on Cloudflare infrastructure
155
+ </AgreementItem>
156
+ <AgreementItem>
157
+ You can enable E2E encryption for additional privacy
158
+ </AgreementItem>
159
+ <AgreementItem>
160
+ You can delete your account and all data at any time
161
+ </AgreementItem>
162
+ </ul>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
+ {/* Footer */}
168
+ <div
169
+ className="px-6 py-4 shrink-0"
170
+ style={{ borderTop: "1px solid var(--border)" }}
171
+ >
172
+ <button
173
+ onClick={onAccept}
174
+ disabled={!canAccept}
175
+ className="w-full py-2.5 font-bold text-body text-white rounded-sm transition-all"
176
+ style={{
177
+ background: canAccept ? "var(--bg-active)" : "var(--bg-hover, #3a3d41)",
178
+ cursor: canAccept ? "pointer" : "not-allowed",
179
+ opacity: canAccept ? 1 : 0.5,
180
+ }}
181
+ >
182
+ I Understand &amp; Accept
183
+ </button>
184
+ <div className="flex items-center justify-center gap-2 mt-3">
185
+ <a
186
+ href="https://botschat.app/privacy.html"
187
+ target="_blank"
188
+ rel="noopener noreferrer"
189
+ className="text-tiny hover:underline"
190
+ style={{ color: "var(--text-muted)" }}
191
+ >
192
+ Privacy Policy
193
+ </a>
194
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>·</span>
195
+ <a
196
+ href="https://botschat.app/terms.html"
197
+ target="_blank"
198
+ rel="noopener noreferrer"
199
+ className="text-tiny hover:underline"
200
+ style={{ color: "var(--text-muted)" }}
201
+ >
202
+ Terms of Service
203
+ </a>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
212
+ return (
213
+ <div className="flex gap-3">
214
+ <div
215
+ className="shrink-0 w-8 h-8 rounded-md flex items-center justify-center mt-0.5"
216
+ style={{ background: "var(--bg-secondary)", color: "var(--text-secondary)" }}
217
+ >
218
+ {icon}
219
+ </div>
220
+ <div className="min-w-0">
221
+ <h3 className="text-body font-bold mb-1" style={{ color: "var(--text-primary)" }}>
222
+ {title}
223
+ </h3>
224
+ <p className="text-caption" style={{ color: "var(--text-secondary)", lineHeight: 1.6 }}>
225
+ {children}
226
+ </p>
227
+ </div>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ function AgreementItem({ children }: { children: React.ReactNode }) {
233
+ return (
234
+ <li className="flex gap-2 text-caption" style={{ color: "var(--text-secondary)" }}>
235
+ <svg
236
+ className="w-4 h-4 shrink-0 mt-0.5"
237
+ viewBox="0 0 20 20"
238
+ fill="var(--bg-active)"
239
+ >
240
+ <path
241
+ fillRule="evenodd"
242
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
243
+ clipRule="evenodd"
244
+ />
245
+ </svg>
246
+ <span style={{ lineHeight: 1.5 }}>{children}</span>
247
+ </li>
248
+ );
249
+ }
@@ -3,7 +3,7 @@ import { authApi, setToken, setRefreshToken } from "../api";
3
3
  import type { AuthConfig } from "../api";
4
4
  import { useAppDispatch } from "../store";
5
5
  import { dlog } from "../debug-log";
6
- import { isFirebaseConfigured, signInWithGoogle, signInWithGitHub } from "../firebase";
6
+ import { isFirebaseConfigured, signInWithGoogle, signInWithGitHub, signInWithApple } from "../firebase";
7
7
 
8
8
  /** Google "G" logo SVG */
9
9
  function GoogleIcon() {
@@ -26,6 +26,15 @@ function GitHubIcon() {
26
26
  );
27
27
  }
28
28
 
29
+ /** Apple logo SVG */
30
+ function AppleIcon() {
31
+ return (
32
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}>
33
+ <path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
34
+ </svg>
35
+ );
36
+ }
37
+
29
38
  export function LoginPage() {
30
39
  const dispatch = useAppDispatch();
31
40
  const [isRegister, setIsRegister] = useState(false);
@@ -34,7 +43,7 @@ export function LoginPage() {
34
43
  const [displayName, setDisplayName] = useState("");
35
44
  const [error, setError] = useState("");
36
45
  const [loading, setLoading] = useState(false);
37
- const [oauthLoading, setOauthLoading] = useState<"google" | "github" | null>(null);
46
+ const [oauthLoading, setOauthLoading] = useState<"google" | "github" | "apple" | null>(null);
38
47
  const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
39
48
 
40
49
  const firebaseEnabled = isFirebaseConfigured();
@@ -44,11 +53,12 @@ export function LoginPage() {
44
53
  useEffect(() => {
45
54
  authApi.config().then(setAuthConfig).catch(() => {
46
55
  // Fallback: assume email enabled (local dev) if config endpoint fails
47
- setAuthConfig({ emailEnabled: true, googleEnabled: firebaseEnabled, githubEnabled: firebaseEnabled });
56
+ setAuthConfig({ emailEnabled: true, googleEnabled: firebaseEnabled, githubEnabled: firebaseEnabled, appleEnabled: firebaseEnabled });
48
57
  });
49
58
  }, [firebaseEnabled]);
50
59
 
51
- const emailEnabled = authConfig?.emailEnabled ?? true;
60
+ // Email/password login is permanently disabled — only OAuth (Google/GitHub) is allowed.
61
+ const emailEnabled = false;
52
62
  const configLoaded = authConfig !== null;
53
63
  const hasAnyLoginMethod = configLoaded && (firebaseEnabled || emailEnabled);
54
64
 
@@ -89,20 +99,24 @@ export function LoginPage() {
89
99
  }
90
100
  };
91
101
 
92
- const handleOAuthSignIn = async (provider: "google" | "github") => {
102
+ const handleOAuthSignIn = async (provider: "google" | "github" | "apple") => {
93
103
  setError("");
94
104
  setOauthLoading(provider);
95
105
 
106
+ const timeoutMs = provider === "apple" ? 120000 : 30000;
107
+ const timeout = setTimeout(() => {
108
+ setOauthLoading(null);
109
+ }, timeoutMs);
110
+
96
111
  try {
97
112
  dlog.info("Auth", `Starting ${provider} sign-in`);
98
- const signInFn = provider === "google" ? signInWithGoogle : signInWithGitHub;
113
+ const signInFn = provider === "google" ? signInWithGoogle : provider === "github" ? signInWithGitHub : signInWithApple;
99
114
  const { idToken } = await signInFn();
100
115
  dlog.info("Auth", `Got Firebase ID token from ${provider}, verifying with backend`);
101
116
  const res = await authApi.firebase(idToken);
102
117
  dlog.info("Auth", `${provider} sign-in success — user ${res.id} (${res.email})`);
103
118
  handleAuthSuccess(res);
104
119
  } catch (err) {
105
- // Don't show error for user-cancelled popup
106
120
  if (err instanceof Error && (
107
121
  err.message.includes("popup-closed-by-user") ||
108
122
  err.message.includes("cancelled")
@@ -114,6 +128,7 @@ export function LoginPage() {
114
128
  setError(message);
115
129
  }
116
130
  } finally {
131
+ clearTimeout(timeout);
117
132
  setOauthLoading(null);
118
133
  }
119
134
  };
@@ -171,6 +186,28 @@ export function LoginPage() {
171
186
  {configLoaded && firebaseEnabled && (
172
187
  <>
173
188
  <div className="space-y-3">
189
+ {/* Apple — listed first per Apple HIG */}
190
+ <button
191
+ type="button"
192
+ onClick={() => handleOAuthSignIn("apple")}
193
+ disabled={anyLoading}
194
+ className="w-full flex items-center justify-center gap-3 py-2.5 px-4 font-medium text-body rounded-sm disabled:opacity-50 transition-colors hover:brightness-95"
195
+ style={{
196
+ background: "var(--bg-surface)",
197
+ color: "var(--text-primary)",
198
+ border: "1px solid var(--border)",
199
+ }}
200
+ >
201
+ {oauthLoading === "apple" ? (
202
+ <span>Signing in...</span>
203
+ ) : (
204
+ <>
205
+ <AppleIcon />
206
+ <span>Continue with Apple</span>
207
+ </>
208
+ )}
209
+ </button>
210
+
174
211
  {/* Google */}
175
212
  <button
176
213
  type="button"
@@ -331,6 +368,17 @@ export function LoginPage() {
331
368
  </div>
332
369
  </>
333
370
  )}
371
+
372
+ {/* Privacy & Terms links */}
373
+ <div className="mt-6 text-center">
374
+ <a href="https://botschat.app/privacy.html" target="_blank" rel="noopener noreferrer" className="text-tiny hover:underline" style={{ color: "var(--text-muted)" }}>
375
+ Privacy Policy
376
+ </a>
377
+ <span className="mx-2 text-tiny" style={{ color: "var(--text-muted)" }}>·</span>
378
+ <a href="https://botschat.app/terms.html" target="_blank" rel="noopener noreferrer" className="text-tiny hover:underline" style={{ color: "var(--text-muted)" }}>
379
+ Terms of Service
380
+ </a>
381
+ </div>
334
382
  </div>
335
383
  </div>
336
384
  </div>
@@ -1,4 +1,5 @@
1
- import React, { useState, useCallback, useMemo } from "react";
1
+ import React, { useState, useCallback, useMemo, useEffect } from "react";
2
+ import { E2eService } from "../e2e";
2
3
  import ReactMarkdown from "react-markdown";
3
4
  import remarkGfm from "remark-gfm";
4
5
  import rehypeHighlight from "rehype-highlight";
@@ -25,6 +26,10 @@ type ParsedAction = {
25
26
  type MessageContentProps = {
26
27
  text: string;
27
28
  mediaUrl?: string;
29
+ /** Message ID — used to derive E2E decryption context as "{messageId}:media" */
30
+ messageId?: string;
31
+ /** Whether this message was E2E encrypted (media binary may also be encrypted) */
32
+ encrypted?: boolean;
28
33
  a2ui?: string;
29
34
  className?: string;
30
35
  isStreaming?: boolean;
@@ -938,6 +943,8 @@ function ActionBlockPlaceholder() {
938
943
  export function MessageContent({
939
944
  text,
940
945
  mediaUrl,
946
+ messageId,
947
+ encrypted,
941
948
  a2ui,
942
949
  className = "",
943
950
  isStreaming,
@@ -965,16 +972,18 @@ export function MessageContent({
965
972
  {/* Media preview */}
966
973
  {mediaUrl && (
967
974
  <div className="mb-2">
968
- <MediaPreview url={mediaUrl} />
975
+ <MediaPreview url={mediaUrl} mediaContextId={encrypted && messageId ? `${messageId}:media` : undefined} />
969
976
  </div>
970
977
  )}
971
978
 
972
979
  {/* Markdown text with enhanced rendering */}
973
980
  {processedText ? (
974
981
  <div
975
- className="prose prose-sm max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-pre:my-0 prose-code:before:content-none prose-code:after:content-none prose-headings:my-2"
982
+ className="prose prose-sm max-w-none overflow-hidden prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-pre:my-0 prose-code:before:content-none prose-code:after:content-none prose-headings:my-2"
976
983
  style={{
977
984
  color: "var(--text-primary)",
985
+ overflowWrap: "break-word",
986
+ wordBreak: "break-word",
978
987
  "--tw-prose-headings": "var(--text-primary)",
979
988
  "--tw-prose-bold": "var(--text-primary)",
980
989
  "--tw-prose-code": "var(--code-text)",
@@ -1007,9 +1016,62 @@ export function MessageContent({
1007
1016
  // Media preview — handles images, audio, video, and file downloads
1008
1017
  // ---------------------------------------------------------------------------
1009
1018
 
1010
- function MediaPreview({ url }: { url: string }) {
1019
+ /**
1020
+ * MediaPreview — renders images, audio, video, and files.
1021
+ * If mediaContextId is provided and E2E key is available, fetches the media,
1022
+ * decrypts it client-side, and renders a local object URL.
1023
+ */
1024
+ function MediaPreview({ url, mediaContextId }: { url: string; mediaContextId?: string }) {
1025
+ const [decryptedUrl, setDecryptedUrl] = useState<string | null>(null);
1026
+ const [decrypting, setDecrypting] = useState(false);
1027
+
1028
+ useEffect(() => {
1029
+ if (!mediaContextId || !E2eService.hasKey()) {
1030
+ setDecryptedUrl(null);
1031
+ return;
1032
+ }
1033
+
1034
+ let cancelled = false;
1035
+ setDecrypting(true);
1036
+
1037
+ (async () => {
1038
+ try {
1039
+ const res = await fetch(url);
1040
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1041
+ const encrypted = new Uint8Array(await res.arrayBuffer());
1042
+ const decrypted = await E2eService.decryptMedia(encrypted, mediaContextId);
1043
+ if (!cancelled) {
1044
+ const blob = new Blob([decrypted.buffer.slice(0) as ArrayBuffer]);
1045
+ setDecryptedUrl(URL.createObjectURL(blob));
1046
+ }
1047
+ } catch (err) {
1048
+ console.warn("[E2E] Media decryption failed, falling back to direct URL:", err);
1049
+ if (!cancelled) setDecryptedUrl(null);
1050
+ } finally {
1051
+ if (!cancelled) setDecrypting(false);
1052
+ }
1053
+ })();
1054
+
1055
+ return () => {
1056
+ cancelled = true;
1057
+ if (decryptedUrl) URL.revokeObjectURL(decryptedUrl);
1058
+ };
1059
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1060
+ }, [url, mediaContextId]);
1061
+
1062
+ // Use decrypted URL if available, otherwise fall back to direct URL
1063
+ const effectiveUrl = decryptedUrl || url;
1011
1064
  const ext = url.split(".").pop()?.toLowerCase().split("?")[0] ?? "";
1012
1065
 
1066
+ if (decrypting) {
1067
+ return (
1068
+ <div className="flex items-center gap-2 px-3 py-2 rounded-md max-w-[360px]"
1069
+ style={{ background: "var(--bg-hover)", border: "1px solid var(--border)" }}>
1070
+ <span className="text-caption" style={{ color: "var(--text-muted)" }}>Decrypting media...</span>
1071
+ </div>
1072
+ );
1073
+ }
1074
+
1013
1075
  // Audio
1014
1076
  if (["mp3", "wav", "ogg", "m4a", "aac", "webm"].includes(ext)) {
1015
1077
  return (
@@ -1021,7 +1083,7 @@ function MediaPreview({ url }: { url: string }) {
1021
1083
  <path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
1022
1084
  </svg>
1023
1085
  <audio controls className="flex-1 h-8" style={{ maxWidth: 280 }}>
1024
- <source src={url} />
1086
+ <source src={effectiveUrl} />
1025
1087
  </audio>
1026
1088
  </div>
1027
1089
  );
@@ -1035,7 +1097,7 @@ function MediaPreview({ url }: { url: string }) {
1035
1097
  className="max-w-[360px] max-h-64 rounded-md"
1036
1098
  style={{ border: "1px solid var(--border)" }}
1037
1099
  >
1038
- <source src={url} />
1100
+ <source src={effectiveUrl} />
1039
1101
  </video>
1040
1102
  );
1041
1103
  }
@@ -1045,7 +1107,7 @@ function MediaPreview({ url }: { url: string }) {
1045
1107
  const filename = url.split("/").pop()?.split("?")[0] ?? "file";
1046
1108
  return (
1047
1109
  <a
1048
- href={url}
1110
+ href={effectiveUrl}
1049
1111
  target="_blank"
1050
1112
  rel="noopener noreferrer"
1051
1113
  className="flex items-center gap-3 px-3 py-2.5 rounded-md max-w-[360px] hover:opacity-90 transition-opacity"
@@ -1072,11 +1134,11 @@ function MediaPreview({ url }: { url: string }) {
1072
1134
  // Default: image
1073
1135
  return (
1074
1136
  <img
1075
- src={url}
1137
+ src={effectiveUrl}
1076
1138
  alt=""
1077
1139
  className="max-w-[360px] max-h-64 rounded-md object-contain cursor-pointer hover:opacity-90 transition-opacity"
1078
1140
  style={{ border: "1px solid var(--border)" }}
1079
- onClick={() => window.open(url, "_blank")}
1141
+ onClick={() => window.open(effectiveUrl, "_blank")}
1080
1142
  />
1081
1143
  );
1082
1144
  }