Merge pull request #708 from storytold/feaute/app-state-api

implemented AppState API, replaced old code
This commit is contained in:
Victor O authored and GitHub committed 2024-09-24 14:58:00 -07:00
commit d31f5ba5f3
17 files changed
+591 -444

No files matched your search

@@ -0,0 +1,86 @@
import MakeRequest from "../MakeRequest";
import { UserDetailsLight } from "../_common/UserDetailsLight";
export interface UserLocale {
full_language_tags: [string, string];
language_codes: [string, string];
}
export interface ServerAlert {
maybe_category: string;
maybe_message: string;
}
export interface ActiveSubscription {
namespace: string;
product_slug: string;
}
export interface PremiumStatus {
active_subscriptions: ActiveSubscription[];
has_free_premium: boolean;
has_paid_premium: boolean;
has_premium: boolean;
maybe_loyalty_program: string | null;
}
export type FeatureFlags =
| "explore_media"
| "studio"
| "upload_3d"
| "video_style_transfer";
export interface UserPermissions {
feature_flags: FeatureFlags[];
is_moderator: boolean;
legacy_permission_flags: {
can_approve_w2l_templates: boolean;
can_ban_users: boolean;
can_delete_other_users_tts_models: boolean;
can_delete_other_users_tts_results: boolean;
can_delete_other_users_w2l_results: boolean;
can_delete_other_users_w2l_templates: boolean;
can_delete_own_account: boolean;
can_delete_own_tts_models: boolean;
can_delete_own_tts_results: boolean;
can_delete_own_w2l_results: boolean;
can_delete_own_w2l_templates: boolean;
can_delete_users: boolean;
can_edit_other_users_profiles: boolean;
can_edit_other_users_tts_models: boolean;
can_edit_other_users_w2l_templates: boolean;
can_upload_tts_models: boolean;
can_upload_w2l_templates: boolean;
can_use_tts: boolean;
can_use_w2l: boolean;
};
}
export interface GetAppStateRequest {}
export interface GetAppStateResponse {
is_banned: boolean;
is_logged_in: boolean;
locale: UserLocale;
maybe_alert: ServerAlert | null;
maybe_premium: PremiumStatus | null;
maybe_user_info: UserDetailsLight | null;
permissions: UserPermissions;
refresh_interval_millis: number;
server_info: {
build_sha: string;
build_sha_short: string;
hostname: string;
};
success: boolean;
}
export const GetAppState = MakeRequest<
string,
GetAppStateRequest,
GetAppStateResponse,
{}
>({
method: "GET",
routingFunction: () => `/v1/app_state`,
});
@@ -11,26 +11,35 @@ const FAKEYOU_NAMESPACE = "fakeyou";
export class SessionSubscriptionsWrapper {
listActiveSubscriptionResponse?: ListActiveSubscriptionsSuccessResponse;
private constructor(listActiveSubscriptionsSuccessResponse?: ListActiveSubscriptionsSuccessResponse) {
private constructor(
listActiveSubscriptionsSuccessResponse?: ListActiveSubscriptionsSuccessResponse
) {
if (listActiveSubscriptionsSuccessResponse !== undefined) {
this.listActiveSubscriptionResponse = listActiveSubscriptionsSuccessResponse;
this.listActiveSubscriptionResponse =
listActiveSubscriptionsSuccessResponse;
}
}
public static async lookupActiveSubscriptions() : Promise<SessionSubscriptionsWrapper> {
public static async lookupActiveSubscriptions(): Promise<SessionSubscriptionsWrapper> {
let response = await ListActiveSubscriptions();
if (ListActiveSubscriptionsIsSuccess(response)) {
return new SessionSubscriptionsWrapper(response);
return SessionSubscriptionsWrapper.wrapResponse(response);
} else {
return SessionSubscriptionsWrapper.emptySubscriptions();
}
}
public static emptySubscriptions() : SessionSubscriptionsWrapper {
public static emptySubscriptions(): SessionSubscriptionsWrapper {
return new SessionSubscriptionsWrapper();
}
public ttsMaximumLength() : number {
public static wrapResponse(
sessionStateResponse: ListActiveSubscriptionsSuccessResponse
): SessionSubscriptionsWrapper {
return new SessionSubscriptionsWrapper(sessionStateResponse);
}
public ttsMaximumLength(): number {
if (this.hasActiveEliteSubscription()) {
return 4096;
} else if (this.hasActiveProSubscription()) {
@@ -43,37 +52,50 @@ export class SessionSubscriptionsWrapper {
return 1024;
}
public hasFreeOrPaidPremiumFeatures() : boolean {
public hasFreeOrPaidPremiumFeatures(): boolean {
return this.hasLoyaltyProgram() || this.hasPaidFeatures();
}
public hasLoyaltyProgram() : boolean {
public hasLoyaltyProgram(): boolean {
return !!this.listActiveSubscriptionResponse?.maybe_loyalty_program;
}
public hasPaidFeatures() : boolean {
const subs = this.listActiveSubscriptionResponse?.active_subscriptions || [];
public hasPaidFeatures(): boolean {
const subs =
this.listActiveSubscriptionResponse?.active_subscriptions || [];
return subs.length > 0;
}
public hasActivePlusSubscription() : boolean {
let maybePlan = FakeYouFrontendEnvironment.getInstance().useProductionStripePlans() ?
this.findActiveSubscription(FAKEYOU_NAMESPACE, "fakeyou_plus") :
this.findActiveSubscription(FAKEYOU_NAMESPACE, "development_fakeyou_plus");
public hasActivePlusSubscription(): boolean {
let maybePlan =
FakeYouFrontendEnvironment.getInstance().useProductionStripePlans()
? this.findActiveSubscription(FAKEYOU_NAMESPACE, "fakeyou_plus")
: this.findActiveSubscription(
FAKEYOU_NAMESPACE,
"development_fakeyou_plus"
);
return maybePlan !== undefined;
}
public hasActiveProSubscription() : boolean {
let maybePlan = FakeYouFrontendEnvironment.getInstance().useProductionStripePlans() ?
this.findActiveSubscription(FAKEYOU_NAMESPACE, "fakeyou_pro") :
this.findActiveSubscription(FAKEYOU_NAMESPACE, "development_fakeyou_pro");
public hasActiveProSubscription(): boolean {
let maybePlan =
FakeYouFrontendEnvironment.getInstance().useProductionStripePlans()
? this.findActiveSubscription(FAKEYOU_NAMESPACE, "fakeyou_pro")
: this.findActiveSubscription(
FAKEYOU_NAMESPACE,
"development_fakeyou_pro"
);
return maybePlan !== undefined;
}
public hasActiveEliteSubscription() : boolean {
let maybePlan = FakeYouFrontendEnvironment.getInstance().useProductionStripePlans() ?
this.findActiveSubscription(FAKEYOU_NAMESPACE, "fakeyou_elite") :
this.findActiveSubscription(FAKEYOU_NAMESPACE, "development_fakeyou_elite");
public hasActiveEliteSubscription(): boolean {
let maybePlan =
FakeYouFrontendEnvironment.getInstance().useProductionStripePlans()
? this.findActiveSubscription(FAKEYOU_NAMESPACE, "fakeyou_elite")
: this.findActiveSubscription(
FAKEYOU_NAMESPACE,
"development_fakeyou_elite"
);
return maybePlan !== undefined;
}
@@ -95,7 +117,7 @@ export class SessionSubscriptionsWrapper {
const subs =
this.listActiveSubscriptionResponse?.active_subscriptions || [];
return subs.find(
(sub) => sub.namespace === namespace && sub.product_slug === product_slug
sub => sub.namespace === namespace && sub.product_slug === product_slug
);
}
}
+11 -202
View File
@@ -2,28 +2,13 @@ import "./AppNew.scss";
import "scss/custom-bootstrap.scss";
import React from "react";
import { ApiConfig } from "@storyteller/components";
import Cookies from "universal-cookie";
import {
DetectLocale,
DetectLocaleIsOk,
} from "@storyteller/components/src/api/locale/DetectLocale";
import { BrowserRouter, Route, Switch } from "react-router-dom";
import { BrowserRouter } from "react-router-dom";
import PageContainer from "./v2/view/PageContainer";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { PosthogClient } from "@storyteller/components/src/analytics/PosthogClient";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { TtsInferenceJob } from "@storyteller/components/src/jobs/TtsInferenceJobs";
import { W2lInferenceJob } from "@storyteller/components/src/jobs/W2lInferenceJobs";
import { FAKEYOU_MERGED_TRANSLATIONS } from "./_i18n/FakeYouTranslations";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import deepEqual from "deep-equal";
import {
AvailableLanguageKey,
AVAILABLE_LANGUAGE_MAP,
ENGLISH_LANGUAGE,
} from "./_i18n/AvailableLanguageMap";
import { VoiceConversionModelListItem } from "@storyteller/components/src/api/voice_conversion/ListVoiceConversionModels";
import HttpBackend from "i18next-http-backend";
@@ -77,33 +62,7 @@ enum MigrationMode {
interface Props {}
interface State {
// Migration Mode
// migrationMode: MigrationMode;
// Rollout of vocodes 2.0
// enableAlpha: boolean;
sessionFetched: boolean;
sessionWrapper: SessionWrapper;
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
// Locale + show flash notice to Spanish speakers
//localeLanguageCodes: string[],
//localeFullLanguageTags: string[],
// isShowingLanguageNotice: boolean;
// displayLanguage: Language;
// primaryLanguageCode: string;
// isShowingTwitchTtsNotice: boolean;
// isShowingPleaseFollowNotice: boolean;
// An improved notice for "new" languages asking users to help.
// isShowingBootstrapLanguageNotice: boolean;
// Current text entered
textBuffer: string;
// voiceConversionModels: VoiceConversionModelListItem[];
maybeSelectedVoiceConversionModel?: VoiceConversionModelListItem;
}
@@ -113,11 +72,6 @@ class App extends React.Component<Props, State> {
super(props);
this.state = {
sessionFetched: false,
sessionWrapper: SessionWrapper.emptySession(),
sessionSubscriptionsWrapper:
SessionSubscriptionsWrapper.emptySubscriptions(),
textBuffer: "",
maybeSelectedVoiceConversionModel: undefined,
@@ -141,132 +95,6 @@ class App extends React.Component<Props, State> {
require("./v2/view/_css/footer.scss");
}
async componentDidMount() {
await this.queryLanguage();
await this.querySession();
await this.querySessionSubscriptions();
setInterval(async () => {
// See warnings in the following methods when adding new methods
// that affect global "state"
await this.querySession();
await this.querySessionSubscriptions();
}, 60000);
// TODO: Use websockets, this is dumb
}
querySession = async () => {
// WARNING: Making setState calls in this scope without checking existing
// state can cause the whole site to refresh/worsen UX. Double check if
// state needs to be set here, or if instead can be refreshed locally on
// the page where the new "state" needed
const sessionWrapper = await SessionWrapper.lookupSession();
const username = sessionWrapper.getDisplayName();
const cookies = new Cookies();
if (username !== undefined) {
// Track only logged-in users (for now)
PosthogClient.enablePosthog();
PosthogClient.setUsername(username);
cookies.set("logged_in_username", username, {
path: "/",
expires: new Date(Date.now() + 3 * 86400000),
});
window.dataLayer.push({
user_id: username,
});
} else {
cookies.remove("logged_in_username", { path: "/" });
}
if (!deepEqual(sessionWrapper, this.state.sessionWrapper)) {
this.setState({
sessionWrapper: sessionWrapper,
});
}
};
querySessionSubscriptions = async () => {
// WARNING: Making setState calls in this scope without checking existing
// state can cause the whole site to refresh/worsen UX. Double check if
// state needs to be set here, or if instead can be refreshed locally on
// the page where the new "state" needed
if (this.state.sessionFetched === false) {
this.setState({ sessionFetched: true });
}
const cookies = new Cookies();
const sessionSubscriptionsWrapper =
await SessionSubscriptionsWrapper.lookupActiveSubscriptions();
const plan = sessionSubscriptionsWrapper.getActiveProductSlug();
if (plan !== undefined) {
cookies.set("logged_in_user_plan", plan, {
path: "/",
expires: new Date(Date.now() + 3 * 86400000),
});
} else {
cookies.remove("logged_in_user_plan", { path: "/" });
}
if (
!deepEqual(
sessionSubscriptionsWrapper,
this.state.sessionSubscriptionsWrapper
)
) {
this.setState({
sessionSubscriptionsWrapper: sessionSubscriptionsWrapper,
});
}
};
queryLanguage = async () => {
let locale = await DetectLocale();
if (DetectLocaleIsOk(locale)) {
// NB: We treat the language preference as being the order in the array.
// As of 2023-01-14, the backend does not handle quality values / q-weights,
// so these may be slightly wrong. An adjustment to the server will fix this.
let preferredLanguage = ENGLISH_LANGUAGE;
for (let languageCode of locale.language_codes) {
let maybeLanguage =
AVAILABLE_LANGUAGE_MAP[languageCode as AvailableLanguageKey];
if (maybeLanguage !== undefined) {
preferredLanguage = maybeLanguage;
break;
}
}
i18n.changeLanguage(preferredLanguage.languageCode);
i18n2.changeLanguage(preferredLanguage.languageCode);
}
};
logoutSession = () => {
const api = new ApiConfig();
const endpointUrl = api.logout();
fetch(endpointUrl, {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
})
.then(_raw_response => {
this.querySession();
this.querySessionSubscriptions();
})
.catch(e => {
/* Ignore. */
});
};
setTextBuffer = (textBuffer: string) => {
this.setState({ textBuffer: textBuffer });
};
@@ -280,36 +108,17 @@ class App extends React.Component<Props, State> {
<BrowserRouter>
<div id="main" className="bg-gradient">
<div id="viewable">
{/* This is the old vocodes1.0-compatible username and version switch
<MigrationTopNav
enableAlpha={this.state.enableAlpha}
sessionWrapper={this.state.sessionWrapper}
querySessionAction={this.querySession}
/>
*/}
<div className="migrationComponentWrapper">
<CoreServicesProvider
{...{
querySession: this.querySession,
querySubscriptions: this.querySessionSubscriptions,
state: this.state,
}}
>
<Switch>
<Route path="/">
<PageContainer
textBuffer={this.state.textBuffer}
setTextBuffer={this.setTextBuffer}
clearTextBuffer={this.clearTextBuffer}
maybeSelectedVoiceConversionModel={
this.state.maybeSelectedVoiceConversionModel
}
/>
</Route>
</Switch>
<FooterNav sessionWrapper={this.state.sessionWrapper} />
<CoreServicesProvider>
<PageContainer
textBuffer={this.state.textBuffer}
setTextBuffer={this.setTextBuffer}
clearTextBuffer={this.clearTextBuffer}
maybeSelectedVoiceConversionModel={
this.state.maybeSelectedVoiceConversionModel
}
/>
<FooterNav />
</CoreServicesProvider>
</div>
</div>
@@ -1,41 +1,52 @@
import { useState } from "react";
import { useChanger, useSession } from "hooks";
import { useContext, useState } from "react";
import { useChanger } from "hooks";
import { FetchStatus } from "@storyteller/components/src/api/_common/SharedFetchTypes";
import { CreateSession, CreateSessionResponse } from "@storyteller/components/src/api/account/CreateSession";
import {
CreateSession,
CreateSessionResponse,
} from "@storyteller/components/src/api/account/CreateSession";
import { AppStateContext } from "components/providers/AppStateProvider";
interface Props {
onSuccess?: (x?:any) => any,
status: FetchStatus,
statusSet: (x: FetchStatus) => void,
onSuccess?: (x?: any) => any;
status: FetchStatus;
statusSet: (x: FetchStatus) => void;
}
export default function useLogin({ onSuccess, status, statusSet }: Props) {
const [errorType,errorTypeSet] = useState("");
const { querySession, querySubscriptions } = useSession();
const [errorType, errorTypeSet] = useState("");
const { queryAppState } = useContext(AppStateContext);
const { allAreValid, setProps, state, update } = useChanger({
password: { value: "" },
usernameOrEmail: { value: "" }
usernameOrEmail: { value: "" },
});
const login = () => {
errorTypeSet("");
statusSet(FetchStatus.in_progress);
// if (allAreValid()) {
CreateSession("",{
username_or_email: state.usernameOrEmail.value,
password: state.password.value,
}).then((res: CreateSessionResponse) => {
if (!res.success && res.error_type) {
statusSet(FetchStatus.error);
errorTypeSet(res.error_type);
} else if (res.success) {
statusSet(FetchStatus.success);
querySession();
querySubscriptions();
if (onSuccess) onSuccess(res);
}
});
};
CreateSession("", {
username_or_email: state.usernameOrEmail.value,
password: state.password.value,
}).then((res: CreateSessionResponse) => {
if (!res.success && res.error_type) {
statusSet(FetchStatus.error);
errorTypeSet(res.error_type);
} else if (res.success) {
statusSet(FetchStatus.success);
queryAppState();
if (onSuccess) onSuccess(res);
}
});
};
return { allAreValid, errorType, errorTypeSet, login, setProps, state, update };
};
return {
allAreValid,
errorType,
errorTypeSet,
login,
setProps,
state,
update,
};
}
@@ -1,23 +1,27 @@
import { useChanger, useSession } from "hooks";
import { useContext } from "react";
import { useChanger } from "hooks";
import { FetchStatus } from "@storyteller/components/src/api/_common/SharedFetchTypes";
import { CreateAccount, CreateAccountResponse } from "@storyteller/components/src/api/account/CreateAccount";
import {
CreateAccount,
CreateAccountResponse,
} from "@storyteller/components/src/api/account/CreateAccount";
import { InputValidation, ValidatorCallbacks } from "common/InputValidation";
import { AppStateContext } from "components/providers/AppStateProvider";
enum EmailReasons {
EmailTaken = "emailTaken",
Invalid = "invalid",
TooShort = "tooShort",
BadInput = "badInput"
BadInput = "badInput",
}
enum PasswordReasons {
TooShort = "tooShort"
TooShort = "tooShort",
}
enum PasswordConfirmReasons {
PasswordMismatch = "passwordMismatch",
TooShort = "tooShort"
TooShort = "tooShort",
}
enum UsernameReasons {
@@ -26,14 +30,14 @@ enum UsernameReasons {
IsReserved = "isReserved",
IsTaken = "isTaken",
TooLong = "tooLong",
TooShort = "tooShort"
TooShort = "tooShort",
}
const emailErrors = {
emailTaken: "Email is taken",
invalid: "Email is invalid",
tooShort: "Email is too short",
badInput: "Bad email input, try another"
badInput: "Bad email input, try another",
};
const passwordErrors = {
@@ -42,7 +46,7 @@ const passwordErrors = {
const passwordConfirmErrors = {
passwordMismatch: "Passwords do not match",
tooShort: "Password is too short"
tooShort: "Password is too short",
};
const usernameErrors = {
@@ -51,42 +55,45 @@ const usernameErrors = {
isReserved: "Username is reserved",
isTaken: "Username is taken",
tooLong: "Username is too long",
tooShort: "Username is too short"
tooShort: "Username is too short",
};
const emailValidator = ({ inputValue }: ValidatorCallbacks) => {
let reason = "";
let validation = InputValidation.Neutral;
if (inputValue.length > 1) {
if (inputValue.length < 3) {
validation = InputValidation.Invalid;
reason = EmailReasons.TooShort;
} else if (!inputValue.includes("@")) {
validation = InputValidation.Invalid;
reason = EmailReasons.Invalid;
} else {
validation = InputValidation.Valid;
}
if (inputValue.length > 1) {
if (inputValue.length < 3) {
validation = InputValidation.Invalid;
reason = EmailReasons.TooShort;
} else if (!inputValue.includes("@")) {
validation = InputValidation.Invalid;
reason = EmailReasons.Invalid;
} else {
validation = InputValidation.Valid;
}
}
return { value: inputValue, reason, validation };
};
const passwordConfirmValidator = ({ inputValue, state }: ValidatorCallbacks) => {
const passwordConfirmValidator = ({
inputValue,
state,
}: ValidatorCallbacks) => {
const password = state.password;
let validation = InputValidation.Neutral;
let reason = "";
if (inputValue.length > 1) {
if (inputValue !== password.value) {
validation = InputValidation.Invalid;
reason = PasswordConfirmReasons.PasswordMismatch;
} else {
validation = InputValidation.Valid;
reason = "";
}
if (inputValue.length > 1) {
if (inputValue !== password.value) {
validation = InputValidation.Invalid;
reason = PasswordConfirmReasons.PasswordMismatch;
} else {
validation = InputValidation.Valid;
reason = "";
}
}
return { value: inputValue, reason, validation };
};
@@ -108,14 +115,20 @@ const passwordValidator = ({ inputValue, state }: ValidatorCallbacks) => {
if (inputValue !== passwordConfirm.value) {
confirmValidation = InputValidation.Invalid;
confirmReason = PasswordConfirmReasons.PasswordMismatch
confirmReason = PasswordConfirmReasons.PasswordMismatch;
} else {
confirmValidation = InputValidation.Valid;
confirmReason = "";
}
}
const additional = { passwordConfirm: { ...passwordConfirm, reason: confirmReason, validation: confirmValidation } }
const additional = {
passwordConfirm: {
...passwordConfirm,
reason: confirmReason,
validation: confirmValidation,
},
};
return { additional, value: inputValue, reason, validation };
};
@@ -123,14 +136,14 @@ const passwordValidator = ({ inputValue, state }: ValidatorCallbacks) => {
const usernameValidator = ({ inputValue }: ValidatorCallbacks) => {
let reason = "";
let validation = InputValidation.Neutral;
if (inputValue.length > 1) {
if (inputValue.length < 3) {
validation = InputValidation.Invalid;
reason = UsernameReasons.TooShort
reason = UsernameReasons.TooShort;
} else if (inputValue.length > 15) {
validation = InputValidation.Invalid;
reason = UsernameReasons.TooLong
reason = UsernameReasons.TooLong;
} else {
validation = InputValidation.Valid;
}
@@ -140,73 +153,98 @@ const usernameValidator = ({ inputValue }: ValidatorCallbacks) => {
};
interface Props {
onSuccess?: (x?:any) => any,
status: FetchStatus,
statusSet: (x: FetchStatus) => void,
onSuccess?: (x?: any) => any;
status: FetchStatus;
statusSet: (x: FetchStatus) => void;
}
export default function useSignup({ onSuccess, status, statusSet }: Props) {
const { querySession } = useSession();
const { queryAppState } = useContext(AppStateContext);
const { allAreValid, setProps, state, update } = useChanger({
email: {
errorText: emailErrors,
validator: emailValidator,
value: ""
value: "",
},
password: {
errorText: passwordErrors,
validator: passwordValidator,
value: ""
value: "",
},
passwordConfirm: {
errorText: passwordConfirmErrors,
validator: passwordConfirmValidator,
value: ""
value: "",
},
username: {
errorText: usernameErrors,
validator: usernameValidator,
value: ""
value: "",
},
});
const signup = () => {
statusSet(FetchStatus.in_progress);
CreateAccount("",{
CreateAccount("", {
username: state.username.value,
email_address: state.email.value,
password: state.password.value,
password_confirmation: state.passwordConfirm.value,
}).then((res: CreateAccountResponse) => {
if (res && res.error_fields && res.error_type) {
statusSet(FetchStatus.error);
statusSet(FetchStatus.error);
if (res.error_fields.email_address) {
switch (res.error_type) {
case "BadInput": return update({ name: "email", reason: EmailReasons.BadInput, validation: InputValidation.Invalid });
case "EmailTaken": return update({ name: "email", reason: EmailReasons.EmailTaken, validation: InputValidation.Invalid });
case "BadInput":
return update({
name: "email",
reason: EmailReasons.BadInput,
validation: InputValidation.Invalid,
});
case "EmailTaken":
return update({
name: "email",
reason: EmailReasons.EmailTaken,
validation: InputValidation.Invalid,
});
}
return;
} else if (res.error_fields.password) {
switch (res.error_type) {
case "TooShort": update({ name: "password", reason: PasswordReasons.TooShort, validation: InputValidation.Invalid });
}
case "TooShort":
update({
name: "password",
reason: PasswordReasons.TooShort,
validation: InputValidation.Invalid,
});
}
} else if (res.error_fields?.username) {
let updateUsername = (reason: UsernameReasons) => update({ reason, name: "username", validation: InputValidation.Invalid });
let updateUsername = (reason: UsernameReasons) =>
update({
reason,
name: "username",
validation: InputValidation.Invalid,
});
switch (res.error_fields.username) {
case "invalid username characters": return updateUsername(UsernameReasons.InvalidCharacters);
case "username is too long": return updateUsername(UsernameReasons.TooLong);
case "username is taken": return updateUsername(UsernameReasons.IsTaken);
case "username is reserved": return updateUsername(UsernameReasons.IsReserved);
case "username contains slurs": return updateUsername(UsernameReasons.ContainsSlurs);
}
case "invalid username characters":
return updateUsername(UsernameReasons.InvalidCharacters);
case "username is too long":
return updateUsername(UsernameReasons.TooLong);
case "username is taken":
return updateUsername(UsernameReasons.IsTaken);
case "username is reserved":
return updateUsername(UsernameReasons.IsReserved);
case "username contains slurs":
return updateUsername(UsernameReasons.ContainsSlurs);
}
}
} else if (res.success) {
statusSet(FetchStatus.success);
querySession();
queryAppState();
if (onSuccess) onSuccess(res);
}
}
});
};
return { allAreValid, setProps, signup, state, update };
};
}
@@ -37,8 +37,7 @@ import { GetDiscordLink } from "@storyteller/components/src/env/GetDiscordLink";
import { WebUrl } from "common/WebUrl";
export default function TopNav() {
const { querySession, querySubscriptions, sessionWrapper, user } =
useSession();
const { queryAppState, sessionWrapper, user } = useSession();
const domain = useDomainConfig();
let history = useHistory();
const [isMobileSearchBarVisible, setIsMobileSearchBarVisible] =
@@ -141,8 +140,7 @@ export default function TopNav() {
const logoutHandler = async () => {
await Logout();
querySession();
querySubscriptions();
queryAppState();
history.push("/");
};
@@ -152,8 +150,8 @@ export default function TopNav() {
let displayName = user.display_name;
let username = user.username;
let emailHash = user.email_gravatar_hash;
let avatarIndex = user.core_info.default_avatar.image_index;
let backgroundColorIndex = user.core_info.default_avatar.color_index;
let avatarIndex = user.core_info?.default_avatar?.image_index || 0;
let backgroundColorIndex = user.core_info?.default_avatar?.color_index || 0;
profileDropdown = (
<ProfileDropdown
@@ -0,0 +1,229 @@
import React, { createContext, useEffect, useState } from "react";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import Cookies from "universal-cookie";
import { PosthogClient } from "@storyteller/components/src/analytics/PosthogClient";
import deepEqual from "deep-equal";
import { i18n2 } from "App";
import {
AvailableLanguageKey,
AVAILABLE_LANGUAGE_MAP,
ENGLISH_LANGUAGE,
} from "_i18n/AvailableLanguageMap";
import {
GetAppState,
GetAppStateResponse,
} from "@storyteller/components/src/api/app_state/GetAppState";
import { useInterval } from "hooks";
interface AppStatusProviderProps {
children: any;
debug?: string;
}
interface CurrentAppState {
appState: GetAppStateResponse;
sessionWrapper: SessionWrapper;
sessionSubscriptions: SessionSubscriptionsWrapper;
}
interface AppStateUtilities extends CurrentAppState {
queryAppState: () => void;
}
export const emptyAppState: GetAppStateResponse = {
is_banned: false,
is_logged_in: false,
locale: {
full_language_tags: ["en-US", "en"],
language_codes: ["en", "en"],
},
maybe_alert: null,
maybe_premium: null,
maybe_user_info: null,
permissions: {
feature_flags: [],
is_moderator: false,
legacy_permission_flags: {
can_approve_w2l_templates: false,
can_ban_users: false,
can_delete_other_users_tts_models: false,
can_delete_other_users_tts_results: false,
can_delete_other_users_w2l_results: false,
can_delete_other_users_w2l_templates: false,
can_delete_own_account: false,
can_delete_own_tts_models: false,
can_delete_own_tts_results: false,
can_delete_own_w2l_results: false,
can_delete_own_w2l_templates: false,
can_delete_users: false,
can_edit_other_users_profiles: false,
can_edit_other_users_tts_models: false,
can_edit_other_users_w2l_templates: false,
can_upload_tts_models: false,
can_upload_w2l_templates: false,
can_use_tts: false,
can_use_w2l: false,
},
},
server_info: {
build_sha: "",
build_sha_short: "",
hostname: "",
},
success: false,
refresh_interval_millis: 60000,
};
const emptyState: CurrentAppState = {
appState: emptyAppState,
sessionWrapper: SessionWrapper.emptySession(),
sessionSubscriptions: SessionSubscriptionsWrapper.emptySubscriptions(),
};
export const AppStateContext = createContext<AppStateUtilities>({
...emptyState,
queryAppState: () => {},
});
export default function AppStateProvider({
children,
debug,
}: AppStatusProviderProps) {
const [stateAndWrappers, stateAndWrappersSet] =
useState<CurrentAppState>(emptyState);
const [keepAlive, keepAliveSet] = useState(true);
const onTick = () => {
if (debug) {
console.log("💾 App State Loop", stateAndWrappers);
}
GetAppState("", {}).then((res: GetAppStateResponse) => {
if (res.success) {
if (debug) {
console.log("App State fetch success ✅", res);
}
// compare response and state without server info as server host can change
// that way we only write state when there are meaningful state updates
const { server_info: unusedEventInfo, ...oldState } =
stateAndWrappers.appState;
const { server_info: unusedResInfo, ...updatedState } = res;
const isUpdated = !deepEqual(oldState, updatedState);
if (isUpdated) {
const newSessionWrapper = SessionWrapper.wrapResponse({
success: true,
logged_in: res.is_logged_in,
...(res.is_logged_in && res.maybe_user_info && res.permissions
? {
user: {
user_token: res.maybe_user_info.user_token,
username: res.maybe_user_info.username,
display_name: res.maybe_user_info.display_name,
email_gravatar_hash: res.maybe_user_info.gravatar_hash,
can_access_studio:
res.permissions.feature_flags.includes("studio"),
maybe_feature_flags: res.permissions.feature_flags,
...res.permissions.legacy_permission_flags,
},
}
: { user: null }),
});
const newSubsciptions = SessionSubscriptionsWrapper.wrapResponse({
success: true,
active_subscriptions: res.maybe_premium
? res.maybe_premium.active_subscriptions
: [],
...(res.maybe_premium?.maybe_loyalty_program
? {
maybe_loyalty_program:
res.maybe_premium.maybe_loyalty_program,
}
: {}),
});
const cookies = new Cookies();
stateAndWrappersSet({
appState: res,
sessionWrapper: newSessionWrapper,
sessionSubscriptions: newSubsciptions,
});
keepAliveSet(false);
if (res.maybe_user_info) {
if (debug) {
console.log("🌻 Logged in");
}
PosthogClient.enablePosthog();
PosthogClient.setUsername(res.maybe_user_info.username);
cookies.set("logged_in_username", res.maybe_user_info.username, {
path: "/",
expires: new Date(Date.now() + 3 * 86400000),
});
// @ts-ignore
window.dataLayer.push({
user_id: res.maybe_user_info.username,
});
} else {
if (debug) {
console.log("🥀 Logged out", res);
}
cookies.remove("logged_in_username", { path: "/" });
}
// this is just a copy/paste of code from App.tsx. I'll probably rewrite it whem I understand it more -V
let preferredLanguage = ENGLISH_LANGUAGE;
for (let languageCode of res.locale.language_codes) {
let maybeLanguage =
AVAILABLE_LANGUAGE_MAP[languageCode as AvailableLanguageKey];
if (maybeLanguage !== undefined) {
preferredLanguage = maybeLanguage;
break;
}
}
i18n2.changeLanguage(preferredLanguage.languageCode);
}
}
});
};
const queryAppState = () => {
if (debug) {
console.log("❓ Manual App State query");
}
onTick();
};
useInterval({
debug,
interval: stateAndWrappers.appState.refresh_interval_millis,
onTick,
locked: !keepAlive,
});
useEffect(() => {
if (!keepAlive) {
keepAliveSet(true);
}
}, [keepAlive]);
return (
<AppStateContext.Provider
{...{
value: { ...stateAndWrappers, queryAppState },
}}
>
{children}
</AppStateContext.Provider>
);
}
@@ -0,0 +1 @@
export { default, AppStateContext, emptyAppState } from "./AppStateProvider";
@@ -1,5 +1,6 @@
import React from "react";
import {
AppStateProvider,
InferenceJobsProvider,
ModalProvider,
NotificationProvider,
@@ -10,37 +11,23 @@ import ServerStatusChecker from "./ServerStatusChecker";
interface Props {
children?: any;
querySession: any;
querySubscriptions: any;
state: any;
}
export default function CoreServicesProvider({
children,
querySession,
querySubscriptions,
state,
}: Props) {
const sessionProps = {
querySession: querySession,
querySubscriptions,
sessionSubscriptions: state.sessionSubscriptionsWrapper,
sessionWrapper: state.sessionWrapper,
sessionFetched: state.sessionFetched,
};
export default function CoreServicesProvider({ children }: Props) {
return (
<SessionProvider {...sessionProps}>
<InferenceJobsProvider>
<NotificationProvider>
<ServerStatusChecker />
<ModalProvider>
<SignUpQuestionnaireProvider>
{children}
</SignUpQuestionnaireProvider>
</ModalProvider>
</NotificationProvider>
</InferenceJobsProvider>
</SessionProvider>
<AppStateProvider>
<SessionProvider>
<InferenceJobsProvider>
<NotificationProvider>
<ServerStatusChecker />
<ModalProvider>
<SignUpQuestionnaireProvider>
{children}
</SignUpQuestionnaireProvider>
</ModalProvider>
</NotificationProvider>
</InferenceJobsProvider>
</SessionProvider>
</AppStateProvider>
);
}
@@ -1,4 +1,4 @@
import React, { createContext } from "react";
import React, { createContext, useContext } from "react";
import ModalLayer from "components/providers/ModalProvider/ModalLayer";
import { ModalConfig, useModalState } from "hooks";
import AccountModal from "components/layout/AccountModal";
@@ -7,6 +7,11 @@ import { StudioRolloutHostnameAllowed } from "@storyteller/components/src/utils/
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { StyleVideoNotAvailable } from "v2/view/_common/StyleVideoNotAvailable";
import { GetAppStateResponse } from "@storyteller/components/src/api/app_state/GetAppState";
import {
AppStateContext,
emptyAppState,
} from "components/providers/AppStateProvider";
export interface AccountModalMessages {
loginMessage?: string;
@@ -19,11 +24,11 @@ export interface AccountModalEvents {
}
export interface SessionUtilities {
querySession: () => void;
querySubscriptions: () => void;
queryAppState: () => void;
}
interface SessionContextType extends SessionUtilities {
appState: GetAppStateResponse;
canAccessStudio: () => boolean;
canEditTtsModel: (creatorUserToken: string) => boolean;
canEditMediaFile: (creatorUserToken?: string) => boolean;
@@ -46,11 +51,8 @@ interface SessionContextType extends SessionUtilities {
userTokenMatch: (token: string) => boolean;
}
interface SessionProviderProps extends SessionUtilities {
interface SessionProviderProps {
children?: any;
sessionFetched: boolean;
sessionSubscriptions: SessionSubscriptionsWrapper;
sessionWrapper: SessionWrapper;
}
// Functions are initially No-ops/dummies so that they are never undefined and never have to be called conditionally.
@@ -60,6 +62,7 @@ interface SessionProviderProps extends SessionUtilities {
// const thingy = new SessionWrapper()
export const SessionContext = createContext<SessionContextType>({
appState: emptyAppState,
canAccessStudio: () => false,
canEditTtsModel: () => false,
canEditMediaFile: () => false,
@@ -74,19 +77,14 @@ export const SessionContext = createContext<SessionContextType>({
open: () => {},
},
userTokenMatch: () => false,
querySession: () => {},
querySubscriptions: () => {},
queryAppState: () => {},
sessionWrapper: SessionWrapper.emptySession(),
});
export default function SessionProvider({
children,
querySession,
querySubscriptions,
sessionFetched,
sessionSubscriptions,
sessionWrapper,
}: SessionProviderProps) {
export default function SessionProvider({ children }: SessionProviderProps) {
const { appState, sessionSubscriptions, sessionWrapper, queryAppState } =
useContext(AppStateContext);
const sessionResponse = sessionWrapper?.sessionStateResponse || {
logged_in: false,
user: null,
@@ -143,6 +141,7 @@ export default function SessionProvider({
<SessionContext.Provider
{...{
value: {
appState,
canAccessStudio,
canEditTtsModel,
canEditMediaFile,
@@ -150,9 +149,8 @@ export default function SessionProvider({
loggedInOrModal,
loggedIn,
modal,
querySession,
querySubscriptions,
sessionFetched,
queryAppState,
sessionFetched: appState.success,
sessionSubscriptions,
sessionWrapper,
studioAccessCheck,
@@ -1,3 +1,4 @@
import AppStateProvider from "./AppStateProvider";
import CoreServicesProvider from "./CoreServicesProvider";
import InferenceJobsProvider, {
InferenceJobsContext,
@@ -8,6 +9,7 @@ import SessionProvider from "./SessionProvider";
import SignUpQuestionnaireProvider from "./SignUpQuestionnaireProvider";
export {
AppStateProvider,
CoreServicesProvider,
InferenceJobsContext,
InferenceJobsProvider,
@@ -1,44 +1,35 @@
import { useEffect, useState } from "react";
import { StatusAlertCheck, StatusAlertCheckResponse } from "@storyteller/components/src/api/server/StatusAlertCheck";
import { useInterval, useNotifications } from "hooks";
import { useContext, useEffect, useState } from "react";
import { useNotifications } from "hooks";
import { AppStateContext } from "components/providers/AppStateProvider";
export default function useStatusPoll() {
const [serverStatus, serverStatusSet] = useState<StatusAlertCheckResponse>({});
const [initialized,initializedSet] = useState(false);
const [downAlerted,downAlertedSet] = useState(false);
const [upAlerted,upAlertedSet] = useState(false);
const [downAlerted, downAlertedSet] = useState(false);
const [upAlerted, upAlertedSet] = useState(false);
const notifications = useNotifications();
const defaultInt = 60000;
const interval = Math.max(defaultInt, serverStatus.refresh_interval_millis || defaultInt);
const onTick = () => {
StatusAlertCheck("",{})
.then((res: StatusAlertCheckResponse) => {
serverStatusSet(res);
});
};
const {
appState: { maybe_alert },
} = useContext(AppStateContext);
useEffect(() => {
if (!initialized) {
initializedSet(true);
onTick();
}
if (serverStatus.maybe_alert && !downAlerted) {
if (maybe_alert && !downAlerted) {
downAlertedSet(true);
upAlertedSet(false);
notifications.create({
autoRemove: false,
title: "Server down for maintenence",
content: "Please check back shortly"
content: "Please check back shortly",
});
} else if (!serverStatus.maybe_alert && downAlerted && !upAlerted) {
} else if (!maybe_alert && downAlerted && !upAlerted) {
upAlertedSet(true);
downAlertedSet(false);
notifications.create({ autoRemove: false, title: "Server is back online", });
notifications.create({
autoRemove: false,
title: "Server is back online",
});
}
},[downAlerted,initialized,notifications,serverStatus,upAlerted]);
}, [downAlerted, notifications, maybe_alert, upAlerted]);
useInterval({ interval, onTick, locked: !initialized, });
return serverStatus;
};
return maybe_alert;
}
@@ -92,8 +92,8 @@ function CreateCommentForm(props: Props) {
let gravatar = <span />;
let username = user?.username;
let emailHash = user?.email_gravatar_hash;
let avatarIndex = user?.core_info.default_avatar.image_index;
let backgroundColorIndex = user?.core_info.default_avatar.color_index;
let avatarIndex = user?.core_info?.default_avatar.image_index || 0;
let backgroundColorIndex = user?.core_info?.default_avatar.color_index || 0;
if (gravatarHash !== undefined) {
gravatar = (
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useContext } from "react";
import { GitSha } from "@storyteller/components/src/elements/GitSha";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { Link, useLocation } from "react-router-dom";
import { ModerationIcon } from "../_icons/ModerationIcon";
import { WebUrl } from "../../../common/WebUrl";
@@ -13,39 +12,20 @@ import {
faRedditAlien,
} from "@fortawesome/free-brands-svg-icons";
import { ThirdPartyLinks } from "@storyteller/components/src/constants/ThirdPartyLinks";
import {
GetServerInfo,
GetServerInfoIsOk,
GetServerInfoSuccessResponse,
} from "@storyteller/components/src/api/server/GetServerInfo";
import { useLocalize } from "hooks";
import { Container } from "components/common";
import { useDomainConfig } from "context/DomainConfigContext";
import { GetDiscordLink } from "@storyteller/components/src/env/GetDiscordLink";
import { AppStateContext } from "components/providers/AppStateProvider";
interface Props {
sessionWrapper: SessionWrapper;
}
function FooterNav(props: Props) {
function FooterNav() {
const {
appState: { server_info },
sessionWrapper,
} = useContext(AppStateContext);
const domain = useDomainConfig();
const { t } = useLocalize("Footer");
const [serverInfo, setServerInfo] = useState<
GetServerInfoSuccessResponse | undefined
>(undefined);
const location = useLocation();
const getServerInfo = useCallback(async () => {
const response = await GetServerInfo();
if (GetServerInfoIsOk(response)) {
setServerInfo(response);
}
}, []);
useEffect(() => {
getServerInfo();
}, [getServerInfo]);
//let myDataLink = WebUrl.signupPage();
//if (props.sessionWrapper.isLoggedIn()) {
@@ -55,7 +35,7 @@ function FooterNav(props: Props) {
let moderationLink = <span />;
if (props.sessionWrapper.canBanUsers()) {
if (sessionWrapper.canBanUsers()) {
moderationLink = (
<div className="mb-4 mb-lg-0 me-0 me-lg-4">
<Link to={WebUrl.moderationMain()}>
@@ -69,15 +49,13 @@ function FooterNav(props: Props) {
let serverGitSha = <></>;
if (
serverInfo !== undefined &&
!!serverInfo.server_build_sha &&
serverInfo.server_build_sha !== "undefined"
server_info !== undefined &&
!!server_info.build_sha_short &&
server_info.build_sha_short !== "undefined"
) {
serverGitSha = (
<div className="d-flex flex-column flex-lg-row align-items-center">
<div className="git-sha">
API: {serverInfo.server_build_sha.substring(0, 8)}
</div>
<div className="git-sha">API: {server_info.build_sha_short}</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { Link, useHistory, useLocation } from "react-router-dom";
import {
CreateSession,
@@ -18,7 +18,7 @@ import Panel from "components/common/Panel";
import { useDomainConfig } from "context/DomainConfigContext";
import ScrollingSceneCarousel from "../landing/storyteller/PostlaunchLanding/ScrollingSceneCarousel";
import { InjectScript } from "common/InjectScript";
import { useSession } from "hooks";
import { AppStateContext } from "components/providers/AppStateProvider";
// NB: Google Sign In requires a global javascript function
declare global {
@@ -28,7 +28,7 @@ declare global {
function LoginPage() {
let history = useHistory();
const domain = useDomainConfig();
const { querySession, querySubscriptions, sessionWrapper } = useSession();
const { sessionWrapper, queryAppState } = useContext(AppStateContext);
const [password, setPassword] = useState("");
const [usernameOrEmail, setUsernameOrEmail] = useState("");
const [errorMessage, setErrorMessage] = useState("");
@@ -80,8 +80,7 @@ function LoginPage() {
if (CreateSessionIsError(response)) {
setErrorMessage(response.error_message);
} else if (CreateSessionIsSuccess(response)) {
querySession();
querySubscriptions();
queryAppState();
Analytics.accountLoginSuccess();
history.push(redirectUrl);
}
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { usePrefixedDocumentTitle } from "../../../../common/UsePrefixedDocumentTitle";
import Container from "components/common/Container";
@@ -11,7 +11,7 @@ import {
RedeemResetPassword,
RedeemResetPasswordIsSuccess,
} from "@storyteller/components/src/api/user/RedeemResetPassword";
import { useSession } from "hooks";
import { AppStateContext } from "components/providers/AppStateProvider";
// TODO(bt,2023-11-12): Localize error messages
const ERR_CODE_NOT_SET = "password reset code is not set";
@@ -23,7 +23,7 @@ const ERR_BACKEND =
function PasswordResetVerificationPage() {
const history = useHistory();
const { querySession, querySubscriptions, sessionWrapper } = useSession();
const { sessionWrapper, queryAppState } = useContext(AppStateContext);
usePrefixedDocumentTitle("Password Reset Verification");
@@ -130,8 +130,7 @@ function PasswordResetVerificationPage() {
if (RedeemResetPasswordIsSuccess(response)) {
setBackendError(undefined);
querySession();
querySubscriptions();
queryAppState();
history.push("/");
} else {
setBackendError(ERR_BACKEND);
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { t } from "i18next";
import { Trans } from "react-i18next";
import {
@@ -19,7 +19,7 @@ import { PosthogClient } from "@storyteller/components/src/analytics/PosthogClie
import Panel from "components/common/Panel";
import { useDomainConfig } from "context/DomainConfigContext";
import ScrollingSceneCarousel from "../landing/storyteller/PostlaunchLanding/ScrollingSceneCarousel";
import { useSession } from "hooks";
import { AppStateContext } from "components/providers/AppStateProvider";
enum FieldTriState {
EMPTY_FALSE,
@@ -31,7 +31,7 @@ function SignupPage() {
let history = useHistory();
const domain = useDomainConfig();
let location = useLocation();
const { querySession, querySubscriptions, sessionWrapper } = useSession();
const { sessionWrapper, queryAppState } = useContext(AppStateContext);
const queryParams = new URLSearchParams(location.search);
const parsedQueryString = queryString.parse(window.location.search);
@@ -242,8 +242,7 @@ function SignupPage() {
setUsernameInvalidReason(reason);
}
} else if (CreateAccountIsSuccess(response)) {
querySession();
querySubscriptions();
queryAppState();
Analytics.accountSignupComplete();