gloomberb 0.9.6 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/config.ts +1 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- package/src/version.ts +1 -1
|
@@ -1,20 +1,33 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { Button, ChoiceDialog } from "../../../components";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { Button, ChoiceDialog, ConfirmDialog, Tabs } from "../../../components";
|
|
3
3
|
import { useAppSelector } from "../../../state/app/context";
|
|
4
|
-
import {
|
|
4
|
+
import { useChartQueries, useFxRatesMap, useTickerFinancialsMap } from "../../../market-data/hooks";
|
|
5
|
+
import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
|
|
6
|
+
import { blendHex, colors } from "../../../theme/colors";
|
|
5
7
|
import type { PaneProps } from "../../../types/plugin";
|
|
6
|
-
import { Box, ScrollBox, Text, Textarea, TextAttributes, type TextareaRenderable } from "../../../ui";
|
|
8
|
+
import { Box, ScrollBox, Text, Textarea, TextAttributes, type TextareaRenderable, useRendererHost, useUiHost } from "../../../ui";
|
|
7
9
|
import { useDialog, type AlertContext, type PromptContext } from "../../../ui/dialog";
|
|
10
|
+
import { openNativeSelect, type NativeSelectElement } from "../../../components/ui/native-select";
|
|
8
11
|
import { apiClient, type AccountProfile } from "../../../api-client";
|
|
9
12
|
import { chatController } from "../chat/controller";
|
|
10
13
|
import { CloudAuthNotice } from "../cloud/auth-actions";
|
|
11
|
-
import { AccountTextField, CheckboxRow, FieldRow, PickerRow } from "./form-components";
|
|
12
14
|
import {
|
|
13
|
-
|
|
15
|
+
AccountTextField,
|
|
16
|
+
CheckboxRow,
|
|
17
|
+
FieldRow,
|
|
18
|
+
PublicAnalyticsGroup,
|
|
19
|
+
accountFieldLabelWidth,
|
|
20
|
+
} from "./form-components";
|
|
21
|
+
import {
|
|
14
22
|
NO_PORTFOLIO_VALUE,
|
|
23
|
+
buildPublishedProfileAnalyticsPreview,
|
|
24
|
+
buildProfileAnalyticsPreview,
|
|
15
25
|
buildPortfolioChoices,
|
|
26
|
+
computeCumulativeReturn,
|
|
16
27
|
countPortfolioHoldings,
|
|
17
28
|
emptyToNull,
|
|
29
|
+
formatPlan,
|
|
30
|
+
getPortfolioPositionTickers,
|
|
18
31
|
portfolioOptionIds,
|
|
19
32
|
profileToDraft,
|
|
20
33
|
selectedPortfolioLabel,
|
|
@@ -24,11 +37,255 @@ import {
|
|
|
24
37
|
import { PasswordChangeDialog } from "./password-dialog";
|
|
25
38
|
import { useAccountManagementFooter } from "./footer";
|
|
26
39
|
import { useAccountManagementKeyboard } from "./keyboard";
|
|
40
|
+
import { buildTrackedCurrencies } from "../analytics/sector-model";
|
|
41
|
+
import {
|
|
42
|
+
buildBenchmarkReturnSeries,
|
|
43
|
+
buildPortfolioChartTargets,
|
|
44
|
+
buildPortfolioReturnSeries,
|
|
45
|
+
} from "../analytics/pane-model";
|
|
46
|
+
import { computeDatedBeta } from "../analytics/metrics";
|
|
47
|
+
import { useCloudSyncStatus } from "../../../sync/react";
|
|
48
|
+
import { cloudSyncController } from "../../../sync/controller";
|
|
49
|
+
import { setSyncedProfileAnalytics } from "../../../sync/profile-analytics";
|
|
50
|
+
|
|
51
|
+
type AccountBusy = "profile" | "password" | "alerts" | "billing" | "delete" | null;
|
|
52
|
+
type AccountTab = "profile" | "emails" | "pro" | "advanced";
|
|
53
|
+
|
|
54
|
+
const CLOUD_UPGRADE_URL = "https://gloom.sh/cloud?upgrade=pro";
|
|
55
|
+
|
|
56
|
+
const ACCOUNT_TABS: Array<{ label: string; value: AccountTab }> = [
|
|
57
|
+
{ label: "Profile", value: "profile" },
|
|
58
|
+
{ label: "Emails", value: "emails" },
|
|
59
|
+
{ label: "Pro", value: "pro" },
|
|
60
|
+
{ label: "Advanced", value: "advanced" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const ACCOUNT_TAB_FIELD_ORDER: Record<AccountTab, AccountFieldKey[]> = {
|
|
64
|
+
profile: [
|
|
65
|
+
"profilePublic",
|
|
66
|
+
"acceptUnknownDms",
|
|
67
|
+
"username",
|
|
68
|
+
"name",
|
|
69
|
+
"company",
|
|
70
|
+
"title",
|
|
71
|
+
"publicEmail",
|
|
72
|
+
"xAccount",
|
|
73
|
+
"bio",
|
|
74
|
+
"sharedPortfolioId",
|
|
75
|
+
],
|
|
76
|
+
emails: ["weeklyRoundupEnabled", "positionAlertsEnabled", "emailAlertsOffAction"],
|
|
77
|
+
pro: ["upgradeAction"],
|
|
78
|
+
advanced: ["passwordAction", "deleteAccountAction"],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const PLAN_COMPARISON_ROWS = [
|
|
82
|
+
{ capability: "Market data", free: "Delayed", pro: "Real-time", proTone: "positive" },
|
|
83
|
+
{ capability: "News", free: "12h delay", pro: "Real-time wire", proTone: "positive" },
|
|
84
|
+
{ capability: "Cloud sync", free: "Included", pro: "Included", proTone: "neutral" },
|
|
85
|
+
{ capability: "X data", free: "No", pro: "Included", proTone: "positive" },
|
|
86
|
+
{ capability: "AI Screener", free: "No", pro: "Soon", proTone: "muted" },
|
|
87
|
+
] as const;
|
|
88
|
+
|
|
89
|
+
function PlanComparison({
|
|
90
|
+
width,
|
|
91
|
+
activePlan,
|
|
92
|
+
upgradeButton,
|
|
93
|
+
}: {
|
|
94
|
+
width: number;
|
|
95
|
+
activePlan: "free" | "pro";
|
|
96
|
+
upgradeButton: ReactNode;
|
|
97
|
+
}) {
|
|
98
|
+
const isDesktop = useUiHost().kind === "desktop-web";
|
|
99
|
+
const comparisonWidth = Math.min(width, 58);
|
|
100
|
+
const capabilityWidth = Math.max(13, Math.min(16, Math.floor(comparisonWidth * 0.32)));
|
|
101
|
+
const valueWidth = Math.max(10, Math.floor((comparisonWidth - capabilityWidth - 2) / 2));
|
|
102
|
+
const rowWidth = capabilityWidth + valueWidth * 2 + 2;
|
|
103
|
+
const proCellBg = blendHex(colors.panel, colors.selected, activePlan === "pro" ? 0.42 : 0.26);
|
|
104
|
+
const freeFg = activePlan === "free" ? colors.text : colors.textDim;
|
|
105
|
+
const proHeadingFg = activePlan === "pro" ? colors.textBright : colors.text;
|
|
106
|
+
const proValueColor = (tone: typeof PLAN_COMPARISON_ROWS[number]["proTone"]) => {
|
|
107
|
+
if (tone === "positive") return colors.positive;
|
|
108
|
+
if (tone === "muted") return colors.textMuted;
|
|
109
|
+
return colors.text;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
if (isDesktop) {
|
|
113
|
+
const gridStyle: CSSProperties = {
|
|
114
|
+
display: "grid",
|
|
115
|
+
gridTemplateColumns: width >= 86
|
|
116
|
+
? "minmax(220px, 1fr) minmax(120px, 0.42fr) minmax(300px, 1.05fr)"
|
|
117
|
+
: "minmax(150px, 0.95fr) minmax(86px, 0.42fr) minmax(180px, 1fr)",
|
|
118
|
+
columnGap: width >= 86 ? "clamp(30px, 5vw, 72px)" : "22px",
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
width: "100%",
|
|
121
|
+
};
|
|
122
|
+
const desktopProBg = blendHex(colors.panel, colors.selected, activePlan === "pro" ? 0.34 : 0.24);
|
|
123
|
+
const desktopProAltBg = blendHex(colors.panel, colors.selected, activePlan === "pro" ? 0.39 : 0.28);
|
|
124
|
+
const desktopText = {
|
|
125
|
+
lineHeight: "22px",
|
|
126
|
+
fontSize: "15px",
|
|
127
|
+
} satisfies CSSProperties;
|
|
128
|
+
return (
|
|
129
|
+
<Box
|
|
130
|
+
flexDirection="column"
|
|
131
|
+
width="100%"
|
|
132
|
+
maxWidth={width >= 86 ? "980px" : "100%"}
|
|
133
|
+
style={{
|
|
134
|
+
marginTop: 18,
|
|
135
|
+
paddingLeft: width >= 86 ? 12 : 4,
|
|
136
|
+
paddingRight: width >= 86 ? 10 : 4,
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<Box
|
|
140
|
+
style={{
|
|
141
|
+
...gridStyle,
|
|
142
|
+
marginBottom: 12,
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<Text fg={colors.textDim} style={{ ...desktopText, fontWeight: 650 }}>
|
|
146
|
+
Capability
|
|
147
|
+
</Text>
|
|
148
|
+
<Text
|
|
149
|
+
fg={activePlan === "free" ? colors.textBright : colors.textDim}
|
|
150
|
+
attributes={activePlan === "free" ? TextAttributes.BOLD : 0}
|
|
151
|
+
style={{ ...desktopText, fontWeight: activePlan === "free" ? 700 : 650 }}
|
|
152
|
+
>
|
|
153
|
+
Free
|
|
154
|
+
</Text>
|
|
155
|
+
<Box flexDirection="row" alignItems="baseline" gap={1}>
|
|
156
|
+
<Text
|
|
157
|
+
fg={colors.borderFocused}
|
|
158
|
+
attributes={TextAttributes.BOLD}
|
|
159
|
+
style={{ ...desktopText, fontWeight: 750 }}
|
|
160
|
+
>
|
|
161
|
+
Pro
|
|
162
|
+
</Text>
|
|
163
|
+
<Text fg={colors.textBright} style={desktopText}>
|
|
164
|
+
$49/mo
|
|
165
|
+
</Text>
|
|
166
|
+
</Box>
|
|
167
|
+
</Box>
|
|
168
|
+
{PLAN_COMPARISON_ROWS.map((row, index) => {
|
|
169
|
+
const first = index === 0;
|
|
170
|
+
const last = index === PLAN_COMPARISON_ROWS.length - 1;
|
|
171
|
+
return (
|
|
172
|
+
<Box
|
|
173
|
+
key={row.capability}
|
|
174
|
+
style={{
|
|
175
|
+
...gridStyle,
|
|
176
|
+
minHeight: 48,
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<Box flexDirection="row" alignItems="center" style={{ minWidth: 0 }}>
|
|
180
|
+
<Box
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
style={{
|
|
183
|
+
width: 8,
|
|
184
|
+
height: 8,
|
|
185
|
+
borderRadius: 999,
|
|
186
|
+
marginRight: 18,
|
|
187
|
+
flexShrink: 0,
|
|
188
|
+
backgroundColor: colors.borderFocused,
|
|
189
|
+
boxShadow: `0 0 0 2px ${blendHex(colors.bg, colors.borderFocused, 0.14)}`,
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
<Text fg={colors.textBright} style={{ ...desktopText, fontWeight: 520 }}>
|
|
193
|
+
{row.capability}
|
|
194
|
+
</Text>
|
|
195
|
+
</Box>
|
|
196
|
+
<Text fg={freeFg} style={desktopText}>
|
|
197
|
+
{row.free}
|
|
198
|
+
</Text>
|
|
199
|
+
<Box
|
|
200
|
+
flexDirection="row"
|
|
201
|
+
alignItems="center"
|
|
202
|
+
backgroundColor={index % 2 === 0 ? desktopProBg : desktopProAltBg}
|
|
203
|
+
style={{
|
|
204
|
+
minHeight: 48,
|
|
205
|
+
paddingLeft: width >= 86 ? 24 : 16,
|
|
206
|
+
paddingRight: width >= 86 ? 24 : 14,
|
|
207
|
+
borderTopLeftRadius: first ? 2 : 0,
|
|
208
|
+
borderTopRightRadius: first ? 2 : 0,
|
|
209
|
+
borderBottomLeftRadius: last ? 2 : 0,
|
|
210
|
+
borderBottomRightRadius: last ? 2 : 0,
|
|
211
|
+
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.025)",
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
<Text
|
|
215
|
+
fg={proValueColor(row.proTone)}
|
|
216
|
+
attributes={row.proTone === "positive" ? TextAttributes.BOLD : 0}
|
|
217
|
+
style={{
|
|
218
|
+
...desktopText,
|
|
219
|
+
fontWeight: row.proTone === "positive" ? 720 : 560,
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
{row.pro}
|
|
223
|
+
</Text>
|
|
224
|
+
</Box>
|
|
225
|
+
</Box>
|
|
226
|
+
);
|
|
227
|
+
})}
|
|
228
|
+
<Box
|
|
229
|
+
style={{
|
|
230
|
+
...gridStyle,
|
|
231
|
+
marginTop: 14,
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
<Box />
|
|
235
|
+
<Box />
|
|
236
|
+
<Box flexDirection="row" justifyContent="center">
|
|
237
|
+
{upgradeButton}
|
|
238
|
+
</Box>
|
|
239
|
+
</Box>
|
|
240
|
+
</Box>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<Box flexDirection="column" width={rowWidth}>
|
|
246
|
+
<Box height={1} flexDirection="row" gap={1}>
|
|
247
|
+
<Text width={capabilityWidth} fg={colors.textDim}>Capability</Text>
|
|
248
|
+
<Text width={valueWidth} fg={activePlan === "free" ? colors.textBright : colors.textDim} attributes={activePlan === "free" ? TextAttributes.BOLD : 0}>
|
|
249
|
+
Free
|
|
250
|
+
</Text>
|
|
251
|
+
<Box width={valueWidth} backgroundColor={proCellBg} paddingX={1}>
|
|
252
|
+
<Text fg={proHeadingFg} attributes={TextAttributes.BOLD}>
|
|
253
|
+
Pro $49/mo
|
|
254
|
+
</Text>
|
|
255
|
+
</Box>
|
|
256
|
+
</Box>
|
|
257
|
+
{PLAN_COMPARISON_ROWS.map((row) => (
|
|
258
|
+
<Box key={row.capability} height={1} flexDirection="row" gap={1}>
|
|
259
|
+
<Text width={capabilityWidth} fg={colors.textDim}>{row.capability}</Text>
|
|
260
|
+
<Text width={valueWidth} fg={freeFg}>{row.free}</Text>
|
|
261
|
+
<Box width={valueWidth} backgroundColor={proCellBg} paddingX={1}>
|
|
262
|
+
<Text fg={proValueColor(row.proTone)} attributes={row.proTone === "positive" ? TextAttributes.BOLD : 0}>
|
|
263
|
+
{row.pro}
|
|
264
|
+
</Text>
|
|
265
|
+
</Box>
|
|
266
|
+
</Box>
|
|
267
|
+
))}
|
|
268
|
+
<Box height={1} flexDirection="row" gap={1}>
|
|
269
|
+
<Box width={capabilityWidth} />
|
|
270
|
+
<Box width={valueWidth} />
|
|
271
|
+
<Box width={valueWidth} flexDirection="row" paddingLeft={1}>
|
|
272
|
+
{upgradeButton}
|
|
273
|
+
</Box>
|
|
274
|
+
</Box>
|
|
275
|
+
</Box>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
27
278
|
|
|
28
279
|
export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
29
280
|
const dialog = useDialog();
|
|
30
|
-
const
|
|
281
|
+
const renderer = useRendererHost();
|
|
282
|
+
const isDesktop = useUiHost().kind === "desktop-web";
|
|
283
|
+
const config = useAppSelector((state) => state.config);
|
|
284
|
+
const portfolios = config.portfolios;
|
|
285
|
+
const baseCurrency = config.baseCurrency;
|
|
31
286
|
const tickers = useAppSelector((state) => state.tickers);
|
|
287
|
+
const cachedFinancials = useAppSelector((state) => state.financials);
|
|
288
|
+
const cachedExchangeRates = useAppSelector((state) => state.exchangeRates);
|
|
32
289
|
const [sessionMarker, setSessionMarker] = useState(() => {
|
|
33
290
|
const snapshot = chatController.getSnapshot();
|
|
34
291
|
return `${apiClient.getSessionToken() ?? ""}:${snapshot.user?.id ?? ""}:${snapshot.user?.username ?? ""}`;
|
|
@@ -38,23 +295,163 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
38
295
|
const [draft, setDraft] = useState<AccountDraft>(() => profileToDraft(null));
|
|
39
296
|
const [activeField, setActiveField] = useState<AccountFieldKey>("username");
|
|
40
297
|
const [message, setMessage] = useState<{ tone: "info" | "success" | "error"; text: string } | null>(null);
|
|
41
|
-
const [busy, setBusy] = useState<
|
|
298
|
+
const [busy, setBusy] = useState<AccountBusy>(null);
|
|
299
|
+
const [activeTab, setActiveTab] = useState<AccountTab>("profile");
|
|
300
|
+
const syncStatus = useCloudSyncStatus();
|
|
42
301
|
const bioRef = useRef<TextareaRenderable | null>(null);
|
|
302
|
+
const portfolioNativeSelectRef = useRef<NativeSelectElement | null>(null);
|
|
303
|
+
const refreshedSyncRevisionRef = useRef<number | null>(null);
|
|
43
304
|
const draftRef = useRef(draft);
|
|
44
305
|
draftRef.current = draft;
|
|
45
306
|
|
|
46
307
|
const formWidth = Math.max(24, Math.min(70, width - 2));
|
|
308
|
+
const contentWidth = activeTab === "pro" && isDesktop ? Math.max(formWidth, width - 2) : formWidth;
|
|
47
309
|
const twoColumns = formWidth >= 60;
|
|
48
310
|
const fieldWidth = twoColumns ? Math.max(22, Math.floor((formWidth - 3) / 2)) : Math.max(18, Math.min(46, formWidth - 2));
|
|
49
|
-
const
|
|
311
|
+
const formLabelWidth = accountFieldLabelWidth(formWidth);
|
|
50
312
|
const bodyHeight = Math.max(5, height);
|
|
51
|
-
const fieldOrder =
|
|
313
|
+
const fieldOrder = ACCOUNT_TAB_FIELD_ORDER[activeTab];
|
|
52
314
|
|
|
53
315
|
const portfolioHoldingCounts = useMemo(() => countPortfolioHoldings(tickers), [tickers]);
|
|
54
316
|
const portfolioChoices = useMemo(
|
|
55
317
|
() => buildPortfolioChoices(portfolios, portfolioHoldingCounts),
|
|
56
318
|
[portfolioHoldingCounts, portfolios],
|
|
57
319
|
);
|
|
320
|
+
const selectedAnalyticsPortfolio = useMemo(
|
|
321
|
+
() => portfolios.find((portfolio) => portfolio.id === draft.sharedPortfolioId) ?? null,
|
|
322
|
+
[draft.sharedPortfolioId, portfolios],
|
|
323
|
+
);
|
|
324
|
+
const portfolioTickers = useMemo(
|
|
325
|
+
() => draft.sharedPortfolioId ? getPortfolioPositionTickers(tickers, draft.sharedPortfolioId) : [],
|
|
326
|
+
[draft.sharedPortfolioId, tickers],
|
|
327
|
+
);
|
|
328
|
+
const marketFinancials = useTickerFinancialsMap(portfolioTickers);
|
|
329
|
+
const financials = useMemo(() => {
|
|
330
|
+
const merged = new Map(cachedFinancials);
|
|
331
|
+
for (const [symbol, data] of marketFinancials) {
|
|
332
|
+
merged.set(symbol, data);
|
|
333
|
+
}
|
|
334
|
+
return merged;
|
|
335
|
+
}, [cachedFinancials, marketFinancials]);
|
|
336
|
+
const trackedCurrencies = useMemo(
|
|
337
|
+
() => buildTrackedCurrencies(portfolioTickers, financials, baseCurrency),
|
|
338
|
+
[baseCurrency, financials, portfolioTickers],
|
|
339
|
+
);
|
|
340
|
+
const fetchedExchangeRates = useFxRatesMap(trackedCurrencies);
|
|
341
|
+
const effectiveExchangeRates = selectEffectiveExchangeRates(fetchedExchangeRates, cachedExchangeRates);
|
|
342
|
+
const chartTargets = useMemo(
|
|
343
|
+
() => buildPortfolioChartTargets(portfolioTickers),
|
|
344
|
+
[portfolioTickers],
|
|
345
|
+
);
|
|
346
|
+
const chartRequests = useMemo(
|
|
347
|
+
() => chartTargets.map((target) => target.request),
|
|
348
|
+
[chartTargets],
|
|
349
|
+
);
|
|
350
|
+
const chartEntries = useChartQueries(chartRequests);
|
|
351
|
+
const spyRequest = useMemo(
|
|
352
|
+
() => ({
|
|
353
|
+
instrument: { symbol: "SPY", exchange: "" },
|
|
354
|
+
bufferRange: "1Y" as const,
|
|
355
|
+
granularity: "range" as const,
|
|
356
|
+
}),
|
|
357
|
+
[],
|
|
358
|
+
);
|
|
359
|
+
const spyChartRequests = useMemo(
|
|
360
|
+
() => portfolioTickers.length > 0 ? [spyRequest] : [],
|
|
361
|
+
[portfolioTickers.length, spyRequest],
|
|
362
|
+
);
|
|
363
|
+
const spyChartEntries = useChartQueries(spyChartRequests);
|
|
364
|
+
const columnContext = useMemo(() => ({
|
|
365
|
+
activeTab: draft.sharedPortfolioId || undefined,
|
|
366
|
+
baseCurrency,
|
|
367
|
+
exchangeRates: effectiveExchangeRates,
|
|
368
|
+
now: Date.now(),
|
|
369
|
+
}), [baseCurrency, draft.sharedPortfolioId, effectiveExchangeRates]);
|
|
370
|
+
const portfolioReturnSeries = useMemo(
|
|
371
|
+
() => buildPortfolioReturnSeries({
|
|
372
|
+
chartTargets,
|
|
373
|
+
chartEntries,
|
|
374
|
+
financials,
|
|
375
|
+
columnContext,
|
|
376
|
+
}),
|
|
377
|
+
[chartEntries, chartTargets, columnContext, financials],
|
|
378
|
+
);
|
|
379
|
+
const spyReturnSeries = useMemo(
|
|
380
|
+
() => buildBenchmarkReturnSeries(spyRequest, spyChartEntries),
|
|
381
|
+
[spyChartEntries, spyRequest],
|
|
382
|
+
);
|
|
383
|
+
const oneYearReturn = useMemo(
|
|
384
|
+
() => portfolioReturnSeries ? computeCumulativeReturn(portfolioReturnSeries) : null,
|
|
385
|
+
[portfolioReturnSeries],
|
|
386
|
+
);
|
|
387
|
+
const beta = useMemo(
|
|
388
|
+
() => (portfolioReturnSeries && spyReturnSeries ? computeDatedBeta(portfolioReturnSeries, spyReturnSeries) : null),
|
|
389
|
+
[portfolioReturnSeries, spyReturnSeries],
|
|
390
|
+
);
|
|
391
|
+
const localAnalyticsPreview = useMemo(
|
|
392
|
+
() => buildProfileAnalyticsPreview({
|
|
393
|
+
beta,
|
|
394
|
+
portfolio: selectedAnalyticsPortfolio,
|
|
395
|
+
portfolioTickers,
|
|
396
|
+
selectedPortfolioId: draft.sharedPortfolioId,
|
|
397
|
+
oneYearReturn,
|
|
398
|
+
}),
|
|
399
|
+
[
|
|
400
|
+
beta,
|
|
401
|
+
draft.sharedPortfolioId,
|
|
402
|
+
portfolioTickers,
|
|
403
|
+
selectedAnalyticsPortfolio,
|
|
404
|
+
oneYearReturn,
|
|
405
|
+
],
|
|
406
|
+
);
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
const portfolioId = selectedAnalyticsPortfolio?.id;
|
|
409
|
+
if (!portfolioId) return;
|
|
410
|
+
const changed = setSyncedProfileAnalytics(portfolioId, localAnalyticsPreview.publicAnalytics);
|
|
411
|
+
if (changed) cloudSyncController.schedulePush("profile-analytics");
|
|
412
|
+
}, [
|
|
413
|
+
localAnalyticsPreview.publicAnalytics?.oneYearReturn,
|
|
414
|
+
localAnalyticsPreview.publicAnalytics?.spyBeta,
|
|
415
|
+
selectedAnalyticsPortfolio?.id,
|
|
416
|
+
]);
|
|
417
|
+
const publicAnalyticsPreview = useMemo(
|
|
418
|
+
() => buildPublishedProfileAnalyticsPreview({
|
|
419
|
+
analytics: profile?.portfolioAnalytics ?? null,
|
|
420
|
+
draftProfilePublic: draft.profilePublic,
|
|
421
|
+
portfolio: selectedAnalyticsPortfolio,
|
|
422
|
+
profileLoaded: !!profile,
|
|
423
|
+
savedProfilePublic: profile?.profilePublic === true,
|
|
424
|
+
savedSharedPortfolioId: profile?.sharedPortfolioId ?? "",
|
|
425
|
+
selectedPortfolioId: draft.sharedPortfolioId,
|
|
426
|
+
syncing: syncStatus.phase === "syncing",
|
|
427
|
+
}),
|
|
428
|
+
[
|
|
429
|
+
draft.profilePublic,
|
|
430
|
+
draft.sharedPortfolioId,
|
|
431
|
+
profile,
|
|
432
|
+
selectedAnalyticsPortfolio,
|
|
433
|
+
syncStatus.phase,
|
|
434
|
+
],
|
|
435
|
+
);
|
|
436
|
+
const profileAnalyticsDetail = useMemo(() => {
|
|
437
|
+
if (!draft.sharedPortfolioId) return "No public portfolio analytics";
|
|
438
|
+
if (!profile) return "Loading published metrics";
|
|
439
|
+
if (
|
|
440
|
+
draft.profilePublic !== profile.profilePublic
|
|
441
|
+
|| draft.sharedPortfolioId !== (profile.sharedPortfolioId ?? "")
|
|
442
|
+
) {
|
|
443
|
+
return "Save profile to update published metrics";
|
|
444
|
+
}
|
|
445
|
+
if (profile.profilePublic !== true) return "Public profile is off";
|
|
446
|
+
if (syncStatus.phase === "syncing") return "Syncing published metrics";
|
|
447
|
+
if (!profile.portfolioAnalytics) return "Waiting for published metrics";
|
|
448
|
+
return "Published public metrics";
|
|
449
|
+
}, [
|
|
450
|
+
draft.profilePublic,
|
|
451
|
+
draft.sharedPortfolioId,
|
|
452
|
+
profile,
|
|
453
|
+
syncStatus.phase,
|
|
454
|
+
]);
|
|
58
455
|
|
|
59
456
|
useEffect(() => {
|
|
60
457
|
const unsubscribe = chatController.subscribe((snapshot) => {
|
|
@@ -90,9 +487,26 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
90
487
|
void loadProfile();
|
|
91
488
|
}, [loadProfile, sessionMarker]);
|
|
92
489
|
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
if (!hasSession || !apiClient.getSessionToken()) return;
|
|
492
|
+
if (syncStatus.phase !== "synced" || syncStatus.revision == null) return;
|
|
493
|
+
if (refreshedSyncRevisionRef.current === syncStatus.revision) return;
|
|
494
|
+
refreshedSyncRevisionRef.current = syncStatus.revision;
|
|
495
|
+
let cancelled = false;
|
|
496
|
+
void (async () => {
|
|
497
|
+
const nextProfile = await apiClient.getAccountProfile().catch(() => null);
|
|
498
|
+
if (cancelled || !nextProfile) return;
|
|
499
|
+
setProfile(nextProfile);
|
|
500
|
+
await chatController.refreshSession().catch(() => {});
|
|
501
|
+
})();
|
|
502
|
+
return () => {
|
|
503
|
+
cancelled = true;
|
|
504
|
+
};
|
|
505
|
+
}, [hasSession, syncStatus.phase, syncStatus.revision]);
|
|
506
|
+
|
|
93
507
|
useEffect(() => {
|
|
94
508
|
if (!fieldOrder.includes(activeField)) {
|
|
95
|
-
setActiveField("username");
|
|
509
|
+
setActiveField(fieldOrder[0] ?? "username");
|
|
96
510
|
}
|
|
97
511
|
}, [activeField, fieldOrder]);
|
|
98
512
|
|
|
@@ -100,6 +514,12 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
100
514
|
setDraft((current) => ({ ...current, [key]: value }));
|
|
101
515
|
}, []);
|
|
102
516
|
|
|
517
|
+
const selectTab = useCallback((tab: string) => {
|
|
518
|
+
const nextTab = tab as AccountTab;
|
|
519
|
+
setActiveTab(nextTab);
|
|
520
|
+
setActiveField(ACCOUNT_TAB_FIELD_ORDER[nextTab][0] ?? "username");
|
|
521
|
+
}, []);
|
|
522
|
+
|
|
103
523
|
const openPasswordDialog = useCallback(() => {
|
|
104
524
|
if (busy) return;
|
|
105
525
|
setActiveField("passwordAction");
|
|
@@ -137,7 +557,7 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
137
557
|
content: (context: PromptContext<string>) => (
|
|
138
558
|
<ChoiceDialog
|
|
139
559
|
{...context}
|
|
140
|
-
title="
|
|
560
|
+
title="Public Stats"
|
|
141
561
|
choices={portfolioChoices}
|
|
142
562
|
selectedChoiceId={currentPortfolioChoiceId}
|
|
143
563
|
/>
|
|
@@ -147,6 +567,15 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
147
567
|
setDraftValue("sharedPortfolioId", selected === NO_PORTFOLIO_VALUE ? "" : selected);
|
|
148
568
|
}, [dialog, portfolioChoices, setDraftValue]);
|
|
149
569
|
|
|
570
|
+
const openPortfolioPicker = useCallback(async () => {
|
|
571
|
+
setActiveField("sharedPortfolioId");
|
|
572
|
+
if (portfolioNativeSelectRef.current) {
|
|
573
|
+
openNativeSelect(portfolioNativeSelectRef.current);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
await openPortfolioDialog();
|
|
577
|
+
}, [openPortfolioDialog]);
|
|
578
|
+
|
|
150
579
|
const cycleField = useCallback((delta: number) => {
|
|
151
580
|
setActiveField((current) => {
|
|
152
581
|
const index = fieldOrder.indexOf(current);
|
|
@@ -186,6 +615,8 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
186
615
|
xAccount: emptyToNull(current.xAccount),
|
|
187
616
|
sharedPortfolioId: emptyToNull(current.sharedPortfolioId),
|
|
188
617
|
acceptUnknownDms: current.acceptUnknownDms,
|
|
618
|
+
weeklyRoundupEnabled: current.weeklyRoundupEnabled,
|
|
619
|
+
positionAlertsEnabled: current.positionAlertsEnabled,
|
|
189
620
|
});
|
|
190
621
|
setProfile(nextProfile);
|
|
191
622
|
setDraft(profileToDraft(nextProfile));
|
|
@@ -201,12 +632,92 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
201
632
|
}
|
|
202
633
|
}, []);
|
|
203
634
|
|
|
635
|
+
const turnOffEmailAlerts = useCallback(async () => {
|
|
636
|
+
setActiveField("emailAlertsOffAction");
|
|
637
|
+
setBusy("alerts");
|
|
638
|
+
setMessage({ tone: "info", text: "Turning off email alerts..." });
|
|
639
|
+
try {
|
|
640
|
+
const settings = await apiClient.updateSyncSettings({
|
|
641
|
+
weeklyRoundupEnabled: false,
|
|
642
|
+
positionAlertsEnabled: false,
|
|
643
|
+
});
|
|
644
|
+
setDraft((current) => ({
|
|
645
|
+
...current,
|
|
646
|
+
weeklyRoundupEnabled: settings.weeklyRoundupEnabled,
|
|
647
|
+
positionAlertsEnabled: settings.positionAlertsEnabled,
|
|
648
|
+
}));
|
|
649
|
+
const nextProfile = await apiClient.getAccountProfile().catch(() => null);
|
|
650
|
+
if (nextProfile) setProfile(nextProfile);
|
|
651
|
+
setMessage({ tone: "success", text: "Email alerts are off." });
|
|
652
|
+
} catch (error) {
|
|
653
|
+
setMessage({
|
|
654
|
+
tone: "error",
|
|
655
|
+
text: error instanceof Error ? error.message : "Failed to update alert settings.",
|
|
656
|
+
});
|
|
657
|
+
} finally {
|
|
658
|
+
setBusy(null);
|
|
659
|
+
}
|
|
660
|
+
}, []);
|
|
661
|
+
|
|
662
|
+
const openUpgrade = useCallback(() => {
|
|
663
|
+
setActiveField("upgradeAction");
|
|
664
|
+
setBusy("billing");
|
|
665
|
+
setMessage({ tone: "info", text: "Opening Pro upgrade..." });
|
|
666
|
+
void renderer.openExternal(CLOUD_UPGRADE_URL)
|
|
667
|
+
.then(() => setMessage(null))
|
|
668
|
+
.catch((error) => {
|
|
669
|
+
setMessage({
|
|
670
|
+
tone: "error",
|
|
671
|
+
text: error instanceof Error ? error.message : "Failed to open upgrade page.",
|
|
672
|
+
});
|
|
673
|
+
})
|
|
674
|
+
.finally(() => setBusy(null));
|
|
675
|
+
}, [renderer]);
|
|
676
|
+
|
|
677
|
+
const deleteAccount = useCallback(async () => {
|
|
678
|
+
setActiveField("deleteAccountAction");
|
|
679
|
+
if (busy) return;
|
|
680
|
+
const confirmed = await dialog.prompt<boolean>({
|
|
681
|
+
closeOnClickOutside: false,
|
|
682
|
+
content: (context: PromptContext<boolean>) => (
|
|
683
|
+
<ConfirmDialog
|
|
684
|
+
{...context}
|
|
685
|
+
title="Delete Account"
|
|
686
|
+
body={[
|
|
687
|
+
"Delete your Gloom Cloud account?",
|
|
688
|
+
"This removes cloud profile, chat, sync, and billing-linked account data.",
|
|
689
|
+
]}
|
|
690
|
+
confirmLabel="Delete Account"
|
|
691
|
+
confirmVariant="danger"
|
|
692
|
+
/>
|
|
693
|
+
),
|
|
694
|
+
}).catch(() => false);
|
|
695
|
+
if (!confirmed) return;
|
|
696
|
+
|
|
697
|
+
setBusy("delete");
|
|
698
|
+
setMessage({ tone: "info", text: "Deleting account..." });
|
|
699
|
+
try {
|
|
700
|
+
await apiClient.deleteAccount();
|
|
701
|
+
setProfile(null);
|
|
702
|
+
setDraft(profileToDraft(null));
|
|
703
|
+
setHasSession(false);
|
|
704
|
+
await chatController.refreshSession().catch(() => {});
|
|
705
|
+
setMessage({ tone: "success", text: "Account deleted." });
|
|
706
|
+
} catch (error) {
|
|
707
|
+
setMessage({
|
|
708
|
+
tone: "error",
|
|
709
|
+
text: error instanceof Error ? error.message : "Failed to delete account.",
|
|
710
|
+
});
|
|
711
|
+
} finally {
|
|
712
|
+
setBusy(null);
|
|
713
|
+
}
|
|
714
|
+
}, [busy, dialog]);
|
|
715
|
+
|
|
204
716
|
useAccountManagementFooter({
|
|
205
717
|
busy,
|
|
206
718
|
draft,
|
|
207
719
|
hasSession,
|
|
208
720
|
message,
|
|
209
|
-
openPasswordDialog,
|
|
210
721
|
profile,
|
|
211
722
|
saveProfile,
|
|
212
723
|
});
|
|
@@ -215,12 +726,15 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
215
726
|
activeField,
|
|
216
727
|
cycleField,
|
|
217
728
|
cyclePortfolio,
|
|
729
|
+
deleteAccount,
|
|
218
730
|
draftRef,
|
|
219
731
|
focused,
|
|
220
732
|
openPasswordDialog,
|
|
221
|
-
openPortfolioDialog,
|
|
733
|
+
openPortfolioDialog: openPortfolioPicker,
|
|
734
|
+
openUpgrade,
|
|
222
735
|
saveProfile,
|
|
223
736
|
setDraftValue,
|
|
737
|
+
turnOffEmailAlerts,
|
|
224
738
|
});
|
|
225
739
|
|
|
226
740
|
if (!hasSession && !apiClient.getSessionToken()) {
|
|
@@ -232,149 +746,264 @@ export function AccountManagementPane({ focused, width, height }: PaneProps) {
|
|
|
232
746
|
}
|
|
233
747
|
|
|
234
748
|
return (
|
|
235
|
-
<Box flexDirection="column" width={width} height={height} paddingX={1}>
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
749
|
+
<Box flexDirection="column" width={width} height={height} paddingX={1} gap={1}>
|
|
750
|
+
<Tabs
|
|
751
|
+
tabs={ACCOUNT_TABS}
|
|
752
|
+
activeValue={activeTab}
|
|
753
|
+
onSelect={selectTab}
|
|
754
|
+
focused={focused}
|
|
755
|
+
variant="pill"
|
|
756
|
+
compact
|
|
757
|
+
keyboardNavigation={false}
|
|
758
|
+
/>
|
|
759
|
+
<ScrollBox height={Math.max(3, bodyHeight - 2)} scrollY focusable={false}>
|
|
760
|
+
<Box flexDirection="column" width={contentWidth} gap={1}>
|
|
761
|
+
{activeTab === "profile" ? (
|
|
762
|
+
<>
|
|
763
|
+
<FieldRow twoColumns={twoColumns}>
|
|
764
|
+
<CheckboxRow
|
|
765
|
+
label="Public Profile"
|
|
766
|
+
checked={draft.profilePublic}
|
|
767
|
+
active={activeField === "profilePublic"}
|
|
768
|
+
width={fieldWidth}
|
|
769
|
+
onFocus={() => setActiveField("profilePublic")}
|
|
770
|
+
onChange={(checked) => setDraftValue("profilePublic", checked)}
|
|
771
|
+
/>
|
|
772
|
+
<CheckboxRow
|
|
773
|
+
label="Incoming DMs"
|
|
774
|
+
checked={draft.acceptUnknownDms}
|
|
775
|
+
active={activeField === "acceptUnknownDms"}
|
|
776
|
+
width={fieldWidth}
|
|
777
|
+
onFocus={() => setActiveField("acceptUnknownDms")}
|
|
778
|
+
onChange={(checked) => setDraftValue("acceptUnknownDms", checked)}
|
|
779
|
+
/>
|
|
780
|
+
</FieldRow>
|
|
781
|
+
|
|
782
|
+
<FieldRow twoColumns={twoColumns}>
|
|
783
|
+
<AccountTextField
|
|
784
|
+
fieldKey="username"
|
|
785
|
+
label="Username"
|
|
786
|
+
value={draft.username}
|
|
787
|
+
placeholder="username"
|
|
788
|
+
activeField={activeField}
|
|
789
|
+
focused={focused}
|
|
790
|
+
width={fieldWidth}
|
|
791
|
+
onFocus={setActiveField}
|
|
792
|
+
onChange={(value) => setDraftValue("username", value)}
|
|
793
|
+
onSubmit={() => { void saveProfile(); }}
|
|
794
|
+
/>
|
|
795
|
+
<AccountTextField
|
|
796
|
+
fieldKey="name"
|
|
797
|
+
label="Full Name"
|
|
798
|
+
value={draft.name}
|
|
799
|
+
placeholder="Full name"
|
|
800
|
+
activeField={activeField}
|
|
801
|
+
focused={focused}
|
|
802
|
+
width={fieldWidth}
|
|
803
|
+
onFocus={setActiveField}
|
|
804
|
+
onChange={(value) => setDraftValue("name", value)}
|
|
805
|
+
onSubmit={() => { void saveProfile(); }}
|
|
806
|
+
/>
|
|
807
|
+
</FieldRow>
|
|
808
|
+
|
|
809
|
+
<FieldRow twoColumns={twoColumns}>
|
|
810
|
+
<AccountTextField
|
|
811
|
+
fieldKey="company"
|
|
812
|
+
label="Company"
|
|
813
|
+
value={draft.company}
|
|
814
|
+
placeholder="Company"
|
|
815
|
+
activeField={activeField}
|
|
816
|
+
focused={focused}
|
|
817
|
+
width={fieldWidth}
|
|
818
|
+
onFocus={setActiveField}
|
|
819
|
+
onChange={(value) => setDraftValue("company", value)}
|
|
820
|
+
onSubmit={() => { void saveProfile(); }}
|
|
821
|
+
/>
|
|
822
|
+
<AccountTextField
|
|
823
|
+
fieldKey="title"
|
|
824
|
+
label="Title"
|
|
825
|
+
value={draft.title}
|
|
826
|
+
placeholder="Title"
|
|
827
|
+
activeField={activeField}
|
|
828
|
+
focused={focused}
|
|
829
|
+
width={fieldWidth}
|
|
830
|
+
onFocus={setActiveField}
|
|
831
|
+
onChange={(value) => setDraftValue("title", value)}
|
|
832
|
+
onSubmit={() => { void saveProfile(); }}
|
|
833
|
+
/>
|
|
834
|
+
</FieldRow>
|
|
835
|
+
|
|
836
|
+
<FieldRow twoColumns={twoColumns}>
|
|
837
|
+
<AccountTextField
|
|
838
|
+
fieldKey="publicEmail"
|
|
839
|
+
label="Public Email"
|
|
840
|
+
value={draft.publicEmail}
|
|
841
|
+
placeholder="public@example.com"
|
|
842
|
+
activeField={activeField}
|
|
843
|
+
focused={focused}
|
|
844
|
+
width={fieldWidth}
|
|
845
|
+
onFocus={setActiveField}
|
|
846
|
+
onChange={(value) => setDraftValue("publicEmail", value)}
|
|
847
|
+
onSubmit={() => { void saveProfile(); }}
|
|
848
|
+
/>
|
|
849
|
+
<AccountTextField
|
|
850
|
+
fieldKey="xAccount"
|
|
851
|
+
label="X Account"
|
|
852
|
+
value={draft.xAccount}
|
|
853
|
+
placeholder="handle"
|
|
854
|
+
activeField={activeField}
|
|
855
|
+
focused={focused}
|
|
856
|
+
width={fieldWidth}
|
|
857
|
+
onFocus={setActiveField}
|
|
858
|
+
onChange={(value) => setDraftValue("xAccount", value)}
|
|
859
|
+
onSubmit={() => { void saveProfile(); }}
|
|
860
|
+
/>
|
|
861
|
+
</FieldRow>
|
|
862
|
+
|
|
863
|
+
<Box
|
|
864
|
+
flexDirection="row"
|
|
865
|
+
width={formWidth}
|
|
866
|
+
gap={1}
|
|
867
|
+
onMouseDown={() => setActiveField("bio")}
|
|
868
|
+
>
|
|
869
|
+
<Text
|
|
870
|
+
width={formLabelWidth}
|
|
871
|
+
fg={activeField === "bio" ? colors.textBright : colors.textDim}
|
|
872
|
+
attributes={activeField === "bio" ? TextAttributes.BOLD : 0}
|
|
873
|
+
>
|
|
874
|
+
{activeField === "bio" ? "> Bio" : " Bio"}
|
|
875
|
+
</Text>
|
|
876
|
+
<Box
|
|
877
|
+
height={3}
|
|
878
|
+
width={Math.max(18, formWidth - formLabelWidth - 1)}
|
|
879
|
+
border
|
|
880
|
+
borderColor={activeField === "bio" ? colors.borderFocused : colors.border}
|
|
881
|
+
backgroundColor={colors.panel}
|
|
882
|
+
style={{
|
|
883
|
+
borderRadius: 6,
|
|
884
|
+
overflow: "hidden",
|
|
885
|
+
}}
|
|
886
|
+
>
|
|
887
|
+
<Textarea
|
|
888
|
+
key={`bio:${profile?.updatedAt ?? "empty"}`}
|
|
889
|
+
ref={bioRef}
|
|
890
|
+
initialValue={draft.bio}
|
|
891
|
+
placeholder="Short profile bio"
|
|
892
|
+
focused={focused && activeField === "bio"}
|
|
893
|
+
textColor={colors.text}
|
|
894
|
+
placeholderColor={colors.textDim}
|
|
895
|
+
backgroundColor={colors.panel}
|
|
896
|
+
flexGrow={1}
|
|
897
|
+
wrapText
|
|
898
|
+
onInput={(value: string) => setDraftValue("bio", value)}
|
|
899
|
+
/>
|
|
900
|
+
</Box>
|
|
901
|
+
</Box>
|
|
902
|
+
|
|
903
|
+
<PublicAnalyticsGroup
|
|
904
|
+
preview={publicAnalyticsPreview}
|
|
905
|
+
choices={portfolioChoices}
|
|
906
|
+
value={draft.sharedPortfolioId || NO_PORTFOLIO_VALUE}
|
|
907
|
+
label={selectedPortfolioLabel(portfolios, draft.sharedPortfolioId)}
|
|
908
|
+
detail={profileAnalyticsDetail}
|
|
909
|
+
active={activeField === "sharedPortfolioId"}
|
|
910
|
+
width={formWidth}
|
|
911
|
+
disclaimer={draft.sharedPortfolioId ? "Only 1Y return and SPY Beta are shared. Positions are not shared." : null}
|
|
912
|
+
selectRef={(element) => {
|
|
913
|
+
portfolioNativeSelectRef.current = element;
|
|
914
|
+
}}
|
|
915
|
+
onFocus={() => setActiveField("sharedPortfolioId")}
|
|
916
|
+
onSelect={(selected) => {
|
|
917
|
+
setActiveField("sharedPortfolioId");
|
|
918
|
+
setDraftValue("sharedPortfolioId", selected === NO_PORTFOLIO_VALUE ? "" : selected);
|
|
919
|
+
}}
|
|
920
|
+
onOpen={() => { void openPortfolioPicker(); }}
|
|
336
921
|
/>
|
|
337
|
-
</Box>
|
|
338
|
-
</Box>
|
|
339
922
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
923
|
+
<Box flexDirection="row" gap={1}>
|
|
924
|
+
<Button label={busy === "profile" ? "Saving..." : "Save Profile"} variant="primary" onPress={() => { void saveProfile(); }} disabled={!!busy} />
|
|
925
|
+
</Box>
|
|
926
|
+
</>
|
|
927
|
+
) : null}
|
|
928
|
+
|
|
929
|
+
{activeTab === "emails" ? (
|
|
930
|
+
<>
|
|
931
|
+
<CheckboxRow
|
|
932
|
+
label="Weekly Roundup"
|
|
933
|
+
checked={draft.weeklyRoundupEnabled}
|
|
934
|
+
active={activeField === "weeklyRoundupEnabled"}
|
|
935
|
+
description="Friday after market close."
|
|
936
|
+
width={formWidth}
|
|
937
|
+
onFocus={() => setActiveField("weeklyRoundupEnabled")}
|
|
938
|
+
onChange={(checked) => setDraftValue("weeklyRoundupEnabled", checked)}
|
|
939
|
+
/>
|
|
940
|
+
<CheckboxRow
|
|
941
|
+
label="Smart Alerts"
|
|
942
|
+
checked={draft.positionAlertsEnabled}
|
|
943
|
+
active={activeField === "positionAlertsEnabled"}
|
|
944
|
+
description="Unusual portfolio or watchlist moves."
|
|
945
|
+
width={formWidth}
|
|
946
|
+
onFocus={() => setActiveField("positionAlertsEnabled")}
|
|
947
|
+
onChange={(checked) => setDraftValue("positionAlertsEnabled", checked)}
|
|
948
|
+
/>
|
|
949
|
+
<Box flexDirection="row" gap={1} style={{ marginTop: 8 }}>
|
|
950
|
+
<Button
|
|
951
|
+
label={busy === "alerts" ? "Turning Off..." : "Turn Off Both"}
|
|
952
|
+
active={activeField === "emailAlertsOffAction"}
|
|
953
|
+
onPress={() => { void turnOffEmailAlerts(); }}
|
|
954
|
+
disabled={!!busy || (!draft.weeklyRoundupEnabled && !draft.positionAlertsEnabled)}
|
|
955
|
+
/>
|
|
956
|
+
<Button label={busy === "profile" ? "Saving..." : "Save"} variant="primary" onPress={() => { void saveProfile(); }} disabled={!!busy} />
|
|
957
|
+
</Box>
|
|
958
|
+
</>
|
|
959
|
+
) : null}
|
|
960
|
+
|
|
961
|
+
{activeTab === "pro" ? (
|
|
962
|
+
<>
|
|
963
|
+
<Box flexDirection="row" gap={1}>
|
|
964
|
+
<Text fg={colors.textDim}>Status</Text>
|
|
965
|
+
<Text fg={profile?.plan === "pro" ? colors.positive : colors.textBright} attributes={TextAttributes.BOLD}>
|
|
966
|
+
{formatPlan(profile?.plan)}
|
|
967
|
+
</Text>
|
|
968
|
+
{profile?.email ? <Text fg={colors.textMuted}>{profile.email}</Text> : null}
|
|
969
|
+
</Box>
|
|
970
|
+
<PlanComparison
|
|
971
|
+
width={contentWidth}
|
|
972
|
+
activePlan={profile?.plan === "pro" ? "pro" : "free"}
|
|
973
|
+
upgradeButton={(
|
|
974
|
+
<Button
|
|
975
|
+
label={profile?.plan === "pro" ? "Manage Pro" : busy === "billing" ? "Opening..." : "Upgrade to Pro"}
|
|
976
|
+
variant={profile?.plan === "pro" ? "secondary" : "primary"}
|
|
977
|
+
width={isDesktop ? 28 : undefined}
|
|
978
|
+
height={isDesktop ? "28px" : undefined}
|
|
979
|
+
active={activeField === "upgradeAction"}
|
|
980
|
+
onPress={openUpgrade}
|
|
981
|
+
disabled={!!busy}
|
|
982
|
+
/>
|
|
983
|
+
)}
|
|
984
|
+
/>
|
|
985
|
+
</>
|
|
986
|
+
) : null}
|
|
987
|
+
|
|
988
|
+
{activeTab === "advanced" ? (
|
|
989
|
+
<>
|
|
990
|
+
<Box flexDirection="row" gap={1}>
|
|
991
|
+
<Button
|
|
992
|
+
label="Change Password"
|
|
993
|
+
active={activeField === "passwordAction"}
|
|
994
|
+
onPress={openPasswordDialog}
|
|
995
|
+
disabled={!!busy}
|
|
996
|
+
/>
|
|
997
|
+
<Button
|
|
998
|
+
label={busy === "delete" ? "Deleting..." : "Delete Account"}
|
|
999
|
+
variant="danger"
|
|
1000
|
+
active={activeField === "deleteAccountAction"}
|
|
1001
|
+
onPress={() => { void deleteAccount(); }}
|
|
1002
|
+
disabled={!!busy}
|
|
1003
|
+
/>
|
|
1004
|
+
</Box>
|
|
1005
|
+
</>
|
|
1006
|
+
) : null}
|
|
378
1007
|
<Box height={1} />
|
|
379
1008
|
</Box>
|
|
380
1009
|
</ScrollBox>
|