diff --git a/android/Omnivore/app/build.gradle b/android/Omnivore/app/build.gradle index 66cccad8f..ef847fd4d 100644 --- a/android/Omnivore/app/build.gradle +++ b/android/Omnivore/app/build.gradle @@ -17,8 +17,8 @@ android { applicationId "app.omnivore.omnivore" minSdk 23 targetSdk 32 - versionCode 7 - versionName "0.0.7" + versionCode 9 + versionName "0.0.9" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt index 3b219d6a3..075eb297b 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt @@ -1,5 +1,7 @@ package app.omnivore.omnivore +import app.omnivore.omnivore.ui.reader.WebFont + object Constants { const val apiURL = BuildConfig.OMNIVORE_API_URL const val webURL = BuildConfig.OMNIVORE_WEB_URL @@ -10,6 +12,11 @@ object DatastoreKeys { const val omnivoreAuthToken = "omnivoreAuthToken" const val omnivoreAuthCookieString = "omnivoreAuthCookieString" const val omnivorePendingUserToken = "omnivorePendingUserToken" + const val preferredWebFontSize = "preferredWebFontSize" + const val preferredWebLineHeight = "preferredWebLineHeight" + const val preferredWebMaxWidthPercentage = "preferredWebMaxWidthPercentage" + const val preferredWebFontFamily = "preferredWebFontFamily" + const val prefersWebHighContrastText = "prefersWebHighContrastText" } object AppleConstants { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebPreferencesDialog.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebPreferencesDialog.kt new file mode 100644 index 000000000..c2466dbe0 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebPreferencesDialog.kt @@ -0,0 +1,184 @@ +package app.omnivore.omnivore.ui.reader + +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import app.omnivore.omnivore.R + +@Composable +fun WebPreferencesDialog(onDismiss: () -> Unit, webReaderViewModel: WebReaderViewModel) { + Dialog(onDismissRequest = { onDismiss() }) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + modifier = Modifier + .height(300.dp) + ) { + WebPreferencesView(webReaderViewModel) + } + } +} + +@Composable +fun WebPreferencesView(webReaderViewModel: WebReaderViewModel) { + val currentWebPreferences = webReaderViewModel.storedWebPreferences() + val isFontListExpanded = remember { mutableStateOf(false) } + val highContrastTextSwitchState = remember { mutableStateOf(currentWebPreferences.prefersHighContrastText) } + val selectedWebFontRawValue = remember { mutableStateOf(currentWebPreferences.fontFamily.rawValue) } + + Column( + modifier = Modifier + .padding(top = 6.dp, start = 6.dp, end = 6.dp, bottom = 6.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.Center + ) { + Text("Web Preferences") + } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + ) { + Stepper( + label = "Font Size:", + onIncrease = { webReaderViewModel.updateFontSize(isIncrease = true) }, + onDecrease = { webReaderViewModel.updateFontSize(isIncrease = false) } + ) + + Stepper( + label = "Margin:", + onIncrease = { webReaderViewModel.updateMaxWidthPercentage(isIncrease = false) }, + onDecrease = { webReaderViewModel.updateMaxWidthPercentage(isIncrease = true) } + ) + + Stepper( + label = "Line Spacing:", + onIncrease = { webReaderViewModel.updateLineSpacing(isIncrease = true) }, + onDecrease = { webReaderViewModel.updateLineSpacing(isIncrease = false) } + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text("High Contrast Text") + Spacer(modifier = Modifier.weight(1.0F)) + Switch( + checked = highContrastTextSwitchState.value, + onCheckedChange = { + highContrastTextSwitchState.value = it + webReaderViewModel.updateHighContrastTextPreference(it) + } + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { isFontListExpanded.value = !isFontListExpanded.value }) + ) { + Text("Font Family") + Spacer(modifier = Modifier.weight(1.0F)) + Icon( + imageVector = + if (isFontListExpanded.value) + Icons.Filled.KeyboardArrowDown + else + Icons.Filled.KeyboardArrowRight, + contentDescription = null + ) + } + + if (isFontListExpanded.value) { + WebFont.values().forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { + webReaderViewModel.applyWebFont(it) + selectedWebFontRawValue.value = it.rawValue + }) + ) { + Text( + it.displayText, + modifier = Modifier + .padding(top = 6.dp, start = 6.dp, end = 6.dp, bottom = 6.dp) + ) + Spacer(modifier = Modifier.weight(1.0F)) + if (it.rawValue == selectedWebFontRawValue.value) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null + ) + } + } + } + } + } + } +} + +@Composable +fun Stepper(label: String, onIncrease: () -> Unit, onDecrease: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = label, + modifier = Modifier + .padding(bottom = 6.dp) + ) + + Spacer(modifier = Modifier.weight(1.0F)) + + IconButton(onClick = { onDecrease() }) { + Icon( + painter = painterResource(id = R.drawable.minus), + contentDescription = null + ) + } + + Divider( + color = Color.Black, + modifier = Modifier + .height(20.dp) + .width(1.dp) + ) + + IconButton(onClick = { onIncrease() }) { + Icon( + painter = painterResource(id = R.drawable.plus), + contentDescription = null + ) + } + } +} + +data class WebPreferences( + val textFontSize: Int, + val lineHeight: Int, + val maxWidthPercentage: Int, + val themeKey: String, + val fontFamily: WebFont, + val prefersHighContrastText: Boolean +) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt index 20e3e0439..afff5de0f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt @@ -8,28 +8,115 @@ import android.view.* import android.webkit.JavascriptInterface import android.webkit.WebView import android.webkit.WebViewClient -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel import app.omnivore.omnivore.R import com.google.gson.Gson +import java.util.* +import kotlin.math.roundToInt @Composable fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) { + var showWebPreferencesDialog by remember { mutableStateOf(false ) } + val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null) + val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null) + + val maxToolbarHeight = 48.dp + val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() } + val toolbarHeightPx = remember { mutableStateOf(maxToolbarHeightPx) } + + // Create a connection to the nested scroll system and listen to the scroll happening inside child Column + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + val newHeight = toolbarHeightPx.value + delta + toolbarHeightPx.value = newHeight.coerceIn(0f, maxToolbarHeightPx) + return Offset.Zero + } + } + } if (webReaderParams == null) { webReaderViewModel.loadItem(slug = slug) } if (webReaderParams != null) { - WebReader(webReaderParams!!, webReaderViewModel) + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(webReaderViewModel.scrollState) + + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .requiredHeight(height = maxToolbarHeight) + ) { + } + WebReader(webReaderParams!!, webReaderViewModel.storedWebPreferences(), webReaderViewModel) + } + + TopAppBar( + modifier = Modifier + .height(height = with(LocalDensity.current) { + toolbarHeightPx.value.roundToInt().toDp() + } ), + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + title = {}, + actions = { + IconButton(onClick = { showWebPreferencesDialog = true }) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = null + ) + } + } + ) + + if (showWebPreferencesDialog) { + WebPreferencesDialog( + onDismiss = { + showWebPreferencesDialog = false + }, + webReaderViewModel = webReaderViewModel + ) + } + + if (annotation != null) { + AnnotationEditView( + initialAnnotation = annotation!!, + onSave = { + webReaderViewModel.saveAnnotation(it) + }, + onCancel = { + webReaderViewModel.cancelAnnotationEdit() + } + ) + } + } } else { // TODO: add a proper loading view Text("Loading...") @@ -38,23 +125,22 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod @SuppressLint("SetJavaScriptEnabled") @Composable -fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) { - // TODO: maybe handle cases where js can be queued up? - val javascriptToExecute = remember { mutableStateOf(null) } - - val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null) +fun WebReader( + params: WebReaderParams, + preferences: WebPreferences, + webReaderViewModel: WebReaderViewModel +) { + val javascriptActionLoopUUID: UUID by webReaderViewModel + .javascriptActionLoopUUIDLiveData + .observeAsState(UUID.randomUUID()) WebView.setWebContentsDebuggingEnabled(true) val webReaderContent = WebReaderContent( - textFontSize = 12, - lineHeight = 150, - maxWidthPercentage = 100, + preferences = preferences, item = params.item, themeKey = "LightGray", - fontFamily = WebFont.SYSTEM , articleContent = params.articleContent, - prefersHighContrastText = false, ) val styledContent = webReaderContent.styledContent() @@ -100,24 +186,14 @@ fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) { ) } }, update = { - if (javascriptToExecute.value != null) { - it.evaluateJavascript(javascriptToExecute.value!!, null) + if (javascriptActionLoopUUID != webReaderViewModel.lastJavascriptActionLoopUUID) { + for (script in webReaderViewModel.javascriptDispatchQueue) { + Log.d("js", "executing script: $script") + it.evaluateJavascript(script, null) + } + webReaderViewModel.resetJavascriptDispatchQueue() } }) - - if (annotation != null) { - AnnotationEditView( - initialAnnotation = annotation!!, - onSave = { - val script = "var event = new Event('saveAnnotation');event.annotation = '$it';document.dispatchEvent(event);" - javascriptToExecute.value = script - webReaderViewModel.cancelAnnotationEdit() - }, - onCancel = { - webReaderViewModel.cancelAnnotationEdit() - } - ) - } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt index 599c55078..69bfb484f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderContent.kt @@ -1,6 +1,5 @@ package app.omnivore.omnivore.ui.reader -import android.util.Log import app.omnivore.omnivore.models.Highlight import app.omnivore.omnivore.models.LinkedItem import com.google.gson.Gson @@ -15,7 +14,6 @@ enum class WebFont(val displayText: String, val rawValue: String) { ROBOTO("Roboto", "Roboto"), CRIMSON_TEXT("Crimson Text", "Crimson Text"), SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"), - Inter("Inter", "Inter"), } enum class ArticleContentStatus(val rawValue: String) { @@ -39,29 +37,28 @@ data class ArticleContent( } data class WebReaderContent( - val textFontSize: Int, - val lineHeight: Int, - val maxWidthPercentage: Int, + val preferences: WebPreferences, val item: LinkedItem, val themeKey: String, - val fontFamily: WebFont, val articleContent: ArticleContent, - val prefersHighContrastText: Boolean ) { fun styledContent(): String { // TODO: Kotlinize these three values (pasted from Swift) val savedAt = "new Date(1662571290735.0).toISOString()" val createdAt = "new Date().toISOString()" - val publishedAt = "new Date().toISOString()" //if (item.publishDate != null) "new Date((item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" else "undefined" + val publishedAt = + "new Date().toISOString()" //if (item.publishDate != null) "new Date((item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" else "undefined" + val textFontSize = preferences.textFontSize + val highlightCssFilePath = "highlight${if (themeKey == "Gray") "-dark" else ""}.css" - val content = """ + return """ @@ -96,11 +93,11 @@ data class WebReaderContent( } window.fontSize = $textFontSize - window.fontFamily = "${fontFamily.rawValue}" - window.maxWidthPercentage = $maxWidthPercentage - window.lineHeight = $lineHeight + window.fontFamily = "${preferences.fontFamily.rawValue}" + window.maxWidthPercentage = $preferences.maxWidthPercentage + window.lineHeight = $preferences.lineHeight window.localStorage.setItem("theme", "$themeKey") - window.prefersHighContrastFont = $prefersHighContrastText + window.prefersHighContrastFont = $preferences.prefersHighContrastText window.enableHighlightBar = false @@ -109,7 +106,5 @@ data class WebReaderContent( """ - - return content } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index e9a6f4aaf..fa816a96f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -1,16 +1,20 @@ package app.omnivore.omnivore.ui.reader import android.util.Log +import androidx.compose.foundation.ScrollState import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.omnivore.omnivore.DatastoreKeys import app.omnivore.omnivore.DatastoreRepository import app.omnivore.omnivore.models.LinkedItem import app.omnivore.omnivore.networking.* import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.json.JSONObject +import java.util.* import javax.inject.Inject data class WebReaderParams( @@ -27,8 +31,13 @@ class WebReaderViewModel @Inject constructor( private val datastoreRepo: DatastoreRepository, private val networker: Networker ): ViewModel() { + var lastJavascriptActionLoopUUID: UUID = UUID.randomUUID() + var javascriptDispatchQueue: MutableList = mutableListOf() + var scrollState = ScrollState(0) + val webReaderParamsLiveData = MutableLiveData(null) val annotationLiveData = MutableLiveData(null) + val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID) fun loadItem(slug: String) { viewModelScope.launch { @@ -90,9 +99,110 @@ class WebReaderViewModel @Inject constructor( fun reset() { webReaderParamsLiveData.value = null annotationLiveData.value = null + scrollState = ScrollState(0) + javascriptDispatchQueue = mutableListOf() + } + + fun resetJavascriptDispatchQueue() { + lastJavascriptActionLoopUUID = javascriptActionLoopUUIDLiveData.value ?: UUID.randomUUID() + javascriptDispatchQueue = mutableListOf() + } + + fun saveAnnotation(annotation: String) { + val script = "var event = new Event('saveAnnotation');event.annotation = '$annotation';document.dispatchEvent(event);" + enqueueScript(script) + cancelAnnotationEdit() } fun cancelAnnotationEdit() { annotationLiveData.value = null } + + private fun enqueueScript(javascript: String) { + javascriptDispatchQueue.add(javascript) + javascriptActionLoopUUIDLiveData.value = UUID.randomUUID() + } + + fun storedWebPreferences(): WebPreferences = runBlocking { + val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize) + val storedLineHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight) + val storedMaxWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage) + + val storedFontFamily = datastoreRepo.getString(DatastoreKeys.preferredWebFontFamily) ?: WebFont.SYSTEM.rawValue + val storedWebFont = WebFont.values().first { it.rawValue == storedFontFamily } + + val prefersHighContrastFont = datastoreRepo.getString(DatastoreKeys.prefersWebHighContrastText) == "true" + + WebPreferences( + textFontSize = storedFontSize ?: 12, + lineHeight = storedLineHeight ?: 150, + maxWidthPercentage = storedMaxWidth ?: 100, + themeKey = "LightGray", // TODO: match system value + fontFamily = storedWebFont, + prefersHighContrastText = prefersHighContrastFont + ) + } + + fun updateFontSize(isIncrease: Boolean) { + val delta = if (isIncrease) 2 else -2 + var newFontSize: Int + + runBlocking { + val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize) + newFontSize = ((storedFontSize ?: 12) + delta).coerceIn(8, 28) + datastoreRepo.putInt(DatastoreKeys.preferredWebFontSize, newFontSize) + } + + // Get value from data store and then update it + val script = "var event = new Event('updateFontSize');event.fontSize = '$newFontSize';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun updateMaxWidthPercentage(isIncrease: Boolean) { + val delta = if (isIncrease) 10 else -10 + var newMaxWidthPercentageValue: Int + + runBlocking { + val storedWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage) + newMaxWidthPercentageValue = ((storedWidth ?: 100) + delta).coerceIn(40, 100) + datastoreRepo.putInt(DatastoreKeys.preferredWebMaxWidthPercentage, newMaxWidthPercentageValue) + } + + // Get value from data store and then update it + val script = "var event = new Event('updateMaxWidthPercentage');event.maxWidthPercentage = '$newMaxWidthPercentageValue';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun updateLineSpacing(isIncrease: Boolean) { + val delta = if (isIncrease) 25 else -25 + var newLineHeight: Int + + runBlocking { + val storedHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight) + newLineHeight = ((storedHeight ?: 150) + delta).coerceIn(100, 300) + datastoreRepo.putInt(DatastoreKeys.preferredWebLineHeight, newLineHeight) + } + + // Get value from data store and then update it + val script = "var event = new Event('updateLineHeight');event.lineHeight = '$newLineHeight';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun updateHighContrastTextPreference(prefersHighContrastText: Boolean) { + runBlocking { + datastoreRepo.putString(DatastoreKeys.prefersWebHighContrastText, prefersHighContrastText.toString()) + } + val fontContrastValue = if (prefersHighContrastText) "high" else "normal" + val script = "var event = new Event('handleFontContrastChange');event.fontContrast = '$fontContrastValue';document.dispatchEvent(event);" + enqueueScript(script) + } + + fun applyWebFont(font: WebFont) { + runBlocking { + datastoreRepo.putString(DatastoreKeys.preferredWebFontFamily, font.rawValue) + } + + val script = "var event = new Event('updateFontFamily');event.fontFamily = '${font.rawValue}';document.dispatchEvent(event);" + enqueueScript(script) + } } diff --git a/android/Omnivore/app/src/main/res/drawable-v24/minus.xml b/android/Omnivore/app/src/main/res/drawable-v24/minus.xml new file mode 100644 index 000000000..7ac604f6a --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable-v24/minus.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/Omnivore/app/src/main/res/drawable-v24/plus.xml b/android/Omnivore/app/src/main/res/drawable-v24/plus.xml new file mode 100644 index 000000000..a476f560f --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable-v24/plus.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apple/Omnivore.xcodeproj/project.pbxproj b/apple/Omnivore.xcodeproj/project.pbxproj index 9627432c5..56e6d8ece 100644 --- a/apple/Omnivore.xcodeproj/project.pbxproj +++ b/apple/Omnivore.xcodeproj/project.pbxproj @@ -1307,7 +1307,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1317,7 +1317,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; @@ -1339,7 +1339,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist; @@ -1349,7 +1349,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.ShareExtension-Mac"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1421,7 +1421,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1432,7 +1432,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; @@ -1455,7 +1455,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; @@ -1466,7 +1466,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1521,7 +1521,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1600,7 +1600,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1639,7 +1639,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1665,7 +1665,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1678,7 +1678,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( @@ -1704,7 +1704,7 @@ CODE_SIGN_ENTITLEMENTS = "Entitlements/SafariExtension-Mac.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 76; DEVELOPMENT_TEAM = QJF2XZ86HB; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1717,7 +1717,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; MTL_FAST_MATH = YES; OTHER_LDFLAGS = ( "-framework", @@ -1804,7 +1804,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1859,7 +1859,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app; PRODUCT_NAME = Omnivore; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1888,7 +1888,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.17.0; PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension"; PRODUCT_NAME = ShareExtension; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5090a25ab..0667008b4 100644 --- a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -153,6 +153,15 @@ "version" : "2.1.0" } }, + { + "identity" : "pspdfkit-sp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PSPDFKit/PSPDFKit-SP", + "state" : { + "branch" : "master", + "revision" : "e9757beadad1b30de84073d3c33c1cd0f7a94b80" + } + }, { "identity" : "sovran-swift", "kind" : "remoteSourceControl", diff --git a/apple/OmnivoreKit/Package.swift b/apple/OmnivoreKit/Package.swift index 58f0d3de0..54299201b 100644 --- a/apple/OmnivoreKit/Package.swift +++ b/apple/OmnivoreKit/Package.swift @@ -55,9 +55,8 @@ let package = Package( var appPackageDependencies: [Target.Dependency] { var deps: [Target.Dependency] = ["Views", "Services", "Models", "Utils"] -// #if canImport(UIKit) -// deps.append(.product(name: "PSPDFKit", package: "PSPDFKit-SP")) -// #endif + // Comment out following line for macOS build + deps.append(.product(name: "PSPDFKit", package: "PSPDFKit-SP")) return deps } @@ -69,8 +68,7 @@ var dependencies: [Package.Dependency] { .package(url: "git@github.com:segmentio/analytics-swift.git", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2") ] -// #if canImport(UIKit) -// deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", branch: "master")) -// #endif + // Comment out following line for macOS build + deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", branch: "master")) return deps } diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index be5320762..9fb741e57 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -22,7 +22,13 @@ public class ShareExtensionViewModel: ObservableObject { let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)") application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl) } + #else + if let workspace = NSWorkspace.value(forKeyPath: #keyPath(NSWorkspace.shared)) as? NSWorkspace { + let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(requestId)") + workspace.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl) + } #endif + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListView.swift b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListView.swift index b16a0c3bf..50de7f421 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Highlights/HighlightsListView.swift @@ -12,7 +12,35 @@ struct HighlightsListView: View { let itemObjectID: NSManagedObjectID @Binding var hasHighlightMutations: Bool + var emptyView: some View { + Text(""" + You have not added any highlights to this page. + """) + .multilineTextAlignment(.center) + .padding(16) + } + var innerBody: some View { + (viewModel.highlightItems.count > 0 ? AnyView(listView) : AnyView(emptyView)) + .navigationTitle("Highlights & Notes") + .listStyle(PlainListStyle()) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + dismissButton + } + } + #else + .toolbar { + ToolbarItemGroup { + dismissButton + } + } + #endif + } + + var listView: some View { List { Section { ForEach(viewModel.highlightItems) { highlightParams in @@ -38,22 +66,6 @@ struct HighlightsListView: View { } } } - .navigationTitle("Highlights") - .listStyle(PlainListStyle()) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - dismissButton - } - } - #else - .toolbar { - ToolbarItemGroup { - dismissButton - } - } - #endif } var dismissButton: some View { diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index e6a508038..17662712f 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -294,7 +294,7 @@ import Views .contextMenu { Button( action: { viewModel.itemForHighlightsView = item }, - label: { Label("View Highlights", systemImage: "highlighter") } + label: { Label("View Highlights & Notes", systemImage: "highlighter") } ) Button( action: { viewModel.itemUnderTitleEdit = item }, @@ -323,8 +323,10 @@ import Views itemToRemove = item confirmationShown = true }, - label: { Label("Delete", systemImage: "trash") } - ) + label: { + Label("Remove Item", systemImage: "trash") + } + ).tint(.red) if FeatureFlag.enableSnooze { Button { viewModel.itemToSnoozeID = item.id @@ -340,26 +342,23 @@ import Views } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !item.isArchived { - Button { + Button(action: { withAnimation(.linear(duration: 0.4)) { viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: true) } - } label: { + }, label: { Label("Archive", systemImage: "archivebox") - }.tint(.green) + }).tint(.green) } else { - Button { + Button(action: { withAnimation(.linear(duration: 0.4)) { viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: false) } - } label: { + }, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") - }.tint(.indigo) + }).tint(.indigo) } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button( - role: .destructive, action: { itemToRemove = item confirmationShown = true @@ -367,17 +366,7 @@ import Views label: { Image(systemName: "trash") } - ) - }.alert("Are you sure?", isPresented: $confirmationShown) { - Button("Remove Link", role: .destructive) { - if let itemToRemove = itemToRemove { - withAnimation { - viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) - } - } - self.itemToRemove = nil - } - Button("Cancel", role: .cancel) { self.itemToRemove = nil } + ).tint(.red) } .swipeActions(edge: .leading, allowsFullSwipe: true) { if FeatureFlag.enableSnooze { @@ -393,6 +382,18 @@ import Views } .padding(.top, 0) .listStyle(PlainListStyle()) + .alert("Are you sure you want to remove this item? All associated notes and highlights will be deleted.", + isPresented: $confirmationShown) { + Button("Remove Item") { + if let itemToRemove = itemToRemove { + withAnimation { + viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) + } + } + self.itemToRemove = nil + } + Button("Cancel", role: .cancel) { self.itemToRemove = nil } + } } } } @@ -448,17 +449,6 @@ import Views isContextMenuOpen: $isContextMenuOpen, viewModel: viewModel ) - .alert("Are you sure?", isPresented: $confirmationShown) { - Button("Remove Link", role: .destructive) { - if let itemToRemove = itemToRemove { - withAnimation { - viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) - } - } - self.itemToRemove = nil - } - Button("Cancel", role: .cancel) { self.itemToRemove = nil } - } } } .padding() @@ -483,6 +473,17 @@ import Views } } } + .alert("Are you sure you want to remove this item? All associated notes and highlights will be deleted.", isPresented: $confirmationShown) { + Button("Remove Item", role: .destructive) { + if let itemToRemove = itemToRemove { + withAnimation { + viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID) + } + } + self.itemToRemove = nil + } + Button("Cancel", role: .cancel) { self.itemToRemove = nil } + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift index 69d11b7a8..9fa414f79 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Profile/Subscriptions.swift @@ -123,7 +123,7 @@ struct SubscriptionCell: View { let subscription: Subscription var body: some View { - VStack { + HStack { VStack(alignment: .leading, spacing: 6) { Text(subscription.name) .font(.appCallout) @@ -140,7 +140,28 @@ struct SubscriptionCell: View { } .multilineTextAlignment(.leading) .padding(.vertical, 8) - .frame(minHeight: 50) + + Spacer() + + Group { + if let icon = subscription.icon, let imageURL = URL(string: icon) { + AsyncImage(url: imageURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 40, height: 40) + .cornerRadius(6) + } else if phase.error != nil { + EmptyView().frame(width: 40, height: 40, alignment: .top) + } else { + Color.appButtonBackground + .frame(width: 40, height: 40) + .cornerRadius(2) + } + } + } + }.frame(minHeight: 50) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 00891c3d0..31e410553 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -152,7 +152,7 @@ struct WebReaderContainerView: View { Group { Button( action: { showHighlightsView = true }, - label: { Label("View Highlights", systemImage: "highlighter") } + label: { Label("View Highlights & Notes", systemImage: "highlighter") } ) Button( action: { showTitleEdit = true }, diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift index 10d6208a9..6179318df 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/Subscription.swift @@ -11,6 +11,7 @@ public struct Subscription { public let unsubscribeMailTo: String? public let updatedAt: Date? public let url: String? + public let icon: String? public init( createdAt: Date?, @@ -22,7 +23,8 @@ public struct Subscription { unsubscribeHttpUrl: String?, unsubscribeMailTo: String?, updatedAt: Date?, - url: String? + url: String?, + icon: String? ) { self.createdAt = createdAt self.description = description @@ -34,6 +36,7 @@ public struct Subscription { self.unsubscribeMailTo = unsubscribeMailTo self.updatedAt = updatedAt self.url = url + self.icon = icon } } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift index 2c2976126..a78045904 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/SubscriptionsQuery.swift @@ -20,7 +20,8 @@ public extension DataService { unsubscribeHttpUrl: try $0.unsubscribeHttpUrl(), unsubscribeMailTo: try $0.unsubscribeMailTo(), updatedAt: try $0.updatedAt().value, - url: try $0.url() + url: try $0.url(), + icon: try $0.icon() ) } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index 99223de4d..57e7e86c1 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -48,7 +48,7 @@ public struct GridCard: View { Group { Button( action: { menuActionHandler(.viewHighlights) }, - label: { Label("View Highlights", systemImage: "highlighter") } + label: { Label("View Highlights & Notes", systemImage: "highlighter") } ) Button( action: { menuActionHandler(.editTitle) }, diff --git a/packages/api/package.json b/packages/api/package.json index 70fa43d9e..ffd240c8f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -3,12 +3,13 @@ "version": "1.0.0", "license": "UNLICENSED", "scripts": { - "build": "tsc", + "build": "tsc && yarn copy-files", "dev": "ts-node-dev --files src/server.ts", "start": "node dist/server.js", "lint": "eslint src --ext ts,js,tsx,jsx", "lint:fix": "eslint src --fix --ext ts,js,tsx,jsx", - "test": "nyc mocha -r ts-node/register --config mocha-config.json --timeout 10000" + "test": "nyc mocha -r ts-node/register --config mocha-config.json --timeout 10000", + "copy-files": "copyfiles -u 1 src/**/*.html dist/" }, "dependencies": { "@elastic/elasticsearch": "~7.12.0", @@ -126,6 +127,7 @@ "chai-as-promised": "^7.1.1", "chai-string": "^1.5.0", "circular-dependency-plugin": "^5.2.0", + "copyfiles": "^2.4.1", "mocha": "^9.0.1", "mocha-unfunk-reporter": "^0.4.0", "nock": "^13.2.4", diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 1414ca56e..9d02d9d26 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -54,6 +54,7 @@ import { ContentParseError } from '../../utils/errors' import { authorized, generateSlug, + isBase64Image, isParsingTimeout, pageError, stringToHash, @@ -436,15 +437,21 @@ export const getArticleResolver: ResolverFn< // We allow the backend to use the ID instead of a slug to fetch the article const page = (await getPageByParam( - { userId: claims.uid, slug }, + { + userId: claims.uid, + slug, + }, includeOriginalHtml )) || (await getPageByParam( - { userId: claims.uid, _id: slug }, + { + userId: claims.uid, + _id: slug, + }, includeOriginalHtml )) - if (!page) { + if (!page || page.state === ArticleSavingRequestStatus.Deleted) { return { errorCodes: [ArticleErrorCode.NotFound] } } @@ -642,10 +649,16 @@ export const setBookmarkArticleResolver = authorized< return { errorCodes: [SetBookmarkArticleErrorCode.NotFound] } } - // delete the page + // delete the page and its metadata const deleted = await updatePage( pageRemoved.id, - { state: ArticleSavingRequestStatus.Deleted }, + { + state: ArticleSavingRequestStatus.Deleted, + labels: [], + highlights: [], + readingProgressAnchorIndex: 0, + readingProgressPercent: 0, + }, { pubsub, uid } ) if (!deleted) { @@ -889,6 +902,10 @@ export const searchResolver = authorized< } const edges = results.map((r) => { + let siteIcon = r.siteIcon + if (siteIcon && !isBase64Image(siteIcon)) { + siteIcon = createImageProxyUrl(siteIcon, 128, 128) + } return { node: { ...r, @@ -900,7 +917,7 @@ export const searchResolver = authorized< publishedAt: validatedDate(r.publishedAt), ownedByViewer: r.userId === claims.uid, pageType: r.pageType || PageType.Highlights, - siteIcon: r.siteIcon && createImageProxyUrl(r.siteIcon, 32, 32), + siteIcon, } as SearchItem, cursor: endCursor, } diff --git a/packages/api/src/resolvers/subscriptions/index.ts b/packages/api/src/resolvers/subscriptions/index.ts index eb416b868..66ef3b2f4 100644 --- a/packages/api/src/resolvers/subscriptions/index.ts +++ b/packages/api/src/resolvers/subscriptions/index.ts @@ -60,7 +60,7 @@ export const subscriptionsResolver = authorized< return { subscriptions: subscriptions.map((s) => ({ ...s, - icon: s.icon && createImageProxyUrl(s.icon, 32, 32), + icon: s.icon && createImageProxyUrl(s.icon, 128, 128), })), } } catch (error) { diff --git a/packages/api/src/routers/auth/mobile/sign_up.ts b/packages/api/src/routers/auth/mobile/sign_up.ts index fea03ef97..9bfc36c78 100644 --- a/packages/api/src/routers/auth/mobile/sign_up.ts +++ b/packages/api/src/routers/auth/mobile/sign_up.ts @@ -72,6 +72,7 @@ export async function createMobileEmailSignUpResponse( json: {}, } } catch (e) { + console.log('error', e) return signUpFailedPayload } } diff --git a/packages/api/src/services/popular_reads.ts b/packages/api/src/services/popular_reads.ts index db2933c3d..499dd019f 100644 --- a/packages/api/src/services/popular_reads.ts +++ b/packages/api/src/services/popular_reads.ts @@ -32,22 +32,27 @@ const popularRead = (key: string): PopularRead | undefined => { return undefined } - const content = readFileSync( - path.resolve(__dirname, `popular_reads/${key}-content.html`), - 'utf8' - ) - const originalHtml = readFileSync( - path.resolve(__dirname, `./popular_reads/${key}-original.html`), - 'utf8' - ) - if (!content || !originalHtml) { - return undefined - } + try { + const content = readFileSync( + path.resolve(__dirname, `popular_reads/${key}-content.html`), + 'utf8' + ) + const originalHtml = readFileSync( + path.resolve(__dirname, `./popular_reads/${key}-original.html`), + 'utf8' + ) + if (!content || !originalHtml) { + return undefined + } - return { - ...metadata, - content, - originalHtml, + return { + ...metadata, + content, + originalHtml, + } + } catch (e) { + console.log('error adding popular read', e) + return undefined } } @@ -116,11 +121,7 @@ const addPopularReads = async ( export const addPopularReadsForNewUser = async ( userId: string ): Promise => { - const defaultReads = [ - 'omnivore_get_started', - 'power_read_it_later', - 'omnivore_organize', - ] + const defaultReads = ['omnivore_organize', 'power_read_it_later'] // get client from request context const client = httpContext.get('client') as string | undefined @@ -137,6 +138,9 @@ export const addPopularReadsForNewUser = async ( break } + // We always want this to be the top-most article in the user's + // list. So we save it last to have the greatest saved_at + defaultReads.push('omnivore_get_started') await addPopularReads(userId, defaultReads) } @@ -152,6 +156,17 @@ const popularReads = [ publishedAt: new Date('2021-10-13'), siteName: 'Omnivore Blog', }, + { + key: 'omnivore_ios', + url: 'https://blog.omnivore.app/p/saving-links-from-your-iphone-or', + title: 'Saving Links from Your iPhone or iPad', + author: 'Omnivore', + description: 'Learn how to save articles on iOS.', + previewImage: + 'https://proxy-prod.omnivore-image-cache.app/260x260,suM2fz_-6_1PDsQDursGPD2bQqnpgGH9Ymj-IVb5dUR4/https://substackcdn.com/image/youtube/w_728,c_limit/k6RkIqepAig', + publishedAt: new Date('2021-10-19'), + siteName: 'Omnivore Blog', + }, { key: 'omnivore_organize', url: 'https://blog.omnivore.app/p/organize-your-omnivore-library-with', diff --git a/packages/api/src/services/popular_reads/omnivore_get_started-content.html b/packages/api/src/services/popular_reads/omnivore_get_started-content.html index 1e214dd7e..843076a29 100644 --- a/packages/api/src/services/popular_reads/omnivore_get_started-content.html +++ b/packages/api/src/services/popular_reads/omnivore_get_started-content.html @@ -1,103 +1,330 @@
-
-

Omnivore is a home for everything you read. We keep it safe, organized, and easy to share.

-

When you start using Omnivore, it is important to figure out the best way to save content to your library.

-

- Saving from your iPhone -

-

If you are using an iPhone or iPad, the best way to save links is by installing the iOS app. You can find the iOS app here:

-

- https://omnivore.app/install/ios -

-

With the iOS Share extension installed, you can save links from Safari or any other app that supports sharing links.

+
-
- - Saving with the Omnivore iOS Share Extension - -
Saving with the Omnivore iOS Share Extension
-
+

Omnivore is a read-it-later app that lets you save and organize everything you read online.

-

When you first install the app, Omnivore might not show up in your list of share targets. To make sure Omnivore is easy to access, scroll to the far right, click More, and add Omnivore.

-

Saving from your Android Device

-

If you are using an Android device you can install the Omnivore Progressive Web App. After logging in to Omnivore in Chrome you should see an “Install Omnivore” option. Most Android versions display this at the bottom of the screen.

-
-
- - - -
+
+

This guide will show you how to use Omnivore’s basic functions and advanced features, divided into four main activities:

+
    +
  • +

    Saving

    +
  • +
  • +

    Reading

    +
  • +
  • +

    Organizing

    +
  • +
  • +

    Integrations

    +
  • +
+

+ The Library is the center of your Omnivore experience, where you can quickly access any links you have saved. Saved links remain in your Library forever unless you delete them. +

+

There are five ways to save links to pages or articles that you wish to read later:

+
    +
  • +

    Saving from Your Omnivore Library

    +
  • +
  • +

    Saving from a Browser 

    +
  • +
  • +

    Saving from a Phone or Tablet (iOS or Android)

    +
  • +
  • +

    Newsletter Subscriptions via Email

    +
  • +
  • +

    + Saving PDFs from a Mac
    +

    +
  • +
+

Saving from Your Omnivore Library

+

+ 1. In the upper right corner of your Library, tap the Add Link button.
+ 2. Enter the URL you wish to save and tap Add Link.
+ 3. The link will appear in your Library the next time you refresh it.
+

+

Saving from a Browser

+

1. Download and install the Omnivore extension for your browser:

+ +

+ 2. Navigate to the page you wish to save and tap the Omnivore button in your browser’s toolbar or Extensions menu.
+ 3. Alternatively, you can right-click (command+click on Mac) on any hyperlink and select Save to Omnivore from the menu.
+ 4. The link will appear in your Library the next time you refresh it.
+

+

Saving from a Phone or Tablet

+

The best way to save links from your mobile device is via the Omnivore app. You can download the app here:

+ +

Once the mobile app is installed:

+
    +
  1. +

    + In your browser, navigate to the page you wish to save and tap the Share button. +

    +
  2. +
  3. +

    + Tap the Omnivore icon in the Share menu. +

    +
  4. +
  5. +

    The link will appear in your Library the next time you refresh it.

    +
  6. +
+

Newsletter Subscriptions via Email

+

+ 1. On the Omnivore website or app, tap your photo, initial, or avatar in the top right corner to access the profile menu. Select Emails from the menu. +

+

+ 2. Tap Create a New Email Address to add a new email address (ex: username-123abc@inbox.omnivore.app) to the list. +

+

3. Click the Copy icon next to the email address.

+

+ 4. Navigate to the signup page for the newsletter you wish to subscribe to.
+ 5. Paste the Omnivore email address into the signup form. +

+

6. New newsletters will be automatically delivered to your Omnivore inbox.

+

Saving PDFs from a Mac 

+
    +
  1. +

    + Install the Mac App +

    +
  2. +
  3. +

    On your Mac, locate the PDF you wish to save and right-click or ctrl+click on the file name.

    +
  4. +
  5. +

    + Select Share from the menu and choose Omnivore. +

    +
  6. +
  7. +

    The link will appear in your Library the next time you refresh it.

    +
  8. +
+

Reading

+

Click any link saved in your Library to enter the Reader view. 

+

Omnivore formats pages for easy reading and highlighting, removing ads and clutter for distraction-free reading. The text-focused view also makes articles smaller and quicker to load.

+

While reading, you can:

+
    +
  • +

    Change Formatting

    +
  • +
  • +

    Highlight Text

    +
  • +
  • +

    Add Notes

    +
  • +
  • +

    View All Saved Highlights and Notes

    +
  • +
  • +

    Track Reading Progress

    +
  • +
+

Change Formatting 

+
    +
  1. +

    + Theme: Tap your photo, initial, or avatar  in the top right corner to access the profile menu. Select the white or black thumbnail to choose the Light or Dark theme. +

    +
  2. +
  3. +

    + Text Formatting: Tap the Aa icon to adjust the text size, font, margins, and line spacing. +

    +
  4. +
+

Highlight Text

+
    +
  1. +

    Select the text you wish to highlight.

    +
  2. +
  3. +

    + Tap the Highlight button. +

    +
  4. +
  5. +

    The text will appear highlighted next time you view the article.

    +
  6. +
+

Add Notes

+
    +
  1. +

    Highlight a section of text where you wish to add a note.

    +
  2. +
  3. +

    + Tap the Note button, type your note, and tap Save. +

    +
  4. +
  5. +

    The Note icon will appear next time you view this article.

    +
  6. +
+

View All Saved Highlights and Notes

+
    +
  1. +

    Tap the Highlight/Note icon to see a list of all the highlighted text and notes you have added to this page.

    +
  2. +
  3. +

    To remove a note or highlight, select it from the list and tap the Trash icon.

    +
  4. +
+

Track Reading Progress

+

Omnivore automatically keeps track of your reading progress across your different devices so you can easily pick up where you left off. A progress bar will appear at the top of each link in your Library after you have started reading.

+

Organizing

+

By default, the Library inbox displays all links you have saved. To manage your list and keep your reading organized, Omnivore provides the following actions: 

+
    +
  • +

    Archiving

    +
  • +
  • +

    Labels

    +
  • +
  • +

    Search

    +
  • +
  • +

    Filters

    +
  • +
+

Archiving

+
    +
  1. +

    Tap the Menu icon next to the link you wish to archive (on the mobile app, long press the link to open the menu).

    +
  2. +
  3. +

    + Select Archive. +

    +
  4. +
  5. +

    The link will disappear from the default Library view, but will show up if you select the Archived filter (see Filters below).

    +
  6. +
+

+ Labels +

+
    +
  1. +

    + Tap the Menu icon next to any link and select Set Labels. +

    +
  2. +
  3. +

    + Select an existing label from the list or tap Edit Labels to create a new one. +

    +
  4. +
  5. +

    The label will appear next to the link in your Library. Tap it to view all links with the same label.

    +
  6. +
  7. +

    + Omnivore mobile app only: tap Labels to see a complete list of all labels you have used; tap one to view all links with the same label +

    +
  8. +
  9. +

    Note: Omnivore will automatically assign some labels, such as “Newsletters.”

    +
  10. +
+

Search

+
    +
  1. +

    To search through all your saved links, enter a keyword or phrase in the search bar. 

    +
  2. +
  3. +

    + You can combine keywords with labels and filters to focus your search even further. Learn more about advanced search. +

    +
  4. +
+

Filters

+
    +
  1. +

    + Use the Filters menu to refine your Library view (some filters may be visible by default). +

    +
  2. +
  3. +

    + Select Read Later to view a list of all your non-archived links except Newsletters. +

    +
  4. +
  5. +

    + Select Highlights to view the text selections you have highlighted in all your saved pages.  +

    +
  6. +
  7. +

    + Select Today to view a list of links you saved today. +

    +
  8. +
  9. +

    + Select Newsletters to view links saved via your newsletter subscriptions. +

    +
  10. +
+

Integrations

+

Omnivore allows integrations with knowledge bases and note-taking apps including:

+
    +
  • +

    Logseq

    +
  • +
  • +

    Webhooks

    +
  • +
+

Logseq

+

+ With Omnivore's Logseq plugin you can sync all your saved articles, highlights, and notes into Logseq, a popular knowledge base. For information on setting up and using the Logseq plugin, please refer to this helpful Omnivore for Logseq Plugin Guide. +

+

Webhooks

+

+ Omnivore can trigger webhooks when you save a link or add highlights to a page you are reading. This example shows webhooks being used to write all saved links to a Google Sheets spreadsheet stored on a Google Drive. +

-

After installing Omnivore as a Progressive Web App it will be displayed in your Sharing Menu on Chrome.

-
-
- - - -
-
-

- Saving from your computer -

-

If you are saving from a computer, you will need to install the Omnivore extension for the web browser(s) you use.

-

The browser extensions are available here:

- -

With the browser extension(s) of your choice installed, you can tap the Omnivore button on any page to save your link.

-
-
- - Saving with the Omnivore Browser Extension - -
Saving with the Omnivore Browser Extension
-
-
-

- Saving PDFs with the Mac App -

-

https://omnivore.app/install/mac

-

With the MacOS App installed you can upload PDFs from your computer to your Omnivore library by right-clicking and sharing to Omnivore.

-
-
- - Sharing a PDF with Omnivore - -
Sharing a PDF with Omnivore
-
-
-

You can enable sharing from Finder on the Mac in the Extensions section of System Preferences.

-
-
- - - -
-
-

Using Omnivore

-

We created a quick tour of Omnivore to get your started with all the other features. This video demonstrates search, archiving, and keyboard commands in the library and sharing highlights from the reader.

-
-
- - - Screenshot of video demonstrating Omnivore - - -
-
-
+
\ No newline at end of file diff --git a/packages/api/src/services/popular_reads/omnivore_get_started-original.html b/packages/api/src/services/popular_reads/omnivore_get_started-original.html index 8270ed1ad..c0175ab57 100644 --- a/packages/api/src/services/popular_reads/omnivore_get_started-original.html +++ b/packages/api/src/services/popular_reads/omnivore_get_started-original.html @@ -5,8 +5,8 @@ - - + + @@ -176,19 +176,22 @@ - + + - - - + + + + - - + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + +
+
+
+ +
+
+
+
+
+
+ Comments +
+
+
+
+
+ +
+
+ +
+
+
+
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+ Learn the best ways to save links with Omnivore +
+
+
+
+ Omnivore +
+
+ +
+ + + + +
+ 2 +
+ + + + + +
+
+
+
+
+ +
+
+ Highlighted <code> in Omnivore +
+
+
+
+ Omnivore +
+
+ +
+ + + + +
+ 1 +
+ + + + + +
+
+
+
+
+ +
+
+ Add to your library with your Omnivore email address +
+
+
+
+ Omnivore +
+
+ +
+ + + + +
+ 1 +
+ + + + + +
+
+
See all + + + + +
+
+
+
+
+ +
+ +
+ +
+
+
+ + + + + + + diff --git a/packages/api/src/services/save_newsletter_email.ts b/packages/api/src/services/save_newsletter_email.ts index 84daf9a40..0f505c53b 100644 --- a/packages/api/src/services/save_newsletter_email.ts +++ b/packages/api/src/services/save_newsletter_email.ts @@ -14,6 +14,7 @@ import { saveSubscription } from './subscriptions' import { NewsletterEmail } from '../entity/newsletter_email' import { fetchFavicon } from '../utils/parser' import { updatePage } from '../elastic/pages' +import { isBase64Image } from '../utils/helpers' interface NewsletterMessage { email: string @@ -69,8 +70,8 @@ export const saveNewsletterEmail = async ( return false } - if (!page.siteIcon) { - // fetch favicon if not already set + if (!page.siteIcon || isBase64Image(page.siteIcon)) { + // fetch favicon if not already set or is a base64 image const favicon = await fetchFavicon(page.url) if (favicon) { page.siteIcon = favicon diff --git a/packages/api/src/utils/helpers.ts b/packages/api/src/utils/helpers.ts index c85903660..a3137fbfb 100644 --- a/packages/api/src/utils/helpers.ts +++ b/packages/api/src/utils/helpers.ts @@ -269,3 +269,7 @@ export const wordsCount = (text: string, isHtml = true): number => { return 0 } } + +export const isBase64Image = (str: string): boolean => { + return str.startsWith('data:image/') +} diff --git a/packages/api/src/utils/parser.ts b/packages/api/src/utils/parser.ts index eee86b417..291fa5d4f 100644 --- a/packages/api/src/utils/parser.ts +++ b/packages/api/src/utils/parser.ts @@ -442,7 +442,7 @@ export const fetchFavicon = async ( const response = await axios.head(url, { timeout: 5000 }) const realUrl = response.request.res.responseUrl const domain = new URL(realUrl).hostname - return `https://api.faviconkit.com/${domain}/32` + return `https://api.faviconkit.com/${domain}/128` } catch (e) { console.log('Error fetching favicon', e) return undefined diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index 54fe8dc02..440b8107b 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -688,6 +688,7 @@ describe('Article API', () => { await graphqlRequest(query, authToken).expect(200) const page = await getPageById(articleId) expect(page?.state).to.eql(ArticleSavingRequestStatus.Deleted) + expect(page?.highlights).to.eql([]) }) }) }) diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index 09ee056ae..ed8560ddd 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -626,8 +626,7 @@ describe('auth router', () => { 0, ] - // TODO: update this when we have more iOS popular reads - expect(count).to.eql(3) + expect(count).to.eql(4) }) }) }) diff --git a/packages/content-handler/src/content-handler.ts b/packages/content-handler/src/content-handler.ts index 317f3d2d9..fd1fe5291 100644 --- a/packages/content-handler/src/content-handler.ts +++ b/packages/content-handler/src/content-handler.ts @@ -78,7 +78,8 @@ export abstract class ContentHandler { postHeader: string from: string unSubHeader: string - html?: string + html: string + dom: Document }): Promise { const re = new RegExp(this.senderRegex) return Promise.resolve( diff --git a/packages/content-handler/src/index.ts b/packages/content-handler/src/index.ts index 5a144b2ee..052642a50 100644 --- a/packages/content-handler/src/index.ts +++ b/packages/content-handler/src/index.ts @@ -24,6 +24,9 @@ import { BloombergNewsletterHandler } from './newsletters/bloomberg-newsletter-h import { BeehiivHandler } from './newsletters/beehiiv-handler' import { ConvertkitHandler } from './newsletters/convertkit-handler' import { RevueHandler } from './newsletters/revue-handler' +import { GhostHandler } from './newsletters/ghost-handler' +import { parseHTML } from 'linkedom' +import { CooperPressHandler } from './newsletters/cooper-press-handler' const validateUrlString = (url: string) => { const u = new URL(url) @@ -70,6 +73,8 @@ const newsletterHandlers: ContentHandler[] = [ new BeehiivHandler(), new ConvertkitHandler(), new RevueHandler(), + new GhostHandler(), + new CooperPressHandler(), ] export const preHandleContent = async ( @@ -119,13 +124,29 @@ export const preParseContent = async ( return undefined } +export const getNewsletterHandler = async (input: { + postHeader: string + from: string + unSubHeader: string + html: string +}): Promise => { + const dom = parseHTML(input.html).document + for (const handler of newsletterHandlers) { + if (await handler.isNewsletter({ ...input, dom })) { + return handler + } + } + + return undefined +} + export const handleNewsletter = async ( input: NewsletterInput ): Promise => { - for (const handler of newsletterHandlers) { - if (await handler.isNewsletter(input)) { - return handler.handleNewsletter(input) - } + const handler = await getNewsletterHandler(input) + if (handler) { + console.log('handleNewsletter', handler.name, input.title) + return handler.handleNewsletter(input) } return undefined @@ -135,4 +156,5 @@ module.exports = { preHandleContent, handleNewsletter, preParseContent, + getNewsletterHandler, } diff --git a/packages/content-handler/src/newsletters/beehiiv-handler.ts b/packages/content-handler/src/newsletters/beehiiv-handler.ts index e0cf5c687..332e7a050 100644 --- a/packages/content-handler/src/newsletters/beehiiv-handler.ts +++ b/packages/content-handler/src/newsletters/beehiiv-handler.ts @@ -1,5 +1,4 @@ import { ContentHandler } from '../content-handler' -import { parseHTML } from 'linkedom' export class BeehiivHandler extends ContentHandler { constructor() { @@ -22,9 +21,9 @@ export class BeehiivHandler extends ContentHandler { postHeader: string from: string unSubHeader: string - html: string + dom: Document }): Promise { - const dom = parseHTML(input.html).document + const dom = input.dom if (dom.querySelectorAll('img[src*="beehiiv.net"]').length > 0) { const beehiivUrl = this.findNewsletterHeaderHref(dom) if (beehiivUrl) { diff --git a/packages/content-handler/src/newsletters/convertkit-handler.ts b/packages/content-handler/src/newsletters/convertkit-handler.ts index 72e65f5da..15aebae26 100644 --- a/packages/content-handler/src/newsletters/convertkit-handler.ts +++ b/packages/content-handler/src/newsletters/convertkit-handler.ts @@ -1,5 +1,4 @@ import { ContentHandler } from '../content-handler' -import { parseHTML } from 'linkedom' export class ConvertkitHandler extends ContentHandler { constructor() { @@ -8,10 +7,13 @@ export class ConvertkitHandler extends ContentHandler { } findNewsletterHeaderHref(dom: Document): string | undefined { - const readOnline = dom.querySelectorAll('table tr td a') + const readOnline = dom.querySelectorAll('a') let res: string | undefined = undefined readOnline.forEach((e) => { - if (e.textContent === 'View this email in your browser') { + if ( + e.textContent === 'View this email in your browser' || + e.textContent === 'Read on FS' + ) { res = e.getAttribute('href') || undefined } }) @@ -22,12 +24,12 @@ export class ConvertkitHandler extends ContentHandler { postHeader: string from: string unSubHeader: string - html: string + dom: Document }): Promise { - const dom = parseHTML(input.html).document + const dom = input.dom return Promise.resolve( dom.querySelectorAll( - 'img[src*="convertkit.com"], img[src*="convertkit-mail.com"]' + 'img[src*="convertkit.com"], img[src*="convertkit-mail"]' ).length > 0 ) } diff --git a/packages/content-handler/src/newsletters/cooper-press-handler.ts b/packages/content-handler/src/newsletters/cooper-press-handler.ts new file mode 100644 index 000000000..d0f39f4c2 --- /dev/null +++ b/packages/content-handler/src/newsletters/cooper-press-handler.ts @@ -0,0 +1,38 @@ +import { ContentHandler } from '../content-handler' + +export class CooperPressHandler extends ContentHandler { + constructor() { + super() + this.name = 'cooper-press' + } + + findNewsletterHeaderHref(dom: Document): string | undefined { + const readOnline = dom.querySelectorAll('a') + let res: string | undefined = undefined + readOnline.forEach((e) => { + if (e.textContent === 'Read on the Web') { + res = e.getAttribute('href') || undefined + } + }) + return res + } + + async isNewsletter(input: { + postHeader: string + from: string + unSubHeader: string + dom: Document + }): Promise { + const dom = input.dom + return Promise.resolve( + dom.querySelectorAll('a[href*="cooperpress.com"]').length > 0 + ) + } + + async parseNewsletterUrl( + postHeader: string, + html: string + ): Promise { + return this.findNewsletterUrl(html) + } +} diff --git a/packages/content-handler/src/newsletters/ghost-handler.ts b/packages/content-handler/src/newsletters/ghost-handler.ts new file mode 100644 index 000000000..ca3e35a85 --- /dev/null +++ b/packages/content-handler/src/newsletters/ghost-handler.ts @@ -0,0 +1,32 @@ +import { ContentHandler } from '../content-handler' + +export class GhostHandler extends ContentHandler { + constructor() { + super() + this.name = 'ghost' + } + + findNewsletterHeaderHref(dom: Document): string | undefined { + const readOnline = dom.querySelector('.view-online-link') + return readOnline?.getAttribute('href') || undefined + } + + async isNewsletter(input: { + postHeader: string + from: string + unSubHeader: string + dom: Document + }): Promise { + const dom = input.dom + return Promise.resolve( + dom.querySelectorAll('img[src*="ghost.org"]').length > 0 + ) + } + + async parseNewsletterUrl( + postHeader: string, + html: string + ): Promise { + return this.findNewsletterUrl(html) + } +} diff --git a/packages/content-handler/src/newsletters/revue-handler.ts b/packages/content-handler/src/newsletters/revue-handler.ts index d8c8f911c..cd22d314c 100644 --- a/packages/content-handler/src/newsletters/revue-handler.ts +++ b/packages/content-handler/src/newsletters/revue-handler.ts @@ -1,5 +1,4 @@ import { ContentHandler } from '../content-handler' -import { parseHTML } from 'linkedom' export class RevueHandler extends ContentHandler { constructor() { @@ -22,9 +21,9 @@ export class RevueHandler extends ContentHandler { postHeader: string from: string unSubHeader: string - html: string + dom: Document }): Promise { - const dom = parseHTML(input.html).document + const dom = input.dom if ( dom.querySelectorAll('img[src*="getrevue.co"], img[src*="revue.email"]') .length > 0 diff --git a/packages/content-handler/src/newsletters/substack-handler.ts b/packages/content-handler/src/newsletters/substack-handler.ts index e90168c8e..e82d6d1cf 100644 --- a/packages/content-handler/src/newsletters/substack-handler.ts +++ b/packages/content-handler/src/newsletters/substack-handler.ts @@ -1,6 +1,5 @@ import addressparser from 'addressparser' import { ContentHandler } from '../content-handler' -import { parseHTML } from 'linkedom' export class SubstackHandler extends ContentHandler { constructor() { @@ -50,17 +49,16 @@ export class SubstackHandler extends ContentHandler { async isNewsletter({ postHeader, - html, + dom, }: { postHeader: string from: string unSubHeader: string - html: string + dom: Document }): Promise { if (postHeader) { return Promise.resolve(true) } - const dom = parseHTML(html).document // substack newsletter emails have tables with a *post-meta class if (dom.querySelector('table[class$="post-meta"]')) { return true diff --git a/packages/content-handler/test/apple-news-handler.test.ts b/packages/content-handler/test/apple-news-handler.test.ts index 1584f9e28..058ece4f4 100644 --- a/packages/content-handler/test/apple-news-handler.test.ts +++ b/packages/content-handler/test/apple-news-handler.test.ts @@ -1,6 +1,11 @@ import { AppleNewsHandler } from '../src/websites/apple-news-handler' +import nock from 'nock' describe('open a simple web page', () => { + before(() => { + nock('https://apple.news').get('/AxjzaZaPvSn23b67LhXI5EQ').reply(200, '') + }) + it('should return a response', async () => { const response = await new AppleNewsHandler().preHandle( 'https://apple.news/AxjzaZaPvSn23b67LhXI5EQ' diff --git a/packages/content-handler/test/data/convertkit-newsletter.html b/packages/content-handler/test/data/convertkit-newsletter.html new file mode 100644 index 000000000..ab2131adb --- /dev/null +++ b/packages/content-handler/test/data/convertkit-newsletter.html @@ -0,0 +1,1292 @@ + + + + + + +
+ + There are a lot of things in life that only work when you commit. + + + +  ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ + +
+ +
+

+ FS | BRAIN FOOD +

+
+ + + +
+

+ No. 494 — October 16th, 2022 — Read on FS +

+

+ Brain Food is a weekly newsletter with the insights you need.
+

+

+ FS +

+

+ One of the biggest things that hold people back they're not aware + of: +

+

+ When we want to improve our value to an organization, we gravitate + toward the skills we need to develop, working extra hours or increasing + our responsibilities. We miss the secret hiding in plain sight: reducing + friction. +

+

+ — + Source +

+

+ TKP +

+

+ Marshall Goldsmith on self-limiting beliefs: +

+

+ "I think we have a lot of self-limiting beliefs. And the self-limiting + beliefs, a lot of these come from inside us. Basically, I can’t do this. + I can’t do that. This is just the way I am. One of the most common + problems is, this is just the way I am as if we have some “real” fixed + identity that lives throughout time. And I have to really work on people + to change that. Even smart people say things like this, “I can’t listen. + I can’t listen. I’ve never been able to listen.” I’ll look in their + ears. “Why not? You got something stuck in there? Why can’t you listen? + Do you have an incurable genetic defect that is prohibiting you from + listening?” As long as we tell ourselves, “That’s the way I am.” Two + things happen, both bad. One, we inhibit the odds of ever getting + better. Two, even if we do change our behavior we don’t seem authentic + to ourselves. We feel like a phony because if the real me can’t listen + and you say, “I’m a good listener. You know what I’m thinking?” Well, + that’s not the real me. I’m just pretending to be a good listener + because the real me is no good at that.” +

+

+ — + Listen and Learn + or + read the transcript. +

+

+ Insight +

+

+ Jeff Bezos on wandering as a counter-balance to efficiency: +

+

+ "Sometimes (often actually) in business, you do know where you’re going, + and when you do, you can be efficient. Put in place a plan and execute. + In contrast, wandering in business is not efficient … but it’s also not + random. It’s guided – by hunch, gut, intuition, curiosity, and powered + by a deep conviction that the prize for customers is big enough that + it’s worth being a little messy and tangential to find our way there. + Wandering is an essential counterbalance to efficiency. You need to + employ both. The outsized discoveries – the “non-linear” ones – are + highly likely to require wandering." +

+

+ — + Source +

+

+ Tiny Thought +

+

+ There are a lot of things in life that only work when you commit. +

+

+ I don’t mean dabble. I don’t mean half-in. I mean commit. +

+

+ Commitment means all in, all the time. +

+

+ It’s easy to trick yourself into thinking that if you put in half the + effort, you can get 80 percent of the results. While that might work for + some things, it doesn’t work for anything important. +

+

+ If you’re half trustworthy, you’re not trustworthy. +

+

+ If you’re often reliable, you’re not reliable. +

+

+ If you’re mostly consistent, you’re not consistent. +

+

+ The key to doing anything well is commitment. Not only does commitment + help you become better at what you do, but it also makes other people + want to help you. +

+

+ If you see your job as punching the clock, not only will you never be + great at it, but your employer won’t invest in you. The best + relationships are the ones where both partners go all in all the time to + make the relationship amazing. +

+

+ If committing sounds like a lot of work, it is. That’s why so many + people are half-in. The problem with half-in and half-committed is that + it doesn’t get you the results you want. If you're not committed, get + out. +

+

+ The committed person gets both the opportunity and the results. +

+

+ All in, all the time. +

+

+ (Share this Tiny Thought on Twitter)
+

+

+ Etc. +

+

+ Rafal Nadal on why hard is good: +

+

+ "One lesson I’ve learned is that if the job I do were easy, I wouldn’t + derive so much satisfaction from it. The thrill of winning is in direct + proportion to the effort I put in before. I also know, from long + experience, that if you make an effort in training when you don’t + especially feel like making it, the payoff is that you will win games + when you are not feeling your best. That is how you win championships, + that is what separates the great player from the merely good player. The + difference lies in how well you’ve prepared." +

+

+ — + Source +

+

+ Fatal flaws that everyone sees: +

+

+ "Everyone knew, deep-down, that middle-school geometry doomed the + design, but everyone also fervently believed that it could somehow be + overcome by sheer will, or hard work, or a stroke of genius. The theme + here is that the cultures that arise around products, methods, and + inventions often grow to exclude discussion of their fatal flaws, and + instead find elaborate ways to paper over them -- to find more and more + clever ways to pretend they don't exist." +

+

+ — + Source +

+

+
+

+ + + + + + +
+ Sponsored by Royce Investment Partners +
+

+ +

+

+ +

+

+ The Royce Funds. + Small-cap specialists. + +

+

+

+ Cheers,
— Shane +

+

+ P.S. Brilliant (and surprising) + design. +

+

+ P.P.S. “Most successful people are just an anxiety disorder + harnessed for productivity.” +

+

+
+

+ Free Version +

+

+ You're getting the Free version. Not only do members support our free + content, but they also get access to a community, hand-edited + transcripts, ad-free everything, and early access. +

+

+ See What You're Missing +

+
+

+

+ +

+ +

+
+

+

+
+

+

+
+

+

+
+

+

+
+

+

+
+

+ + + + + + + +
+ + + diff --git a/packages/content-handler/test/data/ghost-newsletter.html b/packages/content-handler/test/data/ghost-newsletter.html new file mode 100644 index 000000000..0caf6932a --- /dev/null +++ b/packages/content-handler/test/data/ghost-newsletter.html @@ -0,0 +1,361 @@ + + + + + + + why ish / 2022-10-14 + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/content-handler/test/data/node-weekly-newsletter.html b/packages/content-handler/test/data/node-weekly-newsletter.html new file mode 100644 index 000000000..397faa2e1 --- /dev/null +++ b/packages/content-handler/test/data/node-weekly-newsletter.html @@ -0,0 +1,288 @@ + + + + + + + + + + + + +
Plus choosing the best Node Docker image to use, and a way to embed Node and V8 into JVM apps. |
+ + + +
+
+ + + +

#​459 — October 20, 2022

Read on the Web

+ + +
+ + + + + +
Together with  + + Memetria + +
+
+
Node.js Weekly
+
+ +
+ +

Node.js 19 Released

+
+

As an odd-numbered release, Node 19 will never become an 'active LTS' version, but sits as the 'current' release that gets all the tastiest new features until early 2023. It then reaches 'end of life' on June 1, 2023. "If you’re interested in getting access to features early, Node.js 19 is ready,” says Rafael Gonzaga of the core team.

+

New features this time around include:

+
    +
  • +

    Watch mode. An experimental --watch Nodemon-esque mode for 'watching' files and restarting the process when imported files change. (Node 18.11.0 (LTS) also gains this feature.)

    +
  • +
  • +

    HTTP KeepAlive is now enabled by default. It's always been an option but now it's set to true by default. The default duration is 5 seconds.

    +
  • +
  • +

    V8 10.7. Node bumps up to the latest version of the V8 engine. It's not a big jump but does introduce Intl.NumberFormat.

    +
  • +
  • +

    The WebCrypto API is now stable (with the exception of Ed25519, Ed448, X25519, and X448).

    +
  • +
  • +

    Some other dependency upgrades, such as to npm 8.19.2 and llhttp 8.1.0.

    +
  • +
+

As things stand, we're in the odd position of Node 18.x and 19.x both being the 'Current' release, but Node 18 begins its role as an LTS release on October 25. More info in the release policies here and the OpenJS Foundation has extra detail in its release post too.

+
+ +

The Node.js Team

+ +
+ +

Memetria: Secure, Scalable, Full-Featured Redis 7 Hosting — The latest Redis features, instrumented and scaled with the tools teams need as they grow.

+

Memetria sponsor

+
+ +
+ +

Choosing the Best Node.js Docker Image — If you feel tempted to just throw FROM node into your Dockerfile, think again – there are other options to consider.

+

Liran Tal (Snyk)

+
+ +
+ +

▶  Effortless End-to-End Type-Safety with Phero — A demonstration of a library providing a type-safe TypeScript-based way to communicate between frontend and backend. GitHub repo.

+

Jasper Haggenburg

+
+
+

IN BRIEF:

+ +
+ +
+ +

PowerShell, NPM Scripts, and Silently Dropped Arguments — If you’re a Powershell user and you’re finding that some arguments aren’t being passed to your Node scripts run through npm run, Lloyd explains what’s going on.

+

Lloyd Atkinson

+
+ +
+ +

▶  A Next.js Crash Course — There are a lot of such videos, but this is a well recorded and up to date one so it might help you get the lay of the Next.js land if you’re just starting out with it. (2 hours 30 minutes)

+

Anson Foong

+
+ +
+ +

Web Scraping Google Maps with Puppeteer — We’d be surprised if you’d get away with this for long given there’s an official API, but it’s always interesting to see how it’s done.

+

Darshan Khandelwal

+
+ +
+ +

Your Step by Step Guide to Containerizing Node.js Web Applications

+

Snyk sponsor

+
+ +
+

Sending UDP Messages without DNS Lookups +
Herman J. Radtke III +

+
+ +
+

How Wix Uses Threading in Node Apps to Cut Kubernetes Pod Costs +
Jessica Wachtel (The New Stack) +

+
+
+ +

🛠 Code & Tools

+
+ +
+
+ +
+ +

Javet 2.0.0: Embed Node and V8 in Java Apps — Lets you spin up V8 interpreters or full Node.js runtimes within JVM-based apps. There’s a slide presentation to sell you on the idea and demonstrate how the integration works. (The name Javet comes from Java, V, and Eight.)

+

Sam Cao

+
+ +
+ +

Editly 0.14.0: Declarative Command Line Video Editing — Brings Node and FFmpeg together to let you more programatically edit and construct videos instead of wrangling with arcane ffmpeg command line options.

+

Mikael Finstad

+
+ +
+ +

Send Email, Push and SMS with Smart Routing, with Just 8 Lines of Code — Are you stuck using marketing tools like salesforce to contact your users? Send notifications from right within your application using the Courier API.

+

Courier.com sponsor

+
+ +
+

lady-gg: Simple TypeScript gRPC Client +
Mish Ushakov +

+
+
+
    +
  • +

    Awilix 8.0
    + ↳ Inversion of Control (IoC) container for Node.

    +
  • +
  • +

    Nx 15.0
    + ↳ Smart, fast and extensible build system.

    +
  • +
  • +

    Nightwatch 2.4
    + ↳ End-to-end testing framework, now with improved component testing support.

    +
  • +
  • +

    lowdb 4.0
    + ↳ Simple to use local JSON database.

    +
  • +
  • +

    Prisma 4.5
    + ↳ Next-generation ORM. There's a lot new here.

    +
  • +
  • +

    PSD 0.3
    + ↳ Zero-dependency PSD/Photoshop file parser.

    +
  • +
  • +

    fdir 5.3
    + ↳ Performance-oriented directory crawler and globbing library.

    +
  • +
  • +

    google-translate 2.0
    + ↳ Consume Google's Translate API.

    +
  • +
  • +

    Mercurius 11.1
    + ↳ Implement GraphQL servers with Fastify.

    +
  • +
  • +

    Mongoist 2.5.6
    + ↳ MongoDB driver built with async/await in mind.

    +
  • +
  • +

    Mojo.js 1.7
    + ↳ Web framework inspired by Perl's Mojolicious.

    +
  • +
+
+
+ +

💻 Jobs

+ +
+

Doppler - A SecretOps Platform Built by Developers for Developers — Doppler’s looking for Sr. Full-Stack Engineers to help shape the future of security devtools. TypeScript, React, Express, and Go, apply here. +
Doppler +

+
+ +
+

Find Tech Jobs with Hired — Create a profile on Hired to connect with hiring managers at growing startups and Fortune 500 companies. It's free for job-seekers. +
Hired +

+
+
+
+
+ +
+
+ + +n + diff --git a/packages/content-handler/test/newsletter.test.ts b/packages/content-handler/test/newsletter.test.ts index 46e3eb3d4..d8eb84d00 100644 --- a/packages/content-handler/test/newsletter.test.ts +++ b/packages/content-handler/test/newsletter.test.ts @@ -12,6 +12,10 @@ import nock from 'nock' import { generateUniqueUrl } from '../src/content-handler' import fs from 'fs' import { BeehiivHandler } from '../src/newsletters/beehiiv-handler' +import { ConvertkitHandler } from '../src/newsletters/convertkit-handler' +import { GhostHandler } from '../src/newsletters/ghost-handler' +import { CooperPressHandler } from '../src/newsletters/cooper-press-handler' +import { getNewsletterHandler } from '../src' chai.use(chaiAsPromised) chai.use(chaiString) @@ -89,63 +93,95 @@ describe('Newsletter email test', () => { }) }) - describe('isProbablyNewsletter', () => { - it('returns true for substack newsletter', async () => { + describe('getNewsletterHandler', () => { + it('returns substack newsletter handler', async () => { const html = load('./test/data/substack-forwarded-newsletter.html') - await expect( - new SubstackHandler().isNewsletter({ - html, - postHeader: '', - from: '', - unSubHeader: '', - }) - ).to.eventually.be.true + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.instanceOf(SubstackHandler) }) - it('returns true for private forwarded substack newsletter', async () => { + + it('returns SubstackHandler for private forwarded substack newsletter', async () => { const html = load( './test/data/substack-private-forwarded-newsletter.html' ) - await expect( - new SubstackHandler().isNewsletter({ - html, - postHeader: '', - from: '', - unSubHeader: '', - }) - ).to.eventually.be.true + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.instanceOf(SubstackHandler) }) - it('returns false for substack welcome email', async () => { + + it('returns undefined for substack welcome email', async () => { const html = load('./test/data/substack-forwarded-welcome-email.html') - await expect( - new SubstackHandler().isNewsletter({ - html, - postHeader: '', - from: '', - unSubHeader: '', - }) - ).to.eventually.be.false + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.undefined }) - it('returns true for beehiiv.com newsletter', async () => { + + it('returns BeehiivHandler for beehiiv.com newsletter', async () => { const html = load('./test/data/beehiiv-newsletter.html') - await expect( - new BeehiivHandler().isNewsletter({ - html, - postHeader: '', - from: '', - unSubHeader: '', - }) - ).to.eventually.be.true + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.instanceOf(BeehiivHandler) }) - it('returns true for milkroad newsletter', async () => { + + it('returns BeehiivHandler for milkroad newsletter', async () => { const html = load('./test/data/milkroad-newsletter.html') - await expect( - new BeehiivHandler().isNewsletter({ - html, - postHeader: '', - from: '', - unSubHeader: '', - }) - ).to.eventually.be.true + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.instanceOf(BeehiivHandler) + }) + + it('returns GhostHandler for ghost newsletter', async () => { + const html = load('./test/data/ghost-newsletter.html') + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.instanceOf(GhostHandler) + }) + + it('returns ConvertkitHandler for convertkit newsletter', async () => { + const html = load('./test/data/convertkit-newsletter.html') + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.instanceOf(ConvertkitHandler) + }) + + it('returns CooperPressHandler for node-weekly newsletter', async () => { + const html = load('./test/data/node-weekly-newsletter.html') + const handler = await getNewsletterHandler({ + html, + postHeader: '', + from: '', + unSubHeader: '', + }) + expect(handler).to.be.instanceOf(CooperPressHandler) }) }) @@ -156,16 +192,14 @@ describe('Newsletter email test', () => { .head( '/c/eJxNkk2TojAQhn-N3KTyQfg4cGDGchdnYcsZx9K5UCE0EMVAkTiKv36iHnarupNUd7rfVJ4W3EDTj1M89No496Uw0wCxgovuwBgYnbOGsZBVjDHzKPWYU8VehUMWOlIX9Qhw4rKLzXgGZziXnRTcyF7dK0iIGMVOG_OS1aTmKPRDilgVhTQUPCQIcE0x-MFTmJ8rCUpA3KtuenR2urg1ZtAzmszI0tq_Z7m66y-ilQo0uAqMTQ7WRX8auJKg56blZg7WB-iHDuYEBzO6NP0R1IwuYFphQbbTjnTH9NBfs80nym4Zyj8uUvyKbtUyGr5eUz9fNDQ7JCxfJDo9dW1lY9lmj_JNivPbGmf2Pt_lN9tDit9b-WeTetni85Z9pDpVOd7L1E_Vy7egayNO23ZP34eSeLJeux1b0rer_xaZ7ykS78nuSjMY-nL98rparNZNcv07JCjN06_EkTFBxBqOUMACErnELUNMSxTUjLDQZwzcqa4bRjCfeejUEFefS224OLr2S5wxPtij7lVrs80d2CNseRV2P52VNFMBipcdVE-U5jkRD7hFAwpGOylVwU2Mfc9qBh7DoR89yVnWXhgQFHnIsbpVb6tU_B-hH_2yzWY' ) - .reply(302, undefined, { + .reply(301, undefined, { Location: 'https://newsletter.slowchinese.net/p/companies-that-eat-people-217', }) - .get('/p/companies-that-eat-people-217') + nock('https://newsletter.slowchinese.net') + .head('/p/companies-that-eat-people-217') .reply(200, '') }) - after(() => { - nock.restore() - }) it('gets the URL from the header', async () => { const html = load('./test/data/substack-forwarded-newsletter.html') @@ -174,7 +208,7 @@ describe('Newsletter email test', () => { expect(url).to.startWith( 'https://newsletter.slowchinese.net/p/companies-that-eat-people-217' ) - }).timeout(10000) + }) }) context('when email is from beehiiv', () => { @@ -183,24 +217,42 @@ describe('Newsletter email test', () => { .head( '/ss/c/AX1lEgEQaxtvFxLaVo0GBo_geajNrlI1TGeIcmMViR3pL3fEDZnbbkoeKcaY62QZk0KPFudUiUXc_uMLerV4nA/3k5/3TFZmreTR0qKSCgowABnVg/h30/zzLik7UXd1H_n4oyd5W8Xu639AYQQB2UXz-CsssSnno' ) - .reply(302, undefined, { + .reply(301, undefined, { Location: 'https://www.milkroad.com/p/talked-guy-spent-30m-beeple', }) - .get('/p/talked-guy-spent-30m-beeple') + nock('https://www.milkroad.com') + .head('/p/talked-guy-spent-30m-beeple') .reply(200, '') }) - after(() => { - nock.restore() - }) - it('gets the URL from the header', async () => { const html = load('./test/data/beehiiv-newsletter.html') const url = await new BeehiivHandler().findNewsletterUrl(html) expect(url).to.startWith( 'https://www.milkroad.com/p/talked-guy-spent-30m-beeple' ) - }).timeout(10000) + }) + }) + + context('when email is from convertkit', () => { + before(() => { + nock('https://u25184427.ct.sendgrid.net') + .head( + '/ls/click?upn=MnmHBiCwIPe9TmIJeskmA7wDTJvdVU1ACmSJ753YuhScf71JWthxqM8RnVh-2FZG0rYzrbR04P99S2ld2OkTtQmrx2FDwArpYdk5N0jVpN9dLBZ-2BdPNqkRHxNvuygY8-2F-2FtRNFoPjxjtTuyWM6L3tcYDYnAnL2xCueddWcFlUNrQWsvLotmgvC-2BrQc7bxsZhW0pUBmS_vVXscVLXlj5UtQe3aqo5RMTdTq2PepdZjP86UOmA8nzSBnnaDN-2FNHWDodWnbUOPZ063v3w3z8QtcaPpE1qNu8xYkNJJFb-2F1uZEG-2BzsLfyDkjvvVX5zYs5OyyRYlhMOlXDJcr4-2FtMrFwii0uFAvwbhxDdnTxEpi-2F7maufyH39AEO-2BtCeSUg5V4FM43UpI1zUSXeWK-2Fh5JumSmR5XhrrRAig-3D-3D' + ) + .reply(301, undefined, { + Location: 'https://fs.blog/brain-food/october-16-2022/', + }) + nock('https://fs.blog') + .head('/brain-food/october-16-2022/') + .reply(200, '') + }) + + it('gets the URL from the header', async () => { + const html = load('./test/data/convertkit-newsletter.html') + const url = await new ConvertkitHandler().findNewsletterUrl(html) + expect(url).to.startWith('https://fs.blog/brain-food/october-16-2022/') + }) }) it('returns undefined if it is not a newsletter', async () => { @@ -208,6 +260,44 @@ describe('Newsletter email test', () => { const url = await new SubstackHandler().findNewsletterUrl(html) expect(url).to.be.undefined }) + + context('when email is from ghost', () => { + before(() => { + nock('https://u25184427.ct.sendgrid.net') + .head( + '/ls/click?upn=MnmHBiCwIPe9TmIJeskmA9nRLefEmmgrd5xWS-2Bc39wxPBpwDRny1FmWt1H0FpgKAz1dv_vVXscVLXlj5UtQe3aqo5RMTdTq2PepdZjP86UOmA8nzulL-2F3YyC-2FHgJV0JnOPtjNvgjHSaQVfisQ15hPQtnlo4t73zgTQL4QnDoer4qJ3-2F2Lf-2F2ElFMF3NyoUD4eqWCWwUM0w4P9Feaeo-2BolkySAB611BySXRt6V3Z-2F7mQcpcRX3D9zV-2B-2FdRY0Vn30aR-2BKY8qpTFuivxzF19UkQGjK5srg-3D-3D' + ) + .reply(301, undefined, { + Location: 'https://www.openml.fyi/2022-10-14/', + }) + nock('https://www.openml.fyi').head('/2022-10-14/').reply(200, '') + }) + + it('gets the URL from the header', async () => { + const html = load('./test/data/ghost-newsletter.html') + const url = await new GhostHandler().findNewsletterUrl(html) + expect(url).to.startWith('https://www.openml.fyi/2022-10-14/') + }) + }) + + context('when email is from cooper press', () => { + before(() => { + nock('https://u25184427.ct.sendgrid.net') + .head( + '/ls/click?upn=MnmHBiCwIPe9TmIJeskmA7mFdqmsIs-2B5Xs-2FNpSIs56obSPDXnoBEjufvIqRCEJUf5Uqg_vVXscVLXlj5UtQe3aqo5RMTdTq2PepdZjP86UOmA8nyFE700vlj1-2FK27spZiPEiEkDU3SIXWGeoiU60KFhM-2B-2Bxx5yiL8KKbAV6oFceRi8O1gMc3mdwg5D8FaaM3PublaX24iAcVbn99PzxJaPuVrU6xDWbRovw2UgGTIoEI-2BBO-2B0qzi2wv5c6yJTkUGZOcsJ6xGLXO1BO-2BHSbyZMZV4NMw-3D-3D' + ) + .reply(301, undefined, { + Location: 'https://nodeweekly.com/issues/459', + }) + nock('https://nodeweekly.com').head('/issues/459').reply(200, '') + }) + + it('gets the URL from the header', async () => { + const html = load('./test/data/node-weekly-newsletter.html') + const url = await new CooperPressHandler().findNewsletterUrl(html) + expect(url).to.startWith('https://nodeweekly.com/issues/459') + }) + }) }) describe('generateUniqueUrl', () => { diff --git a/packages/readabilityjs/Readability.js b/packages/readabilityjs/Readability.js index 86717a1b2..07cfc11bf 100644 --- a/packages/readabilityjs/Readability.js +++ b/packages/readabilityjs/Readability.js @@ -1909,10 +1909,21 @@ Readability.prototype = { values["og:site_name"] || null; // get website icon - const iconLink = this._doc.querySelector( + const siteIcon = this._doc.querySelector( "link[rel='apple-touch-icon'], link[rel='shortcut icon'], link[rel='icon']" ); - metadata.siteIcon = iconLink?.href; + if (siteIcon) { + const iconHref = siteIcon.getAttribute("href"); + if (iconHref) { + if (this.REGEXPS.b64DataUrl.test(iconHref)) { + // base64 encoded image + metadata.siteIcon = iconHref; + } else { + // allow relative URLs + metadata.siteIcon = this.toAbsoluteURI(iconHref); + } + } + } // get published date metadata.publishedDate = jsonld.publishedDate || @@ -2212,13 +2223,12 @@ Readability.prototype = { _createPlaceholders: async function (e) { for (const element of Array.from(e.getElementsByTagName('a'))) { - if (this.isEmbed(element)) { return; } // Create tweets placeholders from links - if (element.href.includes('twitter.com') || element.parentNode.className === 'tweet') { + if (element.href.includes('twitter.com') || (element.parentNode && element.parentNode.className === 'tweet')) { const link = element.href; const regex = /(https?:\/\/twitter\.com\/\w+\/status\/)(\d+)/gm; const match = regex.exec(link); @@ -2232,12 +2242,12 @@ Readability.prototype = { // remove all containers the tweet is nested in (if they contain the tweet only) let tweetParent = tweet.parentElement || tweet.parentNode; - while (tweetParent && tweetParent.children.length === 1) { + while (tweetParent && tweetParent.children.length === 1 && tweetParent.parentNode) { tweetParent.parentNode.replaceChild(tweet, tweetParent); tweetParent = tweet.parentElement || tweet.parentNode; } - if (tweetParent && tweetParent.className.includes('twitter-tweet')) { + if (tweetParent && tweetParent.className.includes('twitter-tweet') && tweetParent.parentNode) { tweetParent.parentNode.replaceChild(tweet, tweetParent); } } else if (element.parentNode && element.parentNode.className === 'tweet') { diff --git a/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected-metadata.json b/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected-metadata.json index 7be1e12a8..084df9381 100644 --- a/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected-metadata.json +++ b/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected-metadata.json @@ -5,8 +5,8 @@ "excerpt": "Omnivore is a read-it-later app that lets you save and organize everything you read online.", "siteName": "Omnivore", "siteIcon": "https://bucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com/public/images/8f6c575c-355b-44a6-a8e6-67a1c3745b59/favicon.ico", - "previewImage": "https://substackcdn.com/image/fetch/w_1008,h_528,c_fill,f_jpg,q_auto:best,fl_progressive:steep/https%3A%2F%2Fblog.omnivore.app%2Ftwitter%2Fsubscribe-card.svg%3Fv%3De03aaf5cd51e4035f38f2aa621f6a29c%26version%3D7", - "publishedDate": null, + "previewImage": "https://substackcdn.com/image/fetch/w_1200,h_600,c_limit,f_jpg,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F4e9ae3f6-53b2-495d-9794-a9a1661c5a51_964x964.jpeg", + "publishedDate": "2022-10-17T08:48:56.000Z", "language": "English", "readerable": true } diff --git a/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected.html b/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected.html index b6f0e25f6..843076a29 100644 --- a/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected.html +++ b/packages/readabilityjs/test/test-pages/omnivore_getting_started/expected.html @@ -1,257 +1,330 @@
-

Omnivore is a read-it-later app that lets you save and organize everything you read online.

+

Omnivore is a read-it-later app that lets you save and organize everything you read online.

-

This guide will show you how to use Omnivore’s basic functions and advanced features, divided into four main activities:

+

This guide will show you how to use Omnivore’s basic functions and advanced features, divided into four main activities:

  • -

    Saving

    +

    Saving

  • -

    Reading

    +

    Reading

  • -

    Organizing

    +

    Organizing

  • -

    Integrations

    +

    Integrations

-

The Library is the center of your Omnivore experience, where you can quickly access any links you have saved. Saved links remain in your Library forever unless you delete them.

-

There are five ways to save links to pages or articles that you wish to read later:

+

+ The Library is the center of your Omnivore experience, where you can quickly access any links you have saved. Saved links remain in your Library forever unless you delete them. +

+

There are five ways to save links to pages or articles that you wish to read later:

  • -

    Saving from Your Omnivore Library

    +

    Saving from Your Omnivore Library

  • -

    Saving from a Browser 

    +

    Saving from a Browser 

  • -

    Saving from a Phone or Tablet (iOS or Android)

    +

    Saving from a Phone or Tablet (iOS or Android)

  • -

    Newsletter Subscriptions via Email

    +

    Newsletter Subscriptions via Email

  • -

    Saving PDFs from a Mac

    +

    + Saving PDFs from a Mac
    +

-

Saving from Your Omnivore Library

-

1. In the upper right corner of your Library, tap the Add Link button.
2. Enter the URL you wish to save and tap Add Link.
3. The link will appear in your Library the next time you refresh it.

-

Saving from a Browser

-

1. Download and install the Omnivore extension for your browser:

+

Saving from Your Omnivore Library

+

+ 1. In the upper right corner of your Library, tap the Add Link button.
+ 2. Enter the URL you wish to save and tap Add Link.
+ 3. The link will appear in your Library the next time you refresh it.
+

+

Saving from a Browser

+

1. Download and install the Omnivore extension for your browser:

-

2. Navigate to the page you wish to save and tap the Omnivore button in your browser’s toolbar or Extensions menu.
3. Alternatively, you can right-click (command+click on Mac) on any hyperlink and select Save to Omnivore from the menu.
4. The link will appear in your Library the next time you refresh it.

-

Saving from a Phone or Tablet

-

The best way to save links from your mobile device is via the Omnivore app. You can download the app here:

+

+ 2. Navigate to the page you wish to save and tap the Omnivore button in your browser’s toolbar or Extensions menu.
+ 3. Alternatively, you can right-click (command+click on Mac) on any hyperlink and select Save to Omnivore from the menu.
+ 4. The link will appear in your Library the next time you refresh it.
+

+

Saving from a Phone or Tablet

+

The best way to save links from your mobile device is via the Omnivore app. You can download the app here:

-

Once the mobile app is installed:

+

Once the mobile app is installed:

  1. -

    In your browser, navigate to the page you wish to save and tap the Share button.

    +

    + In your browser, navigate to the page you wish to save and tap the Share button. +

  2. -

    Tap the Omnivore icon in the Share menu.

    +

    + Tap the Omnivore icon in the Share menu. +

  3. -

    The link will appear in your Library the next time you refresh it.

    +

    The link will appear in your Library the next time you refresh it.

-

Newsletter Subscriptions via Email

-

1. On the Omnivore website or app, tap your photo, initial, or avatar in the top right corner to access the profile menu. Select Emails from the menu.

-

2. Tap Create a New Email Address to add a new email address (ex: username-123_abc@inbox.omnivore.app) to the list.

-

3. Click the Copy icon next to the email address.

-

4. Navigate to the signup page for the newsletter you wish to subscribe to.
5. Paste the Omnivore email address into the signup form.

-

6. New newsletters will be automatically delivered to your Omnivore inbox.

-

Saving PDFs from a Mac 

+

Newsletter Subscriptions via Email

+

+ 1. On the Omnivore website or app, tap your photo, initial, or avatar in the top right corner to access the profile menu. Select Emails from the menu. +

+

+ 2. Tap Create a New Email Address to add a new email address (ex: username-123abc@inbox.omnivore.app) to the list. +

+

3. Click the Copy icon next to the email address.

+

+ 4. Navigate to the signup page for the newsletter you wish to subscribe to.
+ 5. Paste the Omnivore email address into the signup form. +

+

6. New newsletters will be automatically delivered to your Omnivore inbox.

+

Saving PDFs from a Mac 

  1. -

    Install the Mac App

    +

    + Install the Mac App +

  2. -

    On your Mac, locate the PDF you wish to save and right-click or ctrl+click on the file name.

    +

    On your Mac, locate the PDF you wish to save and right-click or ctrl+click on the file name.

  3. -

    Select Share from the menu and choose Omnivore.

    +

    + Select Share from the menu and choose Omnivore. +

  4. -

    The link will appear in your Library the next time you refresh it.

    +

    The link will appear in your Library the next time you refresh it.

-

Reading

-

Click any link saved in your Library to enter the Reader view. 

-

Omnivore formats pages for easy reading and highlighting, removing ads and clutter for distraction-free reading. The text-focused view also makes articles smaller and quicker to load.

-

While reading, you can:

+

Reading

+

Click any link saved in your Library to enter the Reader view. 

+

Omnivore formats pages for easy reading and highlighting, removing ads and clutter for distraction-free reading. The text-focused view also makes articles smaller and quicker to load.

+

While reading, you can:

  • -

    Change Formatting

    +

    Change Formatting

  • -

    Highlight Text

    +

    Highlight Text

  • -

    Add Notes

    +

    Add Notes

  • -

    View All Saved Highlights and Notes

    +

    View All Saved Highlights and Notes

  • -

    Track Reading Progress

    +

    Track Reading Progress

-

Change Formatting 

+

Change Formatting 

  1. -

    Theme: Tap your photo, initial, or avatar  in the top right corner to access the profile menu. Select the white or black thumbnail to choose the Light or Dark theme.

    +

    + Theme: Tap your photo, initial, or avatar  in the top right corner to access the profile menu. Select the white or black thumbnail to choose the Light or Dark theme. +

  2. -

    Text Formatting: Tap the Aa icon to adjust the text size, font, margins, and line spacing.

    +

    + Text Formatting: Tap the Aa icon to adjust the text size, font, margins, and line spacing. +

-

Highlight Text

+

Highlight Text

  1. -

    Select the text you wish to highlight.

    +

    Select the text you wish to highlight.

  2. -

    Tap the Highlight button.

    +

    + Tap the Highlight button. +

  3. -

    The text will appear highlighted next time you view the article.

    +

    The text will appear highlighted next time you view the article.

-

Add Notes

+

Add Notes

  1. -

    Highlight a section of text where you wish to add a note.

    +

    Highlight a section of text where you wish to add a note.

  2. -

    Tap the Note button, type your note, and tap Save.

    +

    + Tap the Note button, type your note, and tap Save. +

  3. -

    The Note icon will appear next time you view this article.

    +

    The Note icon will appear next time you view this article.

-

View All Saved Highlights and Notes

+

View All Saved Highlights and Notes

  1. -

    Tap the Highlight/Note icon to see a list of all the highlighted text and notes you have added to this page.

    +

    Tap the Highlight/Note icon to see a list of all the highlighted text and notes you have added to this page.

  2. -

    To remove a note or highlight, select it from the list and tap the Trash icon.

    +

    To remove a note or highlight, select it from the list and tap the Trash icon.

-

Track Reading Progress

-

Omnivore automatically keeps track of your reading progress across your different devices so you can easily pick up where you left off. A progress bar will appear at the top of each link in your Library after you have started reading.

-

Organizing

-

By default, the Library inbox displays all links you have saved. To manage your list and keep your reading organized, Omnivore provides the following actions: 

+

Track Reading Progress

+

Omnivore automatically keeps track of your reading progress across your different devices so you can easily pick up where you left off. A progress bar will appear at the top of each link in your Library after you have started reading.

+

Organizing

+

By default, the Library inbox displays all links you have saved. To manage your list and keep your reading organized, Omnivore provides the following actions: 

  • -

    Archiving

    +

    Archiving

  • -

    Labels

    +

    Labels

  • -

    Search

    +

    Search

  • -

    Filters

    +

    Filters

-

Archiving (Web)

+

Archiving

  1. -

    Tap the Menu icon next to the link you wish to archive (on the mobile app, long press the link to open the menu).

    +

    Tap the Menu icon next to the link you wish to archive (on the mobile app, long press the link to open the menu).

  2. -

    Select Archive.

    +

    + Select Archive. +

  3. -

    The link will disappear from the default Library view, but will show up if you select the Archived filter (see Filters below).

    +

    The link will disappear from the default Library view, but will show up if you select the Archived filter (see Filters below).

-

Labels

+

+ Labels +

  1. -

    Tap the Menu icon next to any link and select Set Labels.

    +

    + Tap the Menu icon next to any link and select Set Labels. +

  2. -

    Select an existing label from the list or tap Edit Labels to create a new one.

    +

    + Select an existing label from the list or tap Edit Labels to create a new one. +

  3. -

    The label will appear next to the link in your Library. Tap it to view all links with the same label.

    +

    The label will appear next to the link in your Library. Tap it to view all links with the same label.

  4. -

    Omnivore mobile app only: tap Labels to see a complete list of all labels you have used; tap one to view all links with the same label

    +

    + Omnivore mobile app only: tap Labels to see a complete list of all labels you have used; tap one to view all links with the same label +

  5. -

    Note: Omnivore will automatically assign some labels, such as “Newsletters.”

    +

    Note: Omnivore will automatically assign some labels, such as “Newsletters.”

-

Search

+

Search

  1. -

    To search through all your saved links, enter a keyword or phrase in the search bar. 

    +

    To search through all your saved links, enter a keyword or phrase in the search bar. 

  2. -

    You can combine keywords with labels and filters to focus your search even further. Learn more about advanced search.

    +

    + You can combine keywords with labels and filters to focus your search even further. Learn more about advanced search. +

-

Filters

+

Filters

  1. -

    Use the Filters menu to refine your Library view (some filters may be visible by default).

    +

    + Use the Filters menu to refine your Library view (some filters may be visible by default). +

  2. -

    Select Read Later to view a list of all your non-archived links except Newsletters.

    +

    + Select Read Later to view a list of all your non-archived links except Newsletters. +

  3. -

    Select Highlights to view the text selections you have highlighted in all your saved pages. 

    +

    + Select Highlights to view the text selections you have highlighted in all your saved pages.  +

  4. -

    Select Today to view a list of links you saved today.

    +

    + Select Today to view a list of links you saved today. +

  5. -

    Select Newsletters to view links saved via your newsletter subscriptions.

    +

    + Select Newsletters to view links saved via your newsletter subscriptions. +

-

Integrations

-

Omnivore allows integrations with knowledge bases and note-taking apps including:

+

Integrations

+

Omnivore allows integrations with knowledge bases and note-taking apps including:

  • -

    Logseq

    +

    Logseq

  • -

    Webhooks

    +

    Webhooks

-

Logseq

-

With Omnivore's Logseq plugin you can sync all your saved articles, highlights, and notes into Logseq, a popular knowledge base. For information on setting up and using the Logseq plugin, please refer to this helpful Omnivore for Logseq Plugin Guide.

-

Webhooks

-

Omnivore can trigger webhooks when you save a link or add highlights to a page you are reading. This example shows webhooks being used to write all saved links to a Google Sheets spreadsheet stored on a Google Drive.

+

Logseq

+

+ With Omnivore's Logseq plugin you can sync all your saved articles, highlights, and notes into Logseq, a popular knowledge base. For information on setting up and using the Logseq plugin, please refer to this helpful Omnivore for Logseq Plugin Guide. +

+

Webhooks

+

+ Omnivore can trigger webhooks when you save a link or add highlights to a page you are reading. This example shows webhooks being used to write all saved links to a Google Sheets spreadsheet stored on a Google Drive. +

\ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/omnivore_getting_started/source.html b/packages/readabilityjs/test/test-pages/omnivore_getting_started/source.html index 1e15be2cd..c0175ab57 100644 --- a/packages/readabilityjs/test/test-pages/omnivore_getting_started/source.html +++ b/packages/readabilityjs/test/test-pages/omnivore_getting_started/source.html @@ -1,321 +1,1667 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Getting Started with Omnivore - Omnivore - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + Getting Started with Omnivore - Omnivore + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + #nojs-banner { + position: fixed; + bottom: 0; + left: 0; + padding: 16px 16px 16px 32px; + width: 100%; + box-sizing: border-box; + background: red; + color: white; + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 13px; + line-height: 13px; + } + #nojs-banner a { + color: inherit; + text-decoration: underline; + } + ]]> + + + +
+
+ + +
+ + + + +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ Learn the best ways to save links with Omnivore +
+
+
+
+ Omnivore +
+
+ +
+ + + + +
+ 2 +
+ + + + + +
+
+
+
+
+ +
+
+ Highlighted <code> in Omnivore +
+
+
+
+ Omnivore +
+
+ +
+ + + + +
+ 1 +
+ + + + + +
+
+
+
+
+ +
+
+ Add to your library with your Omnivore email address +
+
+
+
+ Omnivore +
+
+ +
+ + + + +
+ 1 +
+ + + + + +
+
+
See all + + + + +
+
+
+
+
+ +
+ +
+ +
+
+
+ + + + + + + diff --git a/packages/readabilityjs/test/test-pages/omnivore_getting_started/url.txt b/packages/readabilityjs/test/test-pages/omnivore_getting_started/url.txt index 8656d054d..e34bf71bb 100644 --- a/packages/readabilityjs/test/test-pages/omnivore_getting_started/url.txt +++ b/packages/readabilityjs/test/test-pages/omnivore_getting_started/url.txt @@ -1 +1 @@ -https://blog.omnivore.app/p/d0d30ea5-49aa-4c04-8fae-c004be9c51b9 \ No newline at end of file +https://blog.omnivore.app/p/getting-started-with-omnivore-382 \ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/slowboring/expected-metadata.json b/packages/readabilityjs/test/test-pages/slowboring/expected-metadata.json new file mode 100644 index 000000000..0aa1b9aaf --- /dev/null +++ b/packages/readabilityjs/test/test-pages/slowboring/expected-metadata.json @@ -0,0 +1,12 @@ +{ + "title": "Elon Musk’s business ties deserve more scrutiny", + "byline": "Matthew Yglesias", + "dir": null, + "excerpt": "Across industries, executive after executive has chosen the PRC over free speech", + "siteName": "Slow Boring", + "siteIcon": "https://bucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com/public/images/886619a6-6fbb-4b2b-a1ab-cf25e43979c4/favicon.ico", + "previewImage": "https://substackcdn.com/image/fetch/w_1200,h_600,c_limit,f_jpg,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2Ffc981d70-7927-41ec-be3c-844a830a058a_4000x2713.jpeg", + "publishedDate": "2022-10-17T10:01:29.000Z", + "language": "English", + "readerable": true +} diff --git a/packages/readabilityjs/test/test-pages/slowboring/expected.html b/packages/readabilityjs/test/test-pages/slowboring/expected.html new file mode 100644 index 000000000..41803a07e --- /dev/null +++ b/packages/readabilityjs/test/test-pages/slowboring/expected.html @@ -0,0 +1,118 @@ +
+
+
+

Across industries, executive after executive has chosen the PRC over free speech

+
+
+

Dozens of different controversies are swirling around Elon Musk at any given time, most of them related to people being angry and/or thrilled by the idea of a rich businessman vocalizing right-of-center political opinions.

+

+ What has gotten lost in this discourse is a more boring — but, I think, more significant — concern about his possible takeover of Twitter. This is because Musk, like most global manufacturing executives these days, has extensive business dealings with China. And while there’s nothing wrong with that per se, it means Musk has to watch what he says regarding the PRC, not just in his personal capacity as a business executive but potentially in his institutional role as well. And he’s not alone; Apple TV+, for example, has a rule that none of its content can portray China negatively. +

+

That’s an unfortunate but straightforward consequence of Apple TV+ being so small compared to Apple’s core business of making and selling smartphones: they compromise the content business for the sake of the manufacturing business. The good news for the world is that Apple TV+ is a very small share of western cultural output. They’re doing well with niche content (I love “For All Mankind”), and they won an Oscar for “Coda.” But it’s a small service in the scheme of things.

+

+ The problem for the world is that Twitter would be the Apple TV+ of Elon Musk’s enterprises, much smaller and less important than Tesla, so its interests will always be sacrificed to advance Tesla’s interests. And Tesla, like Apple’s hardware business, is deeply enmeshed in China. But Twitter is much more important to global politics and culture than Apple TV+. That’s the whole reason the Musk/Twitter saga has been such a subject of fascination. Twitter is one of a handful of other influential media properties — The New York Times, the three cable networks, AM talk radio stations — that exert a cultural and political influence that far exceeds their modest financial footprints. Apple executives are much less polarizing and controversial than Musk. But pretty much everyone on both the left and right knows they’re a bit squirrelly about China for business reasons. And if they bought the New York Times, that would have dire implications for the integrity of their China coverage. +

+

Musk is mercurial and I won’t pretend to be able to predict what he will do. But I think his business relationships with China and tendency to take pro-PRC positions in his public statements raise some disturbing questions about the future of Twitter that deserve much more scrutiny relative to the concern that he won’t be strict enough in policing hate speech.

+

China leans very effectively on western businesses

+

We don’t know exactly why Apple adopted its “no China” content policy, but we can come up with a pretty clear guess based on a handful of high-profile blowups. Back in 2018, for example, Mercedes Benz posted a Dalai Lama quote in English that had nothing to do with China.

+ +

+ The Chinese government flipped out, and Mercedes officially apologized, even though they obviously didn’t do anything wrong. +

+

+ What was Mercedes so worried about? Well, we know that when Daryl Morey tweeted in English something supportive of protestors in Hong Kong, the PRC didn’t hesitate to wreck the Houston Rockets’ access to the Chinese television market. Other NBA players — including some of the league’s most outspoken and socially conscious stars — sided with China over Morey. +

+

When celebrities do this stuff, it often attracts criticism from Republican China hawks.

+
+

+ Because conservatives agree with Musk about the importance of making social media a welcoming place for transphobic jokes, they haven’t been nearly as quick to condemn him telling the Financial Times that Taiwan ought to become a Beijing-ruled Special Administrative Region. But when it comes to a random celebrity like John Cena, conservatives understand the basic dynamic perfectly well — money talks. +

+
+
+
+
+ + Image + +
+
+
+

But this applies to corporate entities like Apple and Mercedes-Benz even more than it does to individuals like Cena. An individual always has the option of voluntarily earning less money in order to take a principled stand on human rights. If Apple executives started “doing the right thing” in a way that seriously impaired the company’s stock price, they’d be vulnerable to activist investor campaigns and potentially even litigation.

+

+ This strikes me as a fairly profound policy problem. Part of the promise of the old bipartisan consensus in favor of trade with China was that economic integration would help spur the export of American speech norms. Not only has that not worked out, but the practical consequences have been the opposite. I’m sure that if you asked Intel executives whether it’s good that China is perpetrating a genocide and using forced labor in Xinjiang, the vast majority of them would say, in private, that it’s actually bad. But in practice, Intel has apologized for past statements about Xinjiang. Because for all the virtues of capitalism and the for-profit business corporation, it also has at its core an amorality that can produce really bad results when it interfaces with a large despotic regime. +

+

Elon Musk does a ton of business in China

+

+ The global electronics industry is famously exposed to the China market. But as we saw with Mercedes Benz, China is a really big deal for automakers as well. Tesla is setting sales records in China, which is obviously only possible because the Chinese government lets Tesla sell cars there. +

+ +

+ Like many global manufacturing companies, Tesla also builds things in China — including what Musk projects will be the company’s largest factory in the world. +

+

There’s nothing particularly untoward or suspect about this. Many more people live in East Asia than live in North America, so it’s natural that over the long term any manufacturer of large objects is going to want to have their biggest production facilities in Asia.

+

+ But Tesla has received unusually generous treatment from the Chinese government. Part of China’s strategy for industrializing has been to say to foreign companies operating in key sectors that if they want to be in the China market, they need to operate as a joint venture with a Chinese-owned firm. It’s become pretty clear that this is a mechanism for Chinese companies to learn foreign technology and business methods, and ultimately pull Chinese-owned companies up the value chain. Foreign companies don’t like it, but in most cases the lure of the China market is just too good to resist. Telsa, though, managed to become the first automaker to receive an exemption from the joint venture requirement. +

+

+ That’s a huge coup for Musk and a testament both to the quality of Tesla’s products and also to Musk’s shrewdness and skill as a businessman. But without being a hater, I have to say that “shrewdness and skill at making Xi Jinping like you and want to give you favorable treatment” is a mixed ethical bag. The New York Times is blocked in China as part of their censorship regime. So is Substack. It would be better for their businesses if A.G. Sulzberger and Chris Best were as skilled as Musk at getting Xi to do special favors for their companies. But achieving that goal would require them to fundamentally compromise the integrity of editorial businesses in a way that I think would outweigh the benefits. More fundamentally, as a non-corporate person, I would be thrilled to have Chinese readers but also profoundly embarrassed if this site managed to become popular in China without getting censored. That would suggest I’m doing a really bad job of standing up for core values that Americans on the left and right broadly agree on. +

+

Elon Musk’s stated views are very pro-PRC

+

+ Ten years ago, Tesla was a very small company that Mitt Romney denounced as the kind of “loser” that was dependent on unwise subsidies from the Obama administration. At the time, liberals were generally enthusiastic about the idea of an electric vehicle startup. And to the extent that Musk had any known political views, he seemed like a pretty mainstream Democrat. +

+

He also had very normal geopolitical opinions like “foreign dictatorships are bad” that a person who doesn’t have extensive Chinese business interests is free to express on Twitter (which, of course, is banned in China).

+
+

But as Musk’s business grew, he became more China-friendly in his public statements. Initially this took the relatively sensible form adopted by many who need to come up with something nice to say about the PRC: praise for their extensive infrastructure buildout. This is something I’ve done myself — it’s incredibly impressive that China built mid-sized metro systems in a couple of dozen cities over the course of time that it took the United States to build three miles of subway on the Upper East Side.

+
+

But in more recent years, Musk’s thinking on China has evolved in less defensible directions. We know, for example, that part of his rightward political evolution over the past several years was driven by the restrictiveness of California’s Covid-19 protocols. Yet apart from a token March 2020 gesture of praise for Xi’s success in controlling the virus, he’s had absolutely nothing to say about China’s Covid Zero policies that have been much more draconian than anything offered in the United States.

+
+

The same is true, though obviously in a much broader sense, of Musk’s emergence as a champion of free speech who is skeptical of heavy-handed content moderation policies by big tech platforms. Reasonable people can disagree as to exactly where Twitter and Facebook should draw these lines, and I tend to agree with Musk about some of the specifics here. But obviously the speech restrictions on Twitter and Facebook are much less onerous than the ones the PRC imposes on their domestic internet. That’s part of the reason Twitter and Facebook are banned in China! But Musk has never said or done anything to champion free speech in China. That’s not surprising — nobody with business interests in China criticizes the PRC’s speech repression. But it is a reminder that Musk, like many business people, prioritizes being in the good graces of the dictators who run China over maintaining his commitment to free speech.

+

+ And, again, Musk’s silence on free speech in China isn’t because he doesn’t talk about China. He does talk about China — including publicly advocating for a PRC takeover of Taiwan just one day before Tesla buyers were made eligible for an important PRC tax break. +

+

What does this mean for Twitter?

+

Does this mean that a Musk-run Twitter is going to impose China-friendly censorship as heavy-handed as the self-censorship Musk practices in his personal commentary? That he’s going to turn it into a China-free zone the way that Apple has with its content business?

+

Probably not.

+

I really don’t know what Musk’s business ties to China mean for Twitter. And I wish that more of the reporting and commentary on this takeover proposal focused on this. After all, here are some things that we know:

+
    +
  • +

    China is very willing to threaten western companies over their China-related speech practices.

    +
  • +
  • +

    China takes a very expansive view of what speech should be prohibited by people who want to do business in China, including speech via English-language communications on platforms that are banned in China.

    +
  • +
  • +

    Western business enterprises — including car companies — have been willing to do what the PRC asks, even in ridiculous cases like apologizing for an innocuous Dalai Lama quote.

    +
  • +
  • +

    Tesla has both sought and received favorable treatment from the Chinese government and views China as a crucial market for both sales and production — one whose importance is likely to grow in the future.

    +
  • +
  • +

    Musk has, in his personal communication, stopped criticizing the PRC on any topic, including on matters like free speech and Covid protocols where he’s been vocal about U.S. policies, and he has instead praised the PRC government and endorsed its views on geopolitical conflicts.

    +
  • +
+

So what follows from this? I don’t know, in part because Musk is mercurial and hard to predict. But also in part because it’s not clear exactly how Chinese censorship exporting works. I assume nobody had to explicitly tell Musk that criticizing Covid Zero would be bad for business — it’s just something he knows, so he doesn’t do it. By the same token, I doubt there was ever an explicit conversation between Tim Cook and someone from the Chinese government about Apple TV+ programming. Apple just knows the score.

+

Media brands with integrity, including Substack and the New York Times and Twitter, just proceed and accept the censorship hit. Industrial brands like Mercedes stifle speech for the sake of sales. When an industrial brand is also a media brand, like Apple, the industrial brand makes the media brand do what’s good for business. So what does Twitter do once it’s owned by the CEO of a major industrial brand? I don’t know, but I wish the reporters on the Musk beat would ask. And frankly, I wish Congress and the FTC would, too.

+
+
+
\ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/slowboring/source.html b/packages/readabilityjs/test/test-pages/slowboring/source.html new file mode 100644 index 000000000..c6345994a --- /dev/null +++ b/packages/readabilityjs/test/test-pages/slowboring/source.html @@ -0,0 +1,1830 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Elon Musk’s business ties deserve more scrutiny + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + +
+
+
+ +
+
+
+
+
+
+ 234 Comments +
+
+
+
+
+ +
+
+ +
+
+
+
+ + + +
+
+
+
+
+
+ + + + + +
+
+ +
+
+ +
+

+ >>>Because conservatives agree with Musk about the importance of making social media a welcoming place for transphobic jokes, they haven’t been nearly as quick to condemn him telling the Financial Times that Taiwan ought to become a Beijing-ruled Special Administrative Region. But when it comes to a random celebrity like John Cena, conservatives understand the basic dynamic perfectly well — money talks.<<< +

+

+ Matt's being too polite here. Republicans are deeply unserious and/or profoundly duplicitous about China policy. Pretty much everything the GOP does on China is intended for domestic political audiences only. Strengthening America to compete in the new Cold War would mean things like: +

+

+ *Addressing US demographic decline via immigration (Republicans want to do just the opposite, and push our growth levels down to PRC levels). +

+

+ *Competing for the world's best and brightest to strengthen America's STEM sector (Republicans want to do just the opposite, and make it harder for talented foreigners to get work permits, green cards or student visas). +

+

+ *Leading on Pacific Rim economic integration (Republicans have become the more protectionist of our two parties, and gave Obama essentially zero support on TPP, despite the obvious boost joining this group would have given to US efforts to contest the PRC's growing influence in this critical region). +

+

+ *Bolstering US democratic norms (something cold warriors in the 1950s and 1960s understood: a nontrivial degree of support for the Civil Rights movement was generated by the need to compete for global hearts and minds, and to demonstrate the superiority of liberal democracy over totalitarianism). Republicans won't even disavow insurrectionists. +

+

+ *Readying the country for its next, inevitable rendez-vous with a pandemic. (Republicans have mostly gone full anti-vax nutter). +

+

+ I could also cite climate change, infrastructure and various other areas where the US should play a leading role and/or demonstrate robust state capacity. The PRC is a formidable adversary: surely a lot tougher opponent that the USSR ever was. Republicans aren't serious about any of it, though admittedly they do a bang up job hurling racist invective (Kung Flu, etc) on Twitter, so at least there's that! +

+
+
+ Expand full comment +
+
+
+ +
+ +
+
+
+
+
+
+
+ + + + + +
+
+ +
+
+ +
+

+ Matt overrates the cultural importance of Twitter. It has a tenth as many users as FB. The vast majority of users get a really shitty experience: tweeting is about as likely to engage others as sitting alone and screaming at your TV. Twitter has become a forum for elites to spar with each other in public view and that’s not nothing, but it isn’t so different from dueling press releases or cable news appearances, and it could all be transferred to FB pretty easily. Bottom line, Twitter is much less integrated into normal peoples’ lives than FB or Instagram and there isn’t an obvious path for it to broadly engage normal people. +

+
+
+ Expand full comment +
+
+
+ +
+ +
+
+
+
+
+
232 more comments… +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ The launch of a new experiment +
+
+
+ + + +
+ + +
+ + + + +
+ 594 +
+ + + + +
+ 1,037 +
+ + + + + +
+
+
+
+
+ +
+
+ A huge fuckup, with perhaps not-so-huge policy stakes +
+
+ + +
+ + + + +
+ 177 +
+ + + + +
+ 484 +
+ + + + + +
+
+
+
+
+ +
+
+ Prestigious universities and worthy nonprofits shouldn't push nonsense +
+
+ + +
+ + + + +
+ 403 +
+ + + + +
+ 380 +
+ + + + + +
+
+
See all + + + + +
+
+
+
+
+ +
+
+ +
+
+
+ + + + + + + diff --git a/packages/readabilityjs/test/test-pages/slowboring/url.txt b/packages/readabilityjs/test/test-pages/slowboring/url.txt new file mode 100644 index 000000000..53de21b75 --- /dev/null +++ b/packages/readabilityjs/test/test-pages/slowboring/url.txt @@ -0,0 +1 @@ +https://www.slowboring.com/p/elon-musks-business-ties-deserve \ No newline at end of file diff --git a/packages/web/components/templates/ConfirmProfileModal.tsx b/packages/web/components/templates/ConfirmProfileModal.tsx index 6c36c580a..72a939947 100644 --- a/packages/web/components/templates/ConfirmProfileModal.tsx +++ b/packages/web/components/templates/ConfirmProfileModal.tsx @@ -139,7 +139,7 @@ export function ConfirmProfileModal(): JSX.Element { {isUsernameValid && ( Username is available. diff --git a/packages/web/components/templates/SavingRequest.tsx b/packages/web/components/templates/SavingRequest.tsx index 04604ad04..b915e4dc8 100644 --- a/packages/web/components/templates/SavingRequest.tsx +++ b/packages/web/components/templates/SavingRequest.tsx @@ -33,7 +33,7 @@ export function Loader(): JSX.Element { '&:after': { width: '10px', display: 'inline-block', - content: 'test', + content: '', animation: `${breathe} steps(1,end) 2s infinite`, }, }}>Saving Link diff --git a/packages/web/components/templates/auth/EmailForgotPassword.tsx b/packages/web/components/templates/auth/EmailForgotPassword.tsx index e641a6b3f..11867cce5 100644 --- a/packages/web/components/templates/auth/EmailForgotPassword.tsx +++ b/packages/web/components/templates/auth/EmailForgotPassword.tsx @@ -51,6 +51,7 @@ export function EmailForgotPassword(): JSX.Element { name="email" value={email} placeholder="Email" + css={{ bg: 'white', color: 'black' }} onChange={(e) => { e.preventDefault(); setEmail(e.target.value); }} /> diff --git a/packages/web/components/templates/auth/EmailLogin.tsx b/packages/web/components/templates/auth/EmailLogin.tsx index e0332c87a..ced660e4d 100644 --- a/packages/web/components/templates/auth/EmailLogin.tsx +++ b/packages/web/components/templates/auth/EmailLogin.tsx @@ -55,6 +55,7 @@ export function EmailLogin(): JSX.Element { name="email" value={email} placeholder="Email" + css={{ backgroundColor: 'white', color: 'black' }} onChange={(e) => { e.preventDefault(); setEmail(e.target.value); }} /> @@ -67,6 +68,7 @@ export function EmailLogin(): JSX.Element { name="password" value={password} placeholder="Password" + css={{ bg: 'white', color: 'black' }} onChange={(e) => setPassword(e.target.value)} /> diff --git a/packages/web/components/templates/auth/EmailResetPassword.tsx b/packages/web/components/templates/auth/EmailResetPassword.tsx index 43b8e65d8..e58dbbf75 100644 --- a/packages/web/components/templates/auth/EmailResetPassword.tsx +++ b/packages/web/components/templates/auth/EmailResetPassword.tsx @@ -61,6 +61,7 @@ export function EmailResetPassword(): JSX.Element { name="password" value={password} placeholder="Password" + css={{ bg: 'white', color: 'black' }} onChange={(e) => { e.preventDefault(); setPassword(e.target.value); }} /> (Password must be at least 8 chars) diff --git a/packages/web/components/templates/auth/EmailSignup.tsx b/packages/web/components/templates/auth/EmailSignup.tsx index 6a5c3259d..9a8a9b9c5 100644 --- a/packages/web/components/templates/auth/EmailSignup.tsx +++ b/packages/web/components/templates/auth/EmailSignup.tsx @@ -63,7 +63,7 @@ export function EmailSignup(): JSX.Element { return (
- Sign Up + Sign Up Email @@ -73,6 +73,7 @@ export function EmailSignup(): JSX.Element { name="email" value={email} placeholder="Email" + css={{ backgroundColor: 'white', color: 'black' }} onChange={(e) => { e.preventDefault(); setEmail(e.target.value); }} /> @@ -85,6 +86,7 @@ export function EmailSignup(): JSX.Element { name="password" value={password} placeholder="Password" + css={{ bg: 'white', color: 'black' }} onChange={(e) => setPassword(e.target.value)} /> @@ -97,6 +99,7 @@ export function EmailSignup(): JSX.Element { name="name" value={fullname} placeholder="Full Name" + css={{ bg: 'white', color: 'black' }} onChange={(e) => setFullname(e.target.value)} /> @@ -109,6 +112,7 @@ export function EmailSignup(): JSX.Element { name="username" value={username} placeholder="Username" + css={{ bg: 'white', color: 'black' }} onChange={handleUsernameChange} /> @@ -128,7 +132,7 @@ export function EmailSignup(): JSX.Element { {isUsernameValid && ( Username is available. diff --git a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx index c3be5cc91..2869846d1 100644 --- a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx +++ b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx @@ -346,8 +346,22 @@ export function HomeFeedContainer(): JSX.Element { } const modalTargetItem = useMemo(() => { - return (labelsTarget || snoozeTarget || shareTarget || linkToEdit || linkToRemove || linkToUnsubscribe) - }, [labelsTarget, snoozeTarget, shareTarget, linkToEdit, linkToRemove, linkToUnsubscribe]) + return ( + labelsTarget || + snoozeTarget || + shareTarget || + linkToEdit || + linkToRemove || + linkToUnsubscribe + ) + }, [ + labelsTarget, + snoozeTarget, + shareTarget, + linkToEdit, + linkToRemove, + linkToUnsubscribe, + ]) useKeyboardShortcuts( libraryListCommands((action) => { @@ -638,7 +652,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { const [showRemoveLinkConfirmation, setShowRemoveLinkConfirmation] = useState(false) const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] = - useState(false) + useState(false) const updateLayout = useCallback( async (newLayout: LayoutType) => { @@ -975,7 +989,9 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { )} {showRemoveLinkConfirmation && ( setShowRemoveLinkConfirmation(false)} /> diff --git a/packages/web/pages/support.tsx b/packages/web/pages/support.tsx index 78d295140..98e25f366 100644 --- a/packages/web/pages/support.tsx +++ b/packages/web/pages/support.tsx @@ -1,14 +1,51 @@ -import { useEffect } from 'react' +import { useEffect, useCallback } from 'react' +import { Button } from '../components/elements/Button' +import { HStack } from '../components/elements/LayoutPrimitives' import { SettingsLayout } from '../components/templates/SettingsLayout' +import { setupAnalytics } from '../lib/analytics' export default function Support(): JSX.Element { - useEffect(() => { + const initAnalytics = useCallback(() => { + setupAnalytics() window.Intercom('show') }, []) + useEffect(() => { + window.addEventListener('load', initAnalytics) + return () => { + window.removeEventListener('load', initAnalytics) + } + }, [initAnalytics]) + return ( - <> + + + ) } diff --git a/yarn.lock b/yarn.lock index 272f859a8..a90c7857b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11547,6 +11547,19 @@ copy-to-clipboard@^3.3.1: dependencies: toggle-selection "^1.0.6" +copyfiles@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/copyfiles/-/copyfiles-2.4.1.tgz#d2dcff60aaad1015f09d0b66e7f0f1c5cd3c5da5" + integrity sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg== + dependencies: + glob "^7.0.5" + minimatch "^3.0.3" + mkdirp "^1.0.4" + noms "0.0.0" + through2 "^2.0.1" + untildify "^4.0.0" + yargs "^16.1.0" + core-js-compat@^3.20.2, core-js-compat@^3.21.0: version "3.21.1" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.21.1.tgz#cac369f67c8d134ff8f9bd1623e3bc2c42068c82" @@ -14586,6 +14599,18 @@ glob@7.2.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glo once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.0.5: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-dirs@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" @@ -18720,7 +18745,7 @@ minimatch@5.0.1: dependencies: brace-expansion "^2.0.1" -minimatch@^3.0.2, minimatch@^3.0.4: +minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -19454,6 +19479,14 @@ nodemon@^2.0.15: undefsafe "^2.0.5" update-notifier "^5.1.0" +noms@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/noms/-/noms-0.0.0.tgz#da8ebd9f3af9d6760919b27d9cdc8092a7332859" + integrity sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow== + dependencies: + inherits "^2.0.1" + readable-stream "~1.0.31" + nopt@^4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48" @@ -21876,6 +21909,16 @@ readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stre string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@~1.0.31: + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + integrity sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + readdir-scoped-modules@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" @@ -23549,6 +23592,11 @@ string_decoder@^1.0.0, string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== + string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -24083,7 +24131,7 @@ throttleit@^1.0.0: resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw= -through2@^2.0.0: +through2@^2.0.0, through2@^2.0.1: version "2.0.5" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== @@ -25977,7 +26025,7 @@ yargs-unparser@2.0.0: flat "^5.0.2" is-plain-obj "^2.1.0" -yargs@16.2.0, yargs@^16.0.0, yargs@^16.1.1, yargs@^16.2.0: +yargs@16.2.0, yargs@^16.0.0, yargs@^16.1.0, yargs@^16.1.1, yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==