Merge branch 'main' of github.com:omnivore-app/omnivore into feat/1078
This commit is contained in:
@ -17,8 +17,8 @@ android {
|
||||
applicationId "app.omnivore.omnivore"
|
||||
minSdk 23
|
||||
targetSdk 32
|
||||
versionCode 4
|
||||
versionName "0.0.4"
|
||||
versionCode 7
|
||||
versionName "0.0.7"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
query ValidateUsername($username: String!) {
|
||||
validateUsername(username: $username)
|
||||
}
|
||||
@ -9,6 +9,7 @@ object Constants {
|
||||
object DatastoreKeys {
|
||||
const val omnivoreAuthToken = "omnivoreAuthToken"
|
||||
const val omnivoreAuthCookieString = "omnivoreAuthCookieString"
|
||||
const val omnivorePendingUserToken = "omnivorePendingUserToken"
|
||||
}
|
||||
|
||||
object AppleConstants {
|
||||
|
||||
@ -16,6 +16,7 @@ interface DatastoreRepository {
|
||||
suspend fun putInt(key: String, value: Int)
|
||||
suspend fun getString(key: String): String?
|
||||
suspend fun getInt(key: String): Int?
|
||||
suspend fun clearValue(key: String)
|
||||
}
|
||||
|
||||
class OmnivoreDatastore @Inject constructor(
|
||||
@ -55,6 +56,11 @@ class OmnivoreDatastore @Inject constructor(
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
override suspend fun clearValue(key: String) {
|
||||
val preferencesKey = stringPreferencesKey(key)
|
||||
context.dataStore.edit { it.remove(preferencesKey) }
|
||||
}
|
||||
|
||||
override val hasAuthTokenFlow: Flow<Boolean> = context
|
||||
.dataStore.data.map { preferences ->
|
||||
val key = stringPreferencesKey(DatastoreKeys.omnivoreAuthToken)
|
||||
|
||||
@ -12,12 +12,23 @@ data class AuthPayload(
|
||||
val authToken: String
|
||||
)
|
||||
|
||||
data class PendingUserAuthPayload(
|
||||
val pendingUserToken: String,
|
||||
)
|
||||
|
||||
data class SignInParams(
|
||||
val token: String,
|
||||
val provider: String, // APPLE or GOOGLE
|
||||
val source: String = "ANDROID"
|
||||
)
|
||||
|
||||
data class EmailSignUpParams(
|
||||
val email: String,
|
||||
val password: String,
|
||||
val username: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class EmailAuthPayload(
|
||||
val authCookieString: String?,
|
||||
val authToken: String?,
|
||||
@ -29,6 +40,16 @@ data class EmailLoginCredentials(
|
||||
val password: String
|
||||
)
|
||||
|
||||
data class CreateAccountParams(
|
||||
val pendingUserToken: String,
|
||||
val userProfile: UserProfile
|
||||
)
|
||||
|
||||
data class UserProfile(
|
||||
val username: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
interface EmailLoginSubmit {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("/api/mobile-auth/email-sign-in")
|
||||
@ -41,6 +62,24 @@ interface AuthProviderLoginSubmit {
|
||||
suspend fun submitAuthProviderLogin(@Body params: SignInParams): Response<AuthPayload>
|
||||
}
|
||||
|
||||
interface PendingUserSubmit {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("/api/mobile-auth/sign-up")
|
||||
suspend fun submitPendingUser(@Body params: SignInParams): Response<PendingUserAuthPayload>
|
||||
}
|
||||
|
||||
interface CreateAccountSubmit {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("/api/mobile-auth/create-account")
|
||||
suspend fun submitCreateAccount(@Body params: CreateAccountParams): Response<AuthPayload>
|
||||
}
|
||||
|
||||
interface CreateEmailAccountSubmit {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST("/api/mobile-auth/email-sign-up")
|
||||
suspend fun submitCreateEmailAccount(@Body params: EmailSignUpParams): Response<Unit>
|
||||
}
|
||||
|
||||
object RetrofitHelper {
|
||||
fun getInstance(): Retrofit {
|
||||
return Retrofit.Builder().baseUrl(Constants.apiURL)
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.R
|
||||
import org.intellij.lang.annotations.JdkConstants
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun CreateUserProfileView(viewModel: LoginViewModel) {
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Create Your Profile",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
UserProfileFields(
|
||||
name = name,
|
||||
username = username,
|
||||
usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage,
|
||||
showUsernameAsAvailable = viewModel.hasValidUsername,
|
||||
onNameChange = { name = it },
|
||||
onUsernameChange = {
|
||||
username = it
|
||||
viewModel.validateUsername(it)
|
||||
},
|
||||
onSubmit = { viewModel.submitProfile(username = username, name = name) }
|
||||
)
|
||||
|
||||
// TODO: add a activity indicator (maybe after a delay?)
|
||||
if (viewModel.isLoading) {
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Cancel Sign Up"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.cancelNewUserSignUp() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserProfileFields(
|
||||
name: String,
|
||||
username: String,
|
||||
usernameValidationErrorMessage: String?,
|
||||
showUsernameAsAvailable: Boolean,
|
||||
onNameChange: (String) -> Unit,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onSubmit: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(25.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
placeholder = { Text(text = "Name") },
|
||||
label = { Text(text = "Name") },
|
||||
onValueChange = onNameChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
placeholder = { Text(text = "Username") },
|
||||
label = { Text(text = "Username") },
|
||||
onValueChange = onUsernameChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
trailingIcon = {
|
||||
if (showUsernameAsAvailable) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (usernameValidationErrorMessage != null) {
|
||||
Text(
|
||||
text = usernameValidationErrorMessage!!,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (name.isNotBlank() && username.isNotBlank()) {
|
||||
onSubmit()
|
||||
focusManager.clearFocus()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Please enter a valid name and username.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}, colors = ButtonDefaults.buttonColors(
|
||||
contentColor = Color(0xFF3D3D3D),
|
||||
containerColor = Color(0xffffd234)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Submit",
|
||||
modifier = Modifier.padding(horizontal = 100.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
@ -15,16 +19,20 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.omnivore.omnivore.BuildConfig
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun EmailLoginView(viewModel: LoginViewModel, onAuthProviderButtonTap: () -> Unit) {
|
||||
fun EmailLoginView(viewModel: LoginViewModel) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
@ -49,12 +57,33 @@ fun EmailLoginView(viewModel: LoginViewModel, onAuthProviderButtonTap: () -> Uni
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Return to Social Login"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { onAuthProviderButtonTap() }
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("Return to Social Login"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showSocialLogin() }
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Don't have an account?"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showEmailSignUp() }
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Forgot your password?"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = {
|
||||
val uri = "${BuildConfig.OMNIVORE_WEB_URL}/auth/forgot-password"
|
||||
uriHandler.openUri(uri)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
|
||||
@ -0,0 +1,249 @@
|
||||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun EmailSignUpView(viewModel: LoginViewModel) {
|
||||
if (viewModel.pendingEmailUserCreds != null) {
|
||||
val email = viewModel.pendingEmailUserCreds?.email ?: ""
|
||||
val password = viewModel.pendingEmailUserCreds?.password ?: ""
|
||||
|
||||
val verificationMessage = "We've sent a verification email to ${email}. Please verify your email and then tap the button below."
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = verificationMessage,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Button(onClick = {
|
||||
viewModel.login(email, password)
|
||||
}, colors = ButtonDefaults.buttonColors(
|
||||
contentColor = Color(0xFF3D3D3D),
|
||||
containerColor = Color(0xffffd234)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Check Status",
|
||||
modifier = Modifier.padding(horizontal = 100.dp)
|
||||
)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Use a different email?"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showEmailSignUp() }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmailSignUpForm(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun EmailSignUpForm(viewModel: LoginViewModel) {
|
||||
var email by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmailSignUpFields(
|
||||
email = email,
|
||||
password = password,
|
||||
name = name,
|
||||
username = username,
|
||||
usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage,
|
||||
showUsernameAsAvailable = viewModel.hasValidUsername,
|
||||
onEmailChange = { email = it },
|
||||
onPasswordChange = { password = it },
|
||||
onNameChange = { name = it },
|
||||
onUsernameChange = {
|
||||
username = it
|
||||
viewModel.validateUsername(it)
|
||||
},
|
||||
onSubmit = {
|
||||
viewModel.submitEmailSignUp(
|
||||
email = email,
|
||||
password = password,
|
||||
username = username,
|
||||
name = name
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// TODO: add a activity indicator (maybe after a delay?)
|
||||
if (viewModel.isLoading) {
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("Return to Social Login"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showSocialLogin() }
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("Already have an account?"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showEmailSignIn() }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EmailSignUpFields(
|
||||
email: String,
|
||||
password: String,
|
||||
name: String,
|
||||
username: String,
|
||||
usernameValidationErrorMessage: String?,
|
||||
showUsernameAsAvailable: Boolean,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onNameChange: (String) -> Unit,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(25.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
placeholder = { Text(text = "user@email.com") },
|
||||
label = { Text(text = "Email") },
|
||||
onValueChange = onEmailChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
placeholder = { Text(text = "Password") },
|
||||
label = { Text(text = "Password") },
|
||||
onValueChange = onPasswordChange,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
placeholder = { Text(text = "Name") },
|
||||
label = { Text(text = "Name") },
|
||||
onValueChange = onNameChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
placeholder = { Text(text = "Username") },
|
||||
label = { Text(text = "Username") },
|
||||
onValueChange = onUsernameChange,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
trailingIcon = {
|
||||
if (showUsernameAsAvailable) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (usernameValidationErrorMessage != null) {
|
||||
Text(
|
||||
text = usernameValidationErrorMessage!!,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(onClick = {
|
||||
if (email.isNotBlank() && password.isNotBlank() && username.isNotBlank() && name.isNotBlank()) {
|
||||
onSubmit()
|
||||
focusManager.clearFocus()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Please complete all fields.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}, colors = ButtonDefaults.buttonColors(
|
||||
contentColor = Color(0xFF3D3D3D),
|
||||
containerColor = Color(0xffffd234)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Sign Up",
|
||||
modifier = Modifier.padding(horizontal = 100.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,46 +1,151 @@
|
||||
package app.omnivore.omnivore.ui.auth
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.*
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.omnivore.omnivore.*
|
||||
import app.omnivore.omnivore.graphql.generated.SearchQuery
|
||||
import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
||||
import com.google.android.gms.common.api.ApiException
|
||||
import com.google.android.gms.tasks.Task
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
enum class RegistrationState {
|
||||
AuthProviderButtons,
|
||||
EmailSignIn
|
||||
SocialLogin,
|
||||
EmailSignIn,
|
||||
EmailSignUp,
|
||||
PendingUser
|
||||
}
|
||||
|
||||
data class PendingEmailUserCreds(
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
): ViewModel() {
|
||||
private var validateUsernameJob: Job? = null
|
||||
|
||||
var isLoading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var errorMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var hasValidUsername by mutableStateOf<Boolean>(false)
|
||||
private set
|
||||
|
||||
var usernameValidationErrorMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var pendingEmailUserCreds by mutableStateOf<PendingEmailUserCreds?>(null)
|
||||
private set
|
||||
|
||||
val hasAuthTokenLiveData: LiveData<Boolean> = datastoreRepo
|
||||
.hasAuthTokenFlow
|
||||
.distinctUntilChanged()
|
||||
.asLiveData()
|
||||
|
||||
val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin)
|
||||
|
||||
fun getAuthCookieString(): String? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.omnivoreAuthCookieString)
|
||||
}
|
||||
|
||||
fun showSocialLogin() {
|
||||
resetState()
|
||||
registrationStateLiveData.value = RegistrationState.SocialLogin
|
||||
}
|
||||
|
||||
fun showEmailSignIn() {
|
||||
resetState()
|
||||
registrationStateLiveData.value = RegistrationState.EmailSignIn
|
||||
}
|
||||
|
||||
fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) {
|
||||
resetState()
|
||||
pendingEmailUserCreds = pendingCreds
|
||||
registrationStateLiveData.value = RegistrationState.EmailSignUp
|
||||
}
|
||||
|
||||
fun cancelNewUserSignUp() {
|
||||
resetState()
|
||||
viewModelScope.launch {
|
||||
datastoreRepo.clearValue(DatastoreKeys.omnivorePendingUserToken)
|
||||
}
|
||||
showSocialLogin()
|
||||
}
|
||||
|
||||
private fun resetState() {
|
||||
validateUsernameJob = null
|
||||
isLoading = false
|
||||
errorMessage = null
|
||||
hasValidUsername = false
|
||||
usernameValidationErrorMessage = null
|
||||
pendingEmailUserCreds = null
|
||||
}
|
||||
|
||||
fun validateUsername(potentialUsername: String) {
|
||||
validateUsernameJob?.cancel()
|
||||
|
||||
validateUsernameJob = viewModelScope.launch {
|
||||
delay(500)
|
||||
|
||||
// Check the username requirements first
|
||||
if (potentialUsername.isEmpty()) {
|
||||
usernameValidationErrorMessage = null
|
||||
hasValidUsername = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (potentialUsername.length < 4 || potentialUsername.length > 15) {
|
||||
usernameValidationErrorMessage = "Username must be between 4 and 15 characters long."
|
||||
hasValidUsername = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val isValidPattern = Pattern.compile("^[a-z0-9][a-z0-9_]+[a-z0-9]$")
|
||||
.matcher(potentialUsername)
|
||||
.matches()
|
||||
|
||||
if (!isValidPattern) {
|
||||
usernameValidationErrorMessage = "Username can contain only letters and numbers"
|
||||
hasValidUsername = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val apolloClient = ApolloClient.Builder()
|
||||
.serverUrl("${Constants.apiURL}/api/graphql")
|
||||
.build()
|
||||
|
||||
val response = apolloClient.query(
|
||||
ValidateUsernameQuery(username = potentialUsername)
|
||||
).execute()
|
||||
|
||||
if (response.data?.validateUsername == true) {
|
||||
usernameValidationErrorMessage = null
|
||||
hasValidUsername = true
|
||||
} else {
|
||||
hasValidUsername = false
|
||||
usernameValidationErrorMessage = "This username is not available."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(email: String, password: String) {
|
||||
val emailLogin = RetrofitHelper.getInstance().create(EmailLoginSubmit::class.java)
|
||||
|
||||
@ -55,7 +160,7 @@ class LoginViewModel @Inject constructor(
|
||||
isLoading = false
|
||||
|
||||
if (result.body()?.pendingEmailVerification == true) {
|
||||
errorMessage = "Email needs verification"
|
||||
showEmailSignUp(pendingCreds = PendingEmailUserCreds(email = email, password = password))
|
||||
return@launch
|
||||
}
|
||||
|
||||
@ -73,6 +178,74 @@ class LoginViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun submitEmailSignUp(
|
||||
email: String,
|
||||
password: String,
|
||||
username: String,
|
||||
name: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val request = RetrofitHelper.getInstance().create(CreateEmailAccountSubmit::class.java)
|
||||
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
|
||||
val params = EmailSignUpParams(
|
||||
email = email,
|
||||
password = password,
|
||||
name = name,
|
||||
username = username
|
||||
)
|
||||
|
||||
val result = request.submitCreateEmailAccount(params)
|
||||
|
||||
isLoading = false
|
||||
|
||||
if (result.errorBody() != null) {
|
||||
errorMessage = "Something went wrong. Please check your and try again"
|
||||
} else {
|
||||
pendingEmailUserCreds = PendingEmailUserCreds(email, password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPendingAuthToken(): String? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.omnivorePendingUserToken)
|
||||
}
|
||||
|
||||
fun submitProfile(username: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
val request = RetrofitHelper.getInstance().create(CreateAccountSubmit::class.java)
|
||||
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
|
||||
val pendingUserToken = getPendingAuthToken() ?: ""
|
||||
|
||||
val userProfile = UserProfile(name = name, username = username)
|
||||
val params = CreateAccountParams(
|
||||
pendingUserToken = pendingUserToken,
|
||||
userProfile = userProfile
|
||||
)
|
||||
|
||||
val result = request.submitCreateAccount(params)
|
||||
|
||||
isLoading = false
|
||||
|
||||
if (result.body()?.authToken != null) {
|
||||
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
|
||||
} else {
|
||||
errorMessage = "Something went wrong. Please check your email/password and try again"
|
||||
}
|
||||
|
||||
if (result.body()?.authCookieString != null) {
|
||||
datastoreRepo.putString(
|
||||
DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleAppleToken(authToken: String) {
|
||||
submitAuthProviderPayload(
|
||||
params = SignInParams(token = authToken, provider = "APPLE")
|
||||
@ -121,15 +294,46 @@ class LoginViewModel @Inject constructor(
|
||||
|
||||
if (result.body()?.authToken != null) {
|
||||
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
|
||||
} else {
|
||||
errorMessage = "Something went wrong. Please check your credentials and try again"
|
||||
}
|
||||
|
||||
if (result.body()?.authCookieString != null) {
|
||||
datastoreRepo.putString(
|
||||
DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!!
|
||||
)
|
||||
if (result.body()?.authCookieString != null) {
|
||||
datastoreRepo.putString(
|
||||
DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!!
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (result.code()) {
|
||||
401, 403 -> {
|
||||
// This is a new user so they should go through the new user flow
|
||||
submitAuthProviderPayloadForPendingToken(params = params)
|
||||
}
|
||||
418 -> {
|
||||
// Show pending email state
|
||||
errorMessage = "Something went wrong. Please check your credentials and try again"
|
||||
}
|
||||
else -> {
|
||||
errorMessage = "Something went wrong. Please check your credentials and try again"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
|
||||
val request = RetrofitHelper.getInstance().create(PendingUserSubmit::class.java)
|
||||
val result = request.submitPendingUser(params)
|
||||
|
||||
isLoading = false
|
||||
|
||||
if (result.body()?.pendingUserToken != null) {
|
||||
datastoreRepo.putString(
|
||||
DatastoreKeys.omnivorePendingUserToken, result.body()?.pendingUserToken!!
|
||||
)
|
||||
registrationStateLiveData.value = RegistrationState.PendingUser
|
||||
} else {
|
||||
errorMessage = "Something went wrong. Please check your credentials and try again"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -35,11 +36,9 @@ fun WelcomeScreen(viewModel: LoginViewModel) {
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun WelcomeScreenContent(viewModel: LoginViewModel) {
|
||||
var registrationState by rememberSaveable { mutableStateOf(RegistrationState.AuthProviderButtons) }
|
||||
|
||||
val onRegistrationStateChange = { state: RegistrationState ->
|
||||
registrationState = state
|
||||
}
|
||||
val registrationState: RegistrationState by viewModel
|
||||
.registrationStateLiveData
|
||||
.observeAsState(RegistrationState.SocialLogin)
|
||||
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@ -62,14 +61,12 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) {
|
||||
|
||||
when(registrationState) {
|
||||
RegistrationState.EmailSignIn -> {
|
||||
EmailLoginView(
|
||||
viewModel = viewModel,
|
||||
onAuthProviderButtonTap = {
|
||||
onRegistrationStateChange(RegistrationState.AuthProviderButtons)
|
||||
}
|
||||
)
|
||||
EmailLoginView(viewModel = viewModel)
|
||||
}
|
||||
RegistrationState.AuthProviderButtons -> {
|
||||
RegistrationState.EmailSignUp -> {
|
||||
EmailSignUpView(viewModel = viewModel)
|
||||
}
|
||||
RegistrationState.SocialLogin -> {
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
@ -84,10 +81,10 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) {
|
||||
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
|
||||
AuthProviderView(
|
||||
viewModel = viewModel,
|
||||
onEmailButtonTap = { onRegistrationStateChange(RegistrationState.EmailSignIn) }
|
||||
)
|
||||
AuthProviderView(viewModel = viewModel)
|
||||
}
|
||||
RegistrationState.PendingUser -> {
|
||||
CreateUserProfileView(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,10 +109,7 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthProviderView(
|
||||
viewModel: LoginViewModel,
|
||||
onEmailButtonTap: () -> Unit
|
||||
) {
|
||||
fun AuthProviderView(viewModel: LoginViewModel) {
|
||||
val isGoogleAuthAvailable: Boolean = GoogleApiAvailability
|
||||
.getInstance()
|
||||
.isGooglePlayServicesAvailable(LocalContext.current) == 0
|
||||
@ -138,7 +132,7 @@ fun AuthProviderView(
|
||||
text = AnnotatedString("Continue with Email"),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { onEmailButtonTap() }
|
||||
onClick = { viewModel.showEmailSignIn() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
package app.omnivore.omnivore.ui.home
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<!-- Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
|
||||
@ -402,15 +402,24 @@ public struct MiniPlayer: View {
|
||||
}
|
||||
}
|
||||
|
||||
var scrubbing: Bool {
|
||||
switch audioController.scrubState {
|
||||
case .scrubStarted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func onDragChanged(value: DragGesture.Value) {
|
||||
if value.translation.height > 0, expanded {
|
||||
if value.translation.height > 0, expanded, !scrubbing {
|
||||
offset = value.translation.height
|
||||
}
|
||||
}
|
||||
|
||||
func onDragEnded(value: DragGesture.Value) {
|
||||
withAnimation(.interactiveSpring()) {
|
||||
if value.translation.height > minExpandedHeight {
|
||||
if value.translation.height > minExpandedHeight, !scrubbing {
|
||||
expanded = false
|
||||
}
|
||||
offset = 0
|
||||
|
||||
@ -204,15 +204,17 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
||||
if let playerItem = player?.currentItem as? SpeechPlayerItem {
|
||||
if playerItem.speechItem.audioIdx == foundIdx {
|
||||
playerItem.seek(to: CMTimeMakeWithSeconds(remainder, preferredTimescale: 600), completionHandler: nil)
|
||||
scrubState = .reset
|
||||
fireTimer()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Move the playback to the found index, we should also seek a bit
|
||||
// within this index, but this is probably accurate enough for now.
|
||||
// Move the playback to the found index, we also seek by the remainder amount
|
||||
// before moving we pause the player so playback doesnt jump to a previous spot
|
||||
player?.pause()
|
||||
player?.removeAllItems()
|
||||
synthesizeFrom(start: foundIdx, playWhenReady: state == .playing, atOffset: remainder)
|
||||
return
|
||||
} else {
|
||||
// There was no foundIdx, so we are probably trying to seek past the end, so
|
||||
// just seek to the last possible duration.
|
||||
@ -221,6 +223,9 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
||||
synthesizeFrom(start: durations.count - 1, playWhenReady: state == .playing, atOffset: last)
|
||||
}
|
||||
}
|
||||
|
||||
scrubState = .reset
|
||||
fireTimer()
|
||||
}
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechPlaybackRate.rawValue) public var playbackRate = 1.0 {
|
||||
@ -299,8 +304,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
||||
if state == .reachedEnd {
|
||||
return false
|
||||
}
|
||||
return
|
||||
itemAudioProperties?.itemID == itemID &&
|
||||
return itemAudioProperties?.itemID == itemID &&
|
||||
(state == .loading || player?.currentItem == nil || player?.currentItem?.status == .unknown)
|
||||
}
|
||||
|
||||
@ -431,7 +435,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
||||
|
||||
func startTimer() {
|
||||
if timer == nil {
|
||||
// Update every 100ms
|
||||
timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: true)
|
||||
timer?.fire()
|
||||
}
|
||||
@ -445,13 +448,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
||||
stop()
|
||||
}
|
||||
|
||||
if player.items().count == 1, let currentTime = player.currentItem?.currentTime(), let duration = player.currentItem?.duration {
|
||||
if currentTime >= duration {
|
||||
pause()
|
||||
state = .reachedEnd
|
||||
}
|
||||
}
|
||||
|
||||
if let durations = durations {
|
||||
duration = durations.reduce(0, +)
|
||||
durationString = formatTimeInterval(duration)
|
||||
@ -465,17 +461,23 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate
|
||||
let itemElapsed = playerItem.status == .readyToPlay ? CMTimeGetSeconds(playerItem.currentTime()) : 0
|
||||
timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed
|
||||
timeElapsedString = formatTimeInterval(timeElapsed)
|
||||
|
||||
if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo {
|
||||
nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
|
||||
nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying
|
||||
}
|
||||
}
|
||||
case .scrubStarted:
|
||||
break
|
||||
case let .scrubEnded(seekTime):
|
||||
timeElapsed = seekTime
|
||||
timeElapsedString = formatTimeInterval(timeElapsed)
|
||||
if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo {
|
||||
nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
|
||||
nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying
|
||||
}
|
||||
case .scrubStarted:
|
||||
break
|
||||
case let .scrubEnded(seekTime):
|
||||
scrubState = .reset
|
||||
timeElapsed = seekTime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -187,7 +187,6 @@ func fetchUtterance(appEnvironment: AppEnvironment,
|
||||
|
||||
if let ssml = try utterance.toSSML(document: document) {
|
||||
request.httpBody = ssml
|
||||
print("FETCHING: ", String(decoding: ssml, as: UTF8.self))
|
||||
}
|
||||
|
||||
for (header, value) in networker.defaultHeaders {
|
||||
@ -219,7 +218,6 @@ func fetchUtterance(appEnvironment: AppEnvironment,
|
||||
try audioData.write(to: tempPath)
|
||||
try? FileManager.default.removeItem(at: audioPath)
|
||||
try FileManager.default.moveItem(at: tempPath, to: audioPath)
|
||||
print("wrote", audioData.count, "bytes to", audioPath)
|
||||
} catch {
|
||||
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
|
||||
@ -8,12 +8,19 @@ public enum WebViewAction: String, CaseIterable {
|
||||
case readingProgressUpdate
|
||||
}
|
||||
|
||||
enum ContextMenu {
|
||||
case defaultMenu
|
||||
case highlightMenu
|
||||
}
|
||||
|
||||
public final class OmnivoreWebView: WKWebView {
|
||||
#if os(iOS)
|
||||
private var panGestureRecognizer: UIPanGestureRecognizer?
|
||||
private var tapGestureRecognizer: UITapGestureRecognizer?
|
||||
#endif
|
||||
|
||||
private var currentMenu: ContextMenu = .defaultMenu
|
||||
|
||||
override init(frame: CGRect, configuration: WKWebViewConfiguration) {
|
||||
super.init(frame: frame, configuration: configuration)
|
||||
|
||||
@ -21,6 +28,10 @@ public final class OmnivoreWebView: WKWebView {
|
||||
initNativeIOSMenus()
|
||||
#endif
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
self.isFindInteractionEnabled = true
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: NSNotification.Name("SpeakingReaderItem"), object: nil, queue: OperationQueue.main, using: { notification in
|
||||
if let pageID = notification.userInfo?["pageID"] as? String, let anchorIdx = notification.userInfo?["anchorIdx"] as? String {
|
||||
self.dispatchEvent(.speakingSection(anchorIdx: anchorIdx))
|
||||
@ -132,19 +143,33 @@ public final class OmnivoreWebView: WKWebView {
|
||||
}
|
||||
|
||||
private func setDefaultMenu() {
|
||||
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
|
||||
let highlight = UIMenuItem(title: "Highlight", action: #selector(highlightSelection))
|
||||
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
|
||||
currentMenu = .defaultMenu
|
||||
|
||||
UIMenuController.shared.menuItems = [highlight, /* share, */ annotate]
|
||||
if #available(iOS 16.0, *) {
|
||||
// on iOS16 we use menuBuilder to create these items
|
||||
} else {
|
||||
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
|
||||
let highlight = UIMenuItem(title: "Highlight", action: #selector(highlightSelection))
|
||||
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
|
||||
|
||||
UIMenuController.shared.menuItems = [highlight, /* share, */ annotate]
|
||||
}
|
||||
}
|
||||
|
||||
private func setHighlightMenu() {
|
||||
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
|
||||
let remove = UIMenuItem(title: "Remove", action: #selector(removeSelection))
|
||||
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
|
||||
currentMenu = .highlightMenu
|
||||
|
||||
UIMenuController.shared.menuItems = [remove, /* share, */ annotate]
|
||||
if #available(iOS 16.0, *) {
|
||||
// on iOS16 we use menuBuilder to create these items
|
||||
} else {
|
||||
// on iOS16 we use menuBuilder to create these items
|
||||
currentMenu = .defaultMenu
|
||||
let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection))
|
||||
let remove = UIMenuItem(title: "Remove", action: #selector(removeSelection))
|
||||
// let share = UIMenuItem(title: "Share", action: #selector(shareSelection))
|
||||
|
||||
UIMenuController.shared.menuItems = [remove, /* share, */ annotate]
|
||||
}
|
||||
}
|
||||
|
||||
override public var canBecomeFirstResponder: Bool {
|
||||
@ -168,6 +193,8 @@ public final class OmnivoreWebView: WKWebView {
|
||||
case #selector(removeSelection): return true
|
||||
case #selector(copy(_:)): return true
|
||||
case Selector(("_lookup:")): return true
|
||||
case Selector(("_define:")): return true
|
||||
case Selector(("_findSelected:")): return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@ -202,6 +229,21 @@ public final class OmnivoreWebView: WKWebView {
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
override public func buildMenu(with builder: UIMenuBuilder) {
|
||||
if #available(iOS 16.0, *) {
|
||||
let annotate = UICommand(title: "Note", action: #selector(annotateSelection))
|
||||
let highlight = UICommand(title: "Highlight", action: #selector(highlightSelection))
|
||||
let remove = UICommand(title: "Remove", action: #selector(removeSelection))
|
||||
|
||||
let omnivore = UIMenu(title: "",
|
||||
options: .displayInline,
|
||||
children: currentMenu == .defaultMenu ? [highlight, annotate] : [annotate, remove])
|
||||
builder.insertSibling(omnivore, beforeMenu: .lookup)
|
||||
}
|
||||
|
||||
super.buildMenu(with: builder)
|
||||
}
|
||||
|
||||
private func hideMenu() {
|
||||
UIMenuController.shared.hideMenu()
|
||||
if let tapGestureRecognizer = tapGestureRecognizer {
|
||||
|
||||
@ -18,6 +18,7 @@ public struct FeedCard: View {
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(EdgeInsets(top: 0, leading: 0, bottom: 2, trailing: 0))
|
||||
|
||||
if let author = item.author {
|
||||
Text("By \(author)")
|
||||
@ -74,10 +75,10 @@ public struct FeedCard: View {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 8)
|
||||
.frame(
|
||||
minWidth: nil,
|
||||
|
||||
@ -72,7 +72,11 @@ export const saveEmail = async (
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
}
|
||||
|
||||
const page = await getPageByParam({ userId: ctx.uid, url: articleToSave.url })
|
||||
const page = await getPageByParam({
|
||||
userId: ctx.uid,
|
||||
url: articleToSave.url,
|
||||
state: ArticleSavingRequestStatus.Succeeded,
|
||||
})
|
||||
if (page) {
|
||||
const result = await updatePage(page.id, { archivedAt: null }, ctx)
|
||||
console.log('updated page from email', result)
|
||||
|
||||
37
packages/api/src/utils/morning-brew-handler.ts
Normal file
37
packages/api/src/utils/morning-brew-handler.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export class MorningBrewHandler {
|
||||
name = 'morningbrew'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
shouldPrehandle = (url: URL, _dom: Document): boolean => {
|
||||
const host = this.name + '.com'
|
||||
// check if url ends with morningbrew.com
|
||||
return url.hostname.endsWith(host)
|
||||
}
|
||||
|
||||
prehandle = (url: URL, dom: Document): Promise<Document> => {
|
||||
// retain the width of the cells in the table of market info
|
||||
dom
|
||||
.querySelectorAll('.markets-arrow-cell')
|
||||
.forEach((c) => c.setAttribute('width', '20%'))
|
||||
dom
|
||||
.querySelectorAll('.markets-ticker-cell')
|
||||
.forEach((c) => c.setAttribute('width', '34%'))
|
||||
dom
|
||||
.querySelectorAll('.markets-value-cell')
|
||||
.forEach((c) => c.setAttribute('width', '34%'))
|
||||
dom.querySelectorAll('.markets-bubble-cell').forEach((c) => {
|
||||
const table = c.querySelector('.markets-bubble')
|
||||
if (table) {
|
||||
// replace the nested table with the text
|
||||
const e = table.querySelector('.markets-table-text')
|
||||
e && table.parentNode?.replaceChild(e, table)
|
||||
}
|
||||
c.setAttribute('width', '12%')
|
||||
})
|
||||
dom
|
||||
.querySelectorAll('table [role="presentation"]')
|
||||
.forEach((table) => (table.className = 'morning-brew-markets'))
|
||||
|
||||
return Promise.resolve(dom)
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ import { User } from '../entity/user'
|
||||
import { ILike } from 'typeorm'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import addressparser from 'addressparser'
|
||||
import { MorningBrewHandler } from './morning-brew-handler'
|
||||
|
||||
const logger = buildLogger('utils.parse')
|
||||
|
||||
@ -57,6 +58,7 @@ const HANDLERS = [
|
||||
new AxiosHandler(),
|
||||
new BloombergHandler(),
|
||||
new GolangHandler(),
|
||||
new MorningBrewHandler(),
|
||||
]
|
||||
|
||||
/** Hook that prevents DOMPurify from removing youtube iframes */
|
||||
|
||||
@ -226,7 +226,7 @@ Readability.prototype = {
|
||||
|
||||
// These are the classes that readability sets itself.
|
||||
CLASSES_TO_PRESERVE: [
|
||||
"page", "twitter-tweet", "tweet-placeholder", "instagram-placeholder",
|
||||
"page", "twitter-tweet", "tweet-placeholder", "instagram-placeholder", "morning-brew-markets"
|
||||
],
|
||||
|
||||
// Classes of placeholder elements that can be empty but shouldn't be removed
|
||||
@ -831,6 +831,10 @@ Readability.prototype = {
|
||||
* @return void
|
||||
**/
|
||||
_prepArticle: async function (articleContent) {
|
||||
if (this._keepTables) {
|
||||
// replace tables which is not a preserve class with divs for newsletters
|
||||
this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ["table"]).filter(t => !this._classesToPreserve.includes(t.className)), "div");
|
||||
}
|
||||
await this._createPlaceholders(articleContent);
|
||||
this._cleanStyles(articleContent);
|
||||
// Check for data tables before we continue, to avoid removing items in
|
||||
@ -874,7 +878,7 @@ Readability.prototype = {
|
||||
|
||||
// Do these last as the previous stuff may have removed junk
|
||||
// that will affect these
|
||||
!this._keepTables && this._cleanConditionally(articleContent, "table");
|
||||
this._cleanConditionally(articleContent, "table");
|
||||
this._cleanConditionally(articleContent, "ul");
|
||||
this._cleanConditionally(articleContent, "div");
|
||||
|
||||
@ -918,9 +922,6 @@ Readability.prototype = {
|
||||
}
|
||||
});
|
||||
|
||||
// replace tables of article content with divs for newsletters
|
||||
this._keepTables && this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ["table"]), "div");
|
||||
|
||||
// Final clean up of nodes that might pass readability conditions but still contain redundant text
|
||||
// For example, this article (https://www.sciencedirect.com/science/article/abs/pii/S0047248498902196)
|
||||
// has a "View full text" anchor at the bottom of the page
|
||||
|
||||
@ -30,6 +30,11 @@ const enableJavascriptForUrl = (url) => {
|
||||
};
|
||||
|
||||
function generateTestcase(slug) {
|
||||
const options = {};
|
||||
if (slug.startsWith("newsletters/")) {
|
||||
// keep the newsletter content in tables
|
||||
options.keepTables = true;
|
||||
}
|
||||
var destRoot = path.join(testcaseRoot, slug);
|
||||
|
||||
fs.mkdir(destRoot, function (err) {
|
||||
@ -42,12 +47,12 @@ function generateTestcase(slug) {
|
||||
console.error("Source existed but couldn't be read?");
|
||||
process.exit(1);
|
||||
}
|
||||
onResponseReceived(null, data, destRoot);
|
||||
onResponseReceived(null, data, destRoot, options);
|
||||
});
|
||||
} else {
|
||||
fs.writeFile(path.join(destRoot, 'url.txt'), argURL, () => null);
|
||||
fetchSource(argURL, function (fetchErr, data) {
|
||||
onResponseReceived(fetchErr, data, destRoot);
|
||||
onResponseReceived(fetchErr, data, destRoot, options);
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -55,7 +60,7 @@ function generateTestcase(slug) {
|
||||
}
|
||||
fs.writeFile(path.join(destRoot, 'url.txt'), argURL, () => null);
|
||||
fetchSource(argURL, function (fetchErr, data) {
|
||||
onResponseReceived(fetchErr, data, destRoot);
|
||||
onResponseReceived(fetchErr, data, destRoot, options);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -198,7 +203,7 @@ function sanitizeSource(html, callbackFn) {
|
||||
}, callbackFn);
|
||||
}
|
||||
|
||||
function onResponseReceived(error, source, destRoot) {
|
||||
function onResponseReceived(error, source, destRoot, options) {
|
||||
if (error) {
|
||||
console.error("Couldn't tidy source html!");
|
||||
console.error(error);
|
||||
@ -217,11 +222,11 @@ function onResponseReceived(error, source, destRoot) {
|
||||
if (debug) {
|
||||
console.log("Running readability stuff");
|
||||
}
|
||||
await runReadability(source, path.join(destRoot, "expected.html"), path.join(destRoot, "expected-metadata.json"));
|
||||
await runReadability(source, path.join(destRoot, "expected.html"), path.join(destRoot, "expected-metadata.json"), options);
|
||||
});
|
||||
}
|
||||
|
||||
async function runReadability(source, destPath, metadataDestPath) {
|
||||
async function runReadability(source, destPath, metadataDestPath, options) {
|
||||
var uri = "http://fakehost/test/page.html";
|
||||
var myReader, result, readerable;
|
||||
try {
|
||||
@ -230,7 +235,7 @@ async function runReadability(source, destPath, metadataDestPath) {
|
||||
readerable = isProbablyReaderable(dom);
|
||||
// We pass `caption` as a class to check that passing in extra classes works,
|
||||
// given that it appears in some of the test documents.
|
||||
myReader = new Readability(dom, { classesToPreserve: ["caption"], url: uri });
|
||||
myReader = new Readability(dom, { classesToPreserve: ["caption"], url: uri, ...options });
|
||||
result = await myReader.parse();
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Daily Brew // Morning Brew // Update",
|
||||
"byline": null,
|
||||
"dir": null,
|
||||
"excerpt": "",
|
||||
"siteName": null,
|
||||
"publishedDate": null,
|
||||
"language": "English",
|
||||
"readerable": true
|
||||
}
|
||||
@ -0,0 +1,617 @@
|
||||
<DIV class="page" id="readability-page-1">
|
||||
<DIV>
|
||||
<td>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table width="670" align="center" cellpadding="0" cellspacing="0" style="border: 0; width: 670px;" role="Presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<![endif]-->
|
||||
<div>
|
||||
<!-- HEADER -->
|
||||
<!--[if !mso ]> <!- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<p>
|
||||
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvP3V0bV9zb3VyY2U9aGVhZGVyX3NpZ251cCZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX2NhbXBhaWduPW1iJm1pZD05YWI0MjFkZTcxMjI3NWFlYTFiMDA1NTMxMjRiNTlhMA/6281aaf34fe052ec1c0710e7Bf20f1d7a">
|
||||
<img width="450" alt="Morning Brew" src="https://media.sailthru.com/5z8/1k4/4/2/5e86546217fa0.jpg">
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div>
|
||||
<tr>
|
||||
<th>
|
||||
<p> TOGETHER WITH </p>
|
||||
</th>
|
||||
<th>
|
||||
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9bG9nbw/6281aaf34fe052ec1c0710e7B2793e25b"><img alt="Facet Wealth" width="170" src="https://media2.morningbrew.com/uploads/company/logo/562/1641442199-logo_MB.png">
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p><strong>Good morning. </strong>Apple’s iOS 16 dropped for iPhone users yesterday, and once you download the software update you’ll be able to edit an iMessage within 15 minutes of sending it and delete one within two minutes. While that may be useful for most people, the only text we’ve ever regretted sending was, “Sure I’ll go to your friend’s improv show.”</p>
|
||||
<p>—<em>Max Knoblauch, Abby Rubenstein, Matty Merritt</em></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END HEADER -->
|
||||
<!-- MARKETS -->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<h3> MARKETS </h3>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Nasdaq -->
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<p>Nasdaq</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>12,266.41</p>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- END Nasdaq -->
|
||||
<!-- S&P -->
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<p>S&P</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>4,110.41</p>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- END S&P -->
|
||||
<!-- Dow -->
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<p>Dow</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>32,381.34</p>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- END Dow -->
|
||||
<!-- 10-Year -->
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<p>10-Year</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>3.358%</p>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- END 10-Year -->
|
||||
<!-- Bitcoin -->
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<p>Bitcoin</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>$22,311.05</p>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- END Bitcoin -->
|
||||
<!-- Apple -->
|
||||
<tr>
|
||||
<td>
|
||||
<div width="100%" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
|
||||
</td>
|
||||
<td>
|
||||
<p>Apple</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>$163.43</p>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- END Apple -->
|
||||
<!-- START REGULAR TEXT -->
|
||||
<tr>
|
||||
<td>
|
||||
<p> *Stock data as of market close, cryptocurrency data as of 11:00pm ET. <a target="_blank" rel="noopener" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvMjAyMC8xMC8xOS9tYXJrZXRzLTEwMS1yZWFkLXN0b2NrLWluZGV4ZXMtc2VjdXJpdGllcz91dG1fc291cmNlPW9uYm9hcmRpbmcmbWlkPTlhYjQyMWRlNzEyMjc1YWVhMWIwMDU1MzEyNGI1OWEw/6281aaf34fe052ec1c0710e7B61a4e895">Here's what these numbers mean.</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- END REGULAR TEXT -->
|
||||
<tr>
|
||||
<td>
|
||||
<ul>
|
||||
<li>
|
||||
<span><strong>Markets: </strong></span><span>Stocks continued to </span><span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuYmxvb21iZXJnLmNvbS9uZXdzL2FydGljbGVzLzIwMjItMDktMTEvYXNpYS1zZXQtdG8tam9pbi1yaXNrLXJhbGx5LWFzLWRvbGxhci13ZWFrZW5zLW1hcmtldHMtd3JhcD9zcm5kPXByZW1pdW0jeGo0eTd2emtn/6281aaf34fe052ec1c0710e7B04a495f4" target="_blank">rally</a></span><span> yesterday, with the S&P 500 posting its biggest four-day gain since June. Helping to boost markets was Apple, which rose on bullish news about preorders for the iPhone 14 Pro Max.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span><strong>Economy: </strong></span><span>Happy CPI Day to all who celebrate. The consumer price index report that drops this morning is expected to show that inflation </span><span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud3NqLmNvbS9saXZlY292ZXJhZ2Uvc3RvY2stbWFya2V0LW5ld3MtdG9kYXktMDktMTItMjAyMi9jYXJkL2luZmxhdGlvbi1kYXRhLW1heS1ub3Qtc2hha2UtZmVkLXpIWUJ3VGxKVG9zbUNVYTR5cVll/6281aaf34fe052ec1c0710e7Befa496ab" target="_blank">fell</a></span><span> to 8% last month. It’s the last major piece of data that will arrive before the Fed decides how big to go on interest rate hikes next week. </span>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END MARKETS -->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<h3> AUTO </h3>
|
||||
<h2>
|
||||
<SPAN color="#000000"> Nikola founder’s fraud trial gets rolling </SPAN>
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvbmlrb2xhLWZvdW5kZXItdHJldm9yLW1pbHRvbi1mcmF1ZC10cmlhbC1iZWdpbnM_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldyZtaWQ9OWFiNDIxZGU3MTIyNzVhZWExYjAwNTUzMTI0YjU5YTA/6281aaf34fe052ec1c0710e7B5c61b709">
|
||||
<img alt="woman with back to camera looking at paintings" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/c79c555f5fec0ac4b96128359b8a82ed658fb88c-5265x3250.jpg?w=670&q=70&auto=format">
|
||||
</a><span><small><em>Stefan Puchner/Getty Images</em></small></span>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p>The fraud <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubnl0aW1lcy5jb20vMjAyMi8wOS8xMi9idXNpbmVzcy90cmV2b3ItbWlsdG9uLW5pa29sYS10cmlhbC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B0e27791e" target="_blank">trial</a> of Trevor Milton, the founder and former CEO of electric truck company Nikola Motors, began yesterday with jury selection. Milton, who pleaded not guilty in the case, stands accused of lying about his company’s progress in developing electric vehicles, leading to huge losses for investors.</p>
|
||||
<p>The case—a shoo-in for Hulu’s next ripped-from-the-headlines original that everyone at work except you is watching—is seen as a cautionary tale of buying into the hype around companies before they deliver a single product.</p>
|
||||
<p>So how did Nikola go from the third-largest auto company in the US in 2020 with a $33 billion market value to just a $2.3 billion market value as of yesterday with an indicted founder? </p>
|
||||
<h2>A hype-y road</h2>
|
||||
<p>Milton founded Nikola in 2015, capitalizing on investor fervor around EVs, particularly those made by Elon Musk’s Tesla. In June 2020, the company went public via a SPAC. Prosecutors claim that Milton misled investors by making false claims about Nikola’s ability to produce hydrogen and by releasing a now-infamous promotional video that showed a moving Nikola truck (not disclosed: the truck was merely rolling downhill in neutral).</p>
|
||||
<p>Beyond simply drumming up name association with retail investor darling Tesla, Milton also deliberately targeted less knowledgeable investors by promoting Nikola’s stock on social media and in interviews, prosecutors allege.</p>
|
||||
<p>And those retail investors ultimately bore the brunt of Nikola’s losses when its stock dropped 40% following Milton’s resignation in September 2020, days after an activist short seller published a scathing report calling the company an “intricate fraud built on dozens of lies.”</p>
|
||||
<h2>Where things stand now</h2>
|
||||
<p>In December, Nikola agreed to pay a $125 million penalty to settle an SEC fraud investigation. The company, which is still around and has begun production on a battery-powered semi-truck model, reported a net loss of $173 million in the second quarter.</p>
|
||||
<p>Before stepping down, Milton purchased a $32.5 million Utah ranch and a jet, and since his resignation, he has sold more than $300 million in company stock. The main charge against him carries a maximum sentence of 25 years, though he’s likely to see much less time, if he’s even convicted.—<em>MK</em></p>
|
||||
</div>
|
||||
<!-- SOCIAL LINKS -->
|
||||
<!-- END SOCIAL LINKS --->
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<p>In our current economic state, there’s a lot of attention on money—how much it’s worth, how much we pay in interest, inflation, the list goes on.</p>
|
||||
<p>And if all that news has stirred up questions like, <em>“Am I doing the right things with my money so that I can get the most out of life?” </em><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9dGV4dDE/6281aaf34fe052ec1c0710e7Bfdf9733c" target="_blank" rel="noopener">Facet can help</a>.</p>
|
||||
<p>Their <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9dGV4dDI/6281aaf34fe052ec1c0710e7B27c31422" target="_blank" rel="noopener">CERTIFIED FINANCIAL PLANNER™ professionals work with you 1:1</a> for an affordable fixed fee to answer questions about your <em>entire</em> financial life, not just basic money management. Plus, their proprietary tech helps you get the full picture of where you’re at financially <em>and</em> where you’re going.</p>
|
||||
<p>Brew readers get 2 free* months in their first year of financial planning. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9dGV4dDM/6281aaf34fe052ec1c0710e7B1535db94" target="_blank" rel="noopener">Sign up here</a>.</p>
|
||||
<!-- SOCIAL LINKS -->
|
||||
<!-- END SOCIAL LINKS --->
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<img alt="President Biden" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/2b6b83f5903d74c8ad604862b77e858ccfd0b071-1500x1000.png?w=670&q=70&auto=format">
|
||||
<span><small><em>Mandel Ngan/Getty Images</em></small></span>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/rocket_1f680.png" width="18" alt=""> <strong>Biden goes for a “moonshot” on cancer.</strong> Referencing JFK’s famous speech on its 60th anniversary, President Biden <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9hYmNuZXdzLmdvLmNvbS9Qb2xpdGljcy9iaWRlbi1wdXNoZXMtZWZmb3J0cy1lbmQtY2FuY2VyLTYwdGgtYW5uaXZlcnNhcnktamZrcy9zdG9yeT9pZD04OTc1NTI2NA/6281aaf34fe052ec1c0710e7B5d3a52c2" target="_blank">said</a> yesterday he’s laying out his “moonshot” for eradicating cancer. The plan involves a new department focused on health and biomedical research, executive orders beefing up domestic biomanufacturing, and a government group called the “Cancer Cabinet.” In hopeful news that might help move things along, drugmaker Amgen <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud3NqLmNvbS9hcnRpY2xlcy9uZXctY2FuY2VyLWRydWctYmVhdHMtY2hlbW90aGVyYXB5LWluLXN0dWR5LTExNjYyOTk2Njk0P3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B3315a652" target="_blank">reported</a> its new cancer drug was more effective in some areas than chemo for late-stage lung cancer patients.</p>
|
||||
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/office-building_1f3e2.png" width="18" alt=""> <strong>Goldman Sachs to lay off hundreds. </strong>The bank plans to reinstate annual employee <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubnl0aW1lcy5jb20vMjAyMi8wOS8xMi9idXNpbmVzcy9kZWFsYm9vay9nb2xkbWFuLXNhY2hzLWxheW9mZnMuaHRtbD91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7Bb4e674b4" target="_blank">culls</a>, which it had suspended during the pandemic, and may start cutting jobs as soon as next week. Typically, Goldman targets between 1% and 5% of its workforce for layoffs each year, and this round is expected to be at the lower end of the spectrum (current headcount: 47,000). The expected layoffs come after Goldman’s profits fell by nearly half in the second quarter compared to the year before as deal-making slowed.</p>
|
||||
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/face-with-medical-mask_1f637.png" width="18" alt=""> <strong>15,000 nurses walked off the job in Minnesota. </strong>The nurses involved in the largest-ever private sector nursing <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud2FzaGluZ3RvbnBvc3QuY29tL2J1c2luZXNzLzIwMjIvMDkvMTIvbWlubmVzb3RhLW51cnNlcy1zdHJpa2UvP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B1d217377" target="_blank">strike</a> in the US plan to stay out for three days to demand fixes for understaffing, as well as higher pay. Though this strike targets 13 hospitals in the Minneapolis–St. Paul area, facilities across the country had trouble finding enough nurses even before the pandemic. Unions in at least two other states have also authorized work stoppages in the past month.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<h3> EDUCATION </h3>
|
||||
<h2>
|
||||
<SPAN color="#000000"> The list that no one likes is out </SPAN>
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvcmVkLWZsYWdzLWFjY29tcGFueS11cy1uZXdzLWNvbGxlZ2UtcmFua2luZz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3Jm1pZD05YWI0MjFkZTcxMjI3NWFlYTFiMDA1NTMxMjRiNTlhMA/6281aaf34fe052ec1c0710e7B3c094b5e">
|
||||
<img alt="Students walking on campus in the fall." width="670" src="https://cdn.sanity.io/images/bl383u0v/production/f2f89e69014b2b9825d98e6fa25533434cf46bee-5630x3608.jpg?w=670&q=70&auto=format">
|
||||
</a><span><small><em><em>Jon Lovette/Getty Images</em></em></small></span>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p>US News & World Report released its <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cudXNuZXdzLmNvbS9iZXN0LWNvbGxlZ2VzP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7Baffcfd47" target="_blank">yearly ranking</a> of the best colleges in the country yesterday. But this year’s list comes amid a growing number of complaints about how the scores that mean a lot to wide-eyed future loan borrowers (and the administrators who will eventually ask them for money) are calculated.</p>
|
||||
<p>Besides controversial criteria like incoming students’ SAT scores and the level of alumni donations, one of the main problems critics have with the list is that it attributes 20% of its ranking formula to what amounts to basically a popularity contest. US News sends a yearly survey to college admins asking them to rate other schools’ “academic quality.”</p>
|
||||
<p>Even Education Secretary Miguel Cardona, although not referring to the US News list directly, said last month that college rankings that value reputation above things like economic mobility are “a joke.”</p>
|
||||
<p><strong>There’s even more drama this year…</strong>Columbia University dropped from No. 2 to No. 18 because it didn’t submit any data while it investigated a math professor’s claims that the school might be fudging some numbers. On Friday, the university <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25uLmNvbS8yMDIyLzA5LzExL21lZGlhL2NvbHVtYmlhLXVuaXZlcnNpdHktdXMtbmV3cy13b3JsZC1yZXBvcnQtY29sbGVnZS1yYW5raW5ncy9pbmRleC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B5e285774" target="_blank">admitted</a> to the fudging.</p>
|
||||
<p><strong>Final fun fact: </strong>19 of the top 20 schools on this year’s list cost<strong> </strong>$55,000+ per year to attend.<em>—MM</em></p>
|
||||
</div>
|
||||
<!-- SOCIAL LINKS -->
|
||||
<!-- END SOCIAL LINKS --->
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<h3> FOOD & BEVERAGE </h3>
|
||||
<h2> Starbucks puts the ‘mint’ in peppermint </h2>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvMjAyMi8wOS8xMi9zdGFyYnVja3Mtd2FkZXMtaW50by13ZWIzP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXcmbWlkPTlhYjQyMWRlNzEyMjc1YWVhMWIwMDU1MzEyNGI1OWEw/6281aaf34fe052ec1c0710e7Bf03d356a">
|
||||
<img alt="A girl drinks from a Starbucks cup while looking at her phone" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/b5566f12b65660d4c88fbd46381d016ddf1b59d8-1500x1000.png?w=670&q=70&auto=format">
|
||||
</a><span><small><em><em>Zhang Peng/Getty Images</em></em></small></span>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p>The Starbucks customer loyalty program inspires more devotion than the Mocha Joe’s punch card that always gets lost in your wallet. Now the coffee chain is trying to spark even more loyalty by taking its app-based program to the next technological level with NFTs.</p>
|
||||
<p>The company <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly90ZWNoY3J1bmNoLmNvbS8yMDIyLzA5LzEyL3N0YXJidWNrcy11bnZlaWxzLWl0cy1ibG9ja2NoYWluLWJhc2VkLWxveWFsdHktcGxhdGZvcm0tYW5kLW5mdC1jb21tdW5pdHktc3RhcmJ1Y2tzLW9keXNzZXkvP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7Be911511d" target="_blank">unveiled</a> its Starbucks Odyssey program yesterday, a platform using Polygon, an Ethereum network, that coffee drinkers can log into with their existing program credentials to play games or take challenges to earn non-fungible tokens. They’ll also be available for purchase via credit card, no crypto necessary.</p>
|
||||
<p>These NFTs, called “journey stamps,” will unlock rewards for users that go beyond the typical free coffee, like events or trips.</p>
|
||||
<p>Customers can join a <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93YWl0bGlzdC5zdGFyYnVja3MuY29tLz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3Iy9sYW5kaW5n/6281aaf34fe052ec1c0710e7B23409618" target="_blank">waitlist</a> now, but only time will tell whether people will be clambering for coffee-themed digital art. The project is a relatively recent one—an exec told TechCrunch it’s only been in the works for six months, but in that time the market for NFTs has changed drastically. Trading volume at OpenSea, the most popular NFT marketplace, plunged 99% from early May to late August, per Fortune.</p>
|
||||
<p><strong>Zoom out: </strong>The Web3 push is one of many big changes coming to Starbucks. A new leader will succeed interim CEO Howard Schultz, but not before Schultz lays out a new strategy at today’s investor day that’s aimed at boosting efficiency.—<em>AR</em></p>
|
||||
</div>
|
||||
<!-- SOCIAL LINKS -->
|
||||
<!-- END SOCIAL LINKS --->
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<img alt="A phone with an Instagram Reels icon and a 0 views notification" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/ce027ffb07e2772399d217c27dd3ee334e59a5ab-1500x1000.jpg?w=670&q=70&auto=format">
|
||||
<span><small><em>Francis Scialabba</em></small></span>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p><strong>Stat: </strong>People spend 17.6 million hours a day watching Instagram Reels, just a fraction of the 197.8 million hours users waste daily on TikTok, according to an internal Meta document viewed by the <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud3NqLmNvbS9hcnRpY2xlcy9pbnN0YWdyYW0tcmVlbHMtdGlrdG9rLW1ldGEtZmFjZWJvb2stZG9jdW1lbnRzLTExNjYyOTkxNzc3P21vZD1ocF9sZWFkX3BvczEw/6281aaf34fe052ec1c0710e7Bb70f057f" target="_blank">Wall Street Journal</a>. While Reels might seem inescapable in your feed, engagement is trending down, dropping 13.6% in August from the month before, the document showed.</p>
|
||||
<p><strong>Quote: </strong>“Nothing can justify the persistence of this fundamental abuse of human rights.”</p>
|
||||
<p>The director-general of the UN’s labor standards agency yesterday called for an “all-hands-on-deck approach” to combating modern slavery after releasing a <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9uZXdzLnVuLm9yZy9lbi9zdG9yeS8yMDIyLzA5LzExMjY0MjE_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/6281aaf34fe052ec1c0710e7Bbb9fba47" target="_blank">report</a> that found 50 million people were living in modern slavery—28 million in forced labor and 22 million in forced marriages in 2021. That’s 10 million more than in 2016.</p>
|
||||
<p><strong>Read: </strong>Johnson & Johnson and a new war on consumer protection. (<a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubmV3eW9ya2VyLmNvbS9tYWdhemluZS9hLXJlcG9ydGVyLWF0LWxhcmdlLzA5LzE5L2pvaG5zb24tam9obnNvbi1hbmQtYS1uZXctd2FyLW9uLWNvbnN1bWVyLXByb3RlY3Rpb24_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/6281aaf34fe052ec1c0710e7B0d284cc0" target="_blank">The New Yorker</a>)</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<h3> SPONSORED BY ACCRUE X VINCERO </h3>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly92aW5jZXJvY29sbGVjdGl2ZS5jb20vcGFnZXMvbW9ybmluZ2JyZXcyP3V0bV9zb3VyY2U9bWVkaWEmdXRtX21lZGl1bT1lbWFpbC1uZXdzbGV0dGVyJnV0bV9jb250ZW50PXNlcHQtMTMmdXRtX2NhbXBhaWduPW1vcm5pbmctYnJldyZscD1pbWFnZQ/6281aaf34fe052ec1c0710e7B2ee0c099">
|
||||
<img alt="Accrue X Vincero" width="670" src="https://media2.morningbrew.com/uploads/placement/images/7560/1661449748-Accrue_x_MB.png">
|
||||
</a>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>Style meets savings. </strong>With Accrue Savings, you can earn cash rewards from brands like Vincero by saving up for your purchases without relying on credit. Elevate your everyday carry with Vincero’s ethically crafted, premium lifestyle accessories and <strong>earn 15% in cash rewards toward your purchase</strong> when you save up for your Vincero accessories with an Accrue Savings account. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly92aW5jZXJvY29sbGVjdGl2ZS5jb20vcGFnZXMvbW9ybmluZ2JyZXcyP3V0bV9zb3VyY2U9bWVkaWEmdXRtX21lZGl1bT1lbWFpbC1uZXdzbGV0dGVyJnV0bV9jb250ZW50PXNlcHQtMTMmdXRtX2NhbXBhaWduPW1vcm5pbmctYnJldyZscD10ZXh0MQ/6281aaf34fe052ec1c0710e7B4f3caa10" target="_blank" rel="noopener">Sign up here</a> in just a few minutes today.</p>
|
||||
<!-- SOCIAL LINKS -->
|
||||
<!-- END SOCIAL LINKS --->
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<h3> WHAT ELSE IS BREWING </h3>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<ul>
|
||||
<li>
|
||||
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9hcG5ld3MuY29tL2FydGljbGUvcnVzc2lhLXVrcmFpbmUta3lpdi1raGFya2l2LWE2OTFhYjE2MDE2YWFiMDFjZWRiNjhlYTVlMjQ3YjM3P3V0bV9zb3VyY2U9aG9tZXBhZ2UmdXRtX21lZGl1bT1Ub3BOZXdzJnV0bV9jYW1wYWlnbj1wb3NpdGlvbl8x/6281aaf34fe052ec1c0710e7B3ca6f7cf" target="_blank">Ukraine</a></span><span> took back territory from Russia all the way to its northeastern border in some areas, leading one former member of Russia’s parliament to call for peace talks on live TV.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25uLmNvbS8yMDIyLzA5LzEyL2VudGVydGFpbm1lbnQvZW1teS1hd2FyZHMtd2lubmVycy9pbmRleC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B1640a2f5" target="_blank">Streaming services</a></span><span> had a good night at the Emmys, with HBO Max’s </span><span><em>Succession</em></span><span> winning best drama and </span><span><em>The White Lotus</em></span><span> nabbing the statue for best limited series. The Apple TV+ series </span><span><em>Ted Lasso</em></span><span> took the top prize for a comedy series.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25ldC5jb20vbmV3cy9zb2NpYWwtbWVkaWEvdHdpdHRlci13aGlzdGxlYmxvd2VyLXRvLXRlc3RpZnktYmVmb3JlLXNlbmF0ZS1jb21taXR0ZWUtaG93LXRvLXdhdGNoLz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7B55299175" target="_blank">Twitter whistleblower</a></span><span> Peiter “Mudge” Zatko will testify today before the Senate Judiciary Committee about the platform’s privacy and security vulnerabilities.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25uLmNvbS8yMDIyLzA5LzExL2J1c2luZXNzL2Vsb24tbXVzay1naXJsZnJpZW5kLW1lbWVudG9zLWF1Y3Rpb24tdHJuZC9pbmRleC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B4a709ce7" target="_blank">Elon Musk’s</a></span><span> college girlfriend is auctioning off mementos she saved from the relationship, including photos and a signed birthday card.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<h3> BREW'S BETS </h3>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p><strong>Taking back texts: </strong>A complete <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubWFjcnVtb3JzLmNvbS9ndWlkZS9pb3MtMTYtZ2V0dGluZy1zdGFydGVkLz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7B6b3f057c" target="_blank">guide</a> to Apple’s new operating system, which lets you personalize your lock screen and a lot more.</p>
|
||||
<p><strong>Sleight of hand: </strong><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g_dj1USlgtejBPOVRPRQ/6281aaf34fe052ec1c0710e7B785bfa71" target="_blank">Watch</a> the reigning world champion of card magic (a real title) do his thing.</p>
|
||||
<p><strong>Interesting pod alert: </strong>After years on a personal odyssey, journalist Lauren Ober found out she was autistic. <em>The Loudest Girl in the World</em> shares Lauren’s journey once she was diagnosed. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9wb2RjYXN0cy5wdXNoa2luLmZtL2xvdWRlc3Q_c2lkPWJyZXc/6281aaf34fe052ec1c0710e7Ba62d29ba" target="_blank">Listen now</a>.</p>
|
||||
<p><strong>Just like </strong><strong>⌘C, ⌘V</strong><strong>:</strong> With CopyTrader,™ eToro’s most popular feature, you can automatically copy the moves of real investors in real time. No more second-guessing your crypto decisions. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9hZC5kb3VibGVjbGljay5uZXQvZGRtL2Nsay81Mzc1MTQyMTE7MzQ2MTc5NDQ3O2o_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/6281aaf34fe052ec1c0710e7B6e441da8" target="_blank" data-saferedirecturl="https://www.google.com/url?q=https://link.morningbrew.com/click/28290300.1551169/aHR0cHM6Ly9hZC5kb3VibGVjbGljay5uZXQvZGRtL2Nsay81MzExMzIyOTM7MzM5MzI4NDYxO2Q_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/60577279008ae1206753d17aCec96be2b/email&source=gmail&ust=1660655963996000&usg=AOvVaw3M7UKbwIrtq2cHmWuUXow1" rel="noopener">Get started today</a>.*</p>
|
||||
<placementslot data-id="6447e00df1b4"></placementslot>
|
||||
<placementslot data-id="098900774153"></placementslot>
|
||||
<p><sup><em>*This is sponsored advertising content.</em></sup></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p><strong>Brew Mini: </strong>If you know what kind of car James Bond drives, you’re more than 10% of the way to completing today’s Mini. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvMjAyMi8wOS8xMi9icmV3LW1pbmktY2hhbXBpb24_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldyZtaWQ9OWFiNDIxZGU3MTIyNzVhZWExYjAwNTUzMTI0YjU5YTA/6281aaf34fe052ec1c0710e7Be25db10b" target="_blank">Solve it here</a>.</p>
|
||||
<h2>Tagline trivia</h2>
|
||||
<p>There’s nothing cringier than a movie tagline. We’ll give you the tagline for a film, and you have to name the film.</p>
|
||||
<ol>
|
||||
<li><span>“Midnight never strikes when you’re in love.”</span></li>
|
||||
<li><span>“Work sucks.”</span></li>
|
||||
<li><span>“You don’t get to 500 million friends without making a few enemies.”</span></li>
|
||||
<li><span>“The longer you wait, the harder it gets.”</span></li>
|
||||
<li><span>“A comedy of trial and error.”</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9iaXQubHkvM1JaUzAweT91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7C1274f829">
|
||||
<img alt="How to save money in a recession" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/9112f53a4716e66a332be3167daffbfb24c76ecb-1280x720.jpg?w=670&q=70&auto=format">
|
||||
</a>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p>Saving at any point can be a challenge. Saving during an economic downturn? Mission impossible. But Anish Mitra uses his knowledge from 10 years on Wall Street to teach you how saving amid slowing economic growth doesn't have to be difficult. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9iaXQubHkvM1JaUzAweT91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7D1274f829" target="_blank">Watch here</a>.</p>
|
||||
<p><strong>Check out more from the Brew: </strong></p>
|
||||
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/camera-with-flash_1f4f8.png" width="18" alt=""> On <em>Business Casual</em>: Nora chats with Alex Ma, the CEO and co-founder of Poparazzi, an app that allows you to build your friends’ profile pages instead of your own. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9saW5rLmNodGJsLmNvbS9QY2FPSmhXTT91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7Bcfec464d" target="_blank">Listen</a> or <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9iaXQubHkvM0J5d0U0Vj91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7B63a59b90" target="_blank">watch</a> here.</p>
|
||||
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/memo_1f4dd.png" width="18" alt=""> Ready to jumpstart your career but don’t know where to start? We’ve got you covered. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9sZWFybmluZy5tb3JuaW5nYnJldy5jb20vP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXcmbWlkPTlhYjQyMWRlNzEyMjc1YWVhMWIwMDU1MzEyNGI1OWEw/6281aaf34fe052ec1c0710e7B08135aa7" target="_blank">Take our short quiz </a>and find out which Morning Brew Accelerator is right for you.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- ARTICLE --->
|
||||
<!--[if !mso ]><! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<p>1. <em>Cinderella</em></p>
|
||||
<p>2. <em>Office Space</em></p>
|
||||
<p>3. <em>The Social Network</em></p>
|
||||
<p>4. <em>The 40-Year-Old Virgin</em></p>
|
||||
<p>5. <em>My Cousin Vinny</em></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div>
|
||||
<!-- <![endif]-->
|
||||
<!-- END ARTICLE --->
|
||||
<!-- DISCLAIMER -->
|
||||
<!--[if !mso]> <! -- -->
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<th>
|
||||
<div width="100%">
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>✢ A Note From Facet Wealth</strong></p>
|
||||
<p>Facet Wealth is an SEC Registered Investment Advisor headquartered in Baltimore, Maryland. This is not an offer to sell securities or the solicitation of an offer to purchase securities. This is not investment, financial, legal, or tax advice. </p>
|
||||
<p>*Two months free offer is only valid for an annual fee paid at the time of signing.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</div><!-- <![endif]-->
|
||||
<!-- END DISCLAIMER -->
|
||||
<!-- FOOTER -->
|
||||
<!-- END FOOTER -->
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</DIV>
|
||||
</DIV>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@
|
||||
https://www.morningbrew.com/daily/issues/rolling-downhill
|
||||
@ -14,14 +14,23 @@ var testPageRoot = path.join(__dirname, "test-pages");
|
||||
|
||||
exports.getTestPages = function(isOmnivore = null) {
|
||||
const root = isOmnivore ? `${testPageRoot}/omnivore` : testPageRoot;
|
||||
return fs.readdirSync(root).filter(dir => dir !== 'omnivore').map(function(dir) {
|
||||
return {
|
||||
const testPages = [];
|
||||
const testPageDirs = fs.readdirSync(root).filter(dir => dir !== 'omnivore');
|
||||
testPageDirs.forEach(function(dir) {
|
||||
if (dir === 'newsletters') {
|
||||
// newsletters are a special case, they are in a subdirectory
|
||||
testPageDirs.push(fs.readdirSync(path.join(root, dir)).map(subdir => path.join(dir, subdir)));
|
||||
return;
|
||||
}
|
||||
|
||||
testPages.push({
|
||||
dir: dir,
|
||||
source: readFile(path.join(root, dir, "source.html")),
|
||||
expectedContent: readFile(path.join(root, dir, "expected.html")),
|
||||
expectedMetadata: readJSON(path.join(root, dir, "expected-metadata.json")),
|
||||
};
|
||||
});
|
||||
});
|
||||
return testPages;
|
||||
};
|
||||
|
||||
exports.prettyPrint = function(html) {
|
||||
|
||||
@ -337,4 +337,27 @@ on smaller screens we display the note icon
|
||||
}
|
||||
}
|
||||
|
||||
.article-inner-css .morning-brew-markets {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.article-inner-css .morning-brew-markets tbody{
|
||||
width: 100%;
|
||||
display: table;
|
||||
}
|
||||
|
||||
.article-inner-css .morning-brew-markets td:nth-child(1) {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.article-inner-css .morning-brew-markets td:nth-child(2) {
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.article-inner-css .morning-brew-markets td:nth-child(3) {
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.article-inner-css .morning-brew-markets td:nth-child(4) {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user