Merge branch 'main' into android-cleanup-issues-and-warnings

This commit is contained in:
Jackson Harper 2024-01-03 11:53:31 +08:00 committed by GitHub
commit 0df737312a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
206 changed files with 6889 additions and 1816 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,10 @@ package app.omnivore.omnivore.ui.library
import android.content.Intent
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.FloatTweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -10,9 +14,19 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.DismissDirection
import androidx.compose.material.DismissState
import androidx.compose.material.FractionalThreshold
import androidx.compose.material.DismissValue
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material.SwipeToDismiss
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
@ -20,6 +34,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@ -28,9 +43,9 @@ import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.components.AddLinkSheetContent
import app.omnivore.omnivore.ui.editinfo.EditInfoSheetContent
import app.omnivore.omnivore.ui.components.LabelsSelectionSheetContent
import app.omnivore.omnivore.ui.components.LabelsViewModel
import app.omnivore.omnivore.ui.editinfo.EditInfoSheetContent
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
@ -108,7 +123,8 @@ fun LibraryView(
fun BottomSheetContent(libraryViewModel: LibraryViewModel,
labelsViewModel: LabelsViewModel,
saveViewModel: SaveViewModel,
editInfoViewModel: EditInfoViewModel) {
editInfoViewModel: EditInfoViewModel
) {
val showLabelsSelectionSheet: Boolean by libraryViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
val showAddLinkSheet: Boolean by libraryViewModel.showAddLinkSheetLiveData.observeAsState(false)
val showEditInfoSheet: Boolean by libraryViewModel.showEditInfoSheetLiveData.observeAsState(false)
@ -229,27 +245,101 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
item {
LibraryFilterBar(libraryViewModel)
}
items(cardsData) { cardDataWithLabels ->
val selected = cardDataWithLabels.savedItem.savedItemId == selectedItem?.savedItem?.savedItemId
SavedItemCard(
selected = selected,
savedItemViewModel = libraryViewModel,
savedItem = cardDataWithLabels,
onClickHandler = {
libraryViewModel.actionsMenuItemLiveData.postValue(null)
val activityClass =
if (cardDataWithLabels.savedItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.savedItem.slug)
context.startActivity(intent)
},
actionHandler = {
libraryViewModel.handleSavedItemAction(
cardDataWithLabels.savedItem.savedItemId,
it
)
items(
items = cardsData,
key = { item -> item.savedItem.savedItemId }
) { cardDataWithLabels ->
val swipeThreshold = 0.40f
val currentThresholdFraction = remember { mutableStateOf(0f) }
val currentItem by rememberUpdatedState(cardDataWithLabels.savedItem)
val swipeState = rememberDismissState(
confirmStateChange = {
if (it == DismissValue.DismissedToEnd ||
currentThresholdFraction.value < swipeThreshold ||
currentThresholdFraction.value > 1.0f) {
false
}
if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving.
if (currentItem.isArchived) {
libraryViewModel.unarchiveSavedItem(currentItem.savedItemId)
} else {
libraryViewModel.archiveSavedItem(currentItem.savedItemId)
}
} else if (it == DismissValue.DismissedToStart) { // Deleting.
libraryViewModel.deleteSavedItem(currentItem.savedItemId)
}
true
}
)
SwipeToDismiss(
state = swipeState,
modifier = Modifier.padding(vertical = 4.dp),
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
dismissThresholds = { FractionalThreshold(swipeThreshold) },
background = {
val direction = swipeState.dismissDirection ?: return@SwipeToDismiss
val color by animateColorAsState(
when (swipeState.targetValue) {
DismissValue.Default -> Color.LightGray
DismissValue.DismissedToEnd -> Color.Green
DismissValue.DismissedToStart -> Color.Red
}, label = "backgroundColor"
)
val alignment = when (direction) {
DismissDirection.StartToEnd -> Alignment.CenterStart
DismissDirection.EndToStart -> Alignment.CenterEnd
}
val icon = when (direction) {
DismissDirection.StartToEnd -> if (currentItem.isArchived) Icons.Default.Unarchive else Icons.Default.Archive
DismissDirection.EndToStart -> Icons.Default.Delete
}
val scale by animateFloatAsState(
if (swipeState.targetValue == DismissValue.Default) 0.75f else 1f,
label = "scaleAnimation"
)
Box(
Modifier.fillMaxSize().background(color).padding(horizontal = 20.dp),
contentAlignment = alignment
) {
currentThresholdFraction.value = swipeState.progress.fraction
Icon(
icon,
contentDescription = null,
modifier = Modifier.scale(scale)
)
}
},
dismissContent = {
val selected = currentItem.savedItemId == selectedItem?.savedItem?.savedItemId
SavedItemCard(
selected = selected,
savedItemViewModel = libraryViewModel,
savedItem = cardDataWithLabels,
onClickHandler = {
libraryViewModel.actionsMenuItemLiveData.postValue(null)
val activityClass =
if (currentItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
intent.putExtra("SAVED_ITEM_SLUG", currentItem.slug)
context.startActivity(intent)
},
actionHandler = {
libraryViewModel.handleSavedItemAction(
currentItem.savedItemId,
it
)
}
)
},
)
when {
swipeState.isDismissed(DismissDirection.EndToStart) -> Reset(state = swipeState)
swipeState.isDismissed(DismissDirection.StartToEnd) -> Reset(state = swipeState)
}
}
}
@ -273,6 +363,18 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) {
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun Reset(state: DismissState) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = state.dismissDirection) {
scope.launch {
state.reset()
state.animateTo(DismissValue.Default, FloatTweenSpec(duration= 0, delay = 0))
}
}
}
@Composable
private fun BottomSheetUI(content: @Composable () -> Unit) {
Box(

View file

@ -271,19 +271,13 @@ class LibraryViewModel @Inject constructor(
override fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
}
deleteSavedItem(itemID)
}
SavedItemAction.Archive -> {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
}
archiveSavedItem(itemID)
}
SavedItemAction.Unarchive -> {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
}
unarchiveSavedItem(itemID)
}
SavedItemAction.EditLabels -> {
currentItemLiveData.value = itemID
@ -297,6 +291,24 @@ class LibraryViewModel @Inject constructor(
actionsMenuItemLiveData.postValue(null)
}
fun deleteSavedItem(itemID: String) {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
}
}
fun archiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
}
}
fun unarchiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
}
}
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
viewModelScope.launch {
withContext(Dispatchers.IO) {

View file

@ -7,7 +7,7 @@ import com.google.gson.Gson
enum class WebFont(val displayText: String, val rawValue: String) {
INTER("Inter", "Inter"),
SYSTEM("System Default", "unset"),
SYSTEM("System Default", "system-ui"),
OPEN_DYSLEXIC("Open Dyslexic", "OpenDyslexic"),
MERRIWEATHER("Merriweather", "Merriweather"),
LORA("Lora", "Lora"),
@ -21,6 +21,8 @@ enum class WebFont(val displayText: String, val rawValue: String) {
ATKINSON_HYPERLEGIBLE("Atkinson Hyperlegible", "AtkinsonHyperlegible"),
SOURCE_SANS_PRO("Source Sans Pro", "SourceSansPro"),
IBM_PLEX_SANS("IBM Plex Sans", "IBMPlexSans"),
LITERATA("Literata", "Literata"),
FRAUNCES("Fraunces", "Fraunces"),
}
enum class ArticleContentStatus(val rawValue: String) {

View file

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1420"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42FF1B15271154A700B38C38"
BuildableName = "SafariExtension.appex"
BlueprintName = "SafariExtension (iOS)"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42FF1B1F271154A700B38C38"
BuildableName = "SafariExtension.appex"
BlueprintName = "SafariExtension (macOS)"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "0"
BundleIdentifier = "com.apple.Safari"
RemotePath = "/Applications/Safari.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1340"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42FF1B1F271154A700B38C38"
BuildableName = "SafariExtension.appex"
BlueprintName = "SafariExtension (macOS)"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "046C5CD126A3F89A00AC5349"
BuildableName = "ShareExtension-Mac.appex"
BlueprintName = "ShareExtension-Mac"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
debugAsWhichUser = "root"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<PathRunnable
runnableDebuggingMode = "0"
BundleIdentifier = "com.apple.Safari"
FilePath = "/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app">
</PathRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1340"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3D30FF2BDEFCACE5A2A13AD1"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "42E2BFB028E458E0007F29B2"
BuildableName = "AppStoreScreenshots.xctest"
BlueprintName = "AppStoreScreenshots"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -189,6 +189,24 @@
"version" : "1.0.2"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36",
"version" : "1.0.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "d029d9d39c87bed85b1c50adee7c41795261a192",
"version" : "1.0.6"
}
},
{
"identity" : "swift-graphql",
"kind" : "remoteSourceControl",

View file

@ -40,7 +40,8 @@ let package = Package(
"Valet",
.product(name: "SwiftGraphQL", package: "swift-graphql"),
"Models",
"Utils"
"Utils",
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms")
]
),
.testTarget(name: "ServicesTests", dependencies: ["Services"]),
@ -73,7 +74,8 @@ var dependencies: [Package.Dependency] {
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"),
.package(url: "https://github.com/exyte/PopupView.git", from: "2.6.0"),
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0"),
.package(url: "https://github.com/nathantannar4/Transmission", from: "1.0.1")
.package(url: "https://github.com/nathantannar4/Transmission", from: "1.0.1"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0")
]
// Comment out following line for macOS build
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.1.0"))

View file

@ -5,6 +5,7 @@ import SwiftUI
import Utils
import Views
@MainActor
public class ShareExtensionViewModel: ObservableObject {
@Published public var status: ShareExtensionStatus = .processing
@Published public var title: String = ""

View file

@ -124,6 +124,7 @@ public struct ShareExtensionView: View {
Spacer()
}
})
.buttonStyle(.plain)
.foregroundColor(hasNoteText ?
Color.appGrayTextContrast : Color.extensionTextSubtle
)
@ -140,8 +141,11 @@ public struct ShareExtensionView: View {
Text("Add Labels").font(Font.system(size: 12, weight: .medium)).tint(Color.white)
} icon: {
Image.label.resizable(resizingMode: .stretch).frame(width: 17, height: 17).tint(Color.white)
}.padding(.leading, 10).padding(.trailing, 12)
}
.foregroundColor(Color.white)
.padding(.leading, 10).padding(.trailing, 12)
})
.buttonStyle(.plain)
.frame(height: 28)
.background(Color.blue)
.cornerRadius(24)
@ -243,7 +247,7 @@ public struct ShareExtensionView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 15, height: 15)
}
}
}.buttonStyle(.plain)
}
var closeButton: some View {
@ -262,7 +266,7 @@ public struct ShareExtensionView: View {
.font(Font.title.weight(.bold))
.frame(width: 12, height: 12)
}
})
}).buttonStyle(.plain)
}
var titleBar: some View {
@ -337,12 +341,14 @@ public struct ShareExtensionView: View {
viewModel.handleReadNowAction(extensionContext: extensionContext)
}, label: {
Text("Read Now")
.foregroundColor(Color.white)
#if os(iOS)
.font(Font.system(size: 17, weight: .semibold))
.tint(Color.white)
.padding(20)
#endif
})
.buttonStyle(.plain)
#if os(iOS)
.frame(height: 50)
.background(Color.blue)

View file

@ -237,7 +237,7 @@ import Utils
}
.fullScreenCover(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
itemObjectID: viewModel.pdfItem.objectID,
viewModel: NotebookViewModel(item: viewModel.pdfItem.item),
hasHighlightMutations: $hasPerformedHighlightMutations,
onDeleteHighlight: { highlightId in
coordinator.removeHighlightFromPDF(highlightId: highlightId)

View file

@ -5,6 +5,7 @@ import OSLog
import Services
import Utils
@MainActor
public final class Services {
static let fetchTaskID = "app.omnivore.fetchLinkedItems"
static let secondsToWaitBeforeNextBackgroundRefresh: TimeInterval = isDebug ? 0 : 3600 // 1 hour
@ -84,6 +85,7 @@ public final class Services {
Task {
do {
let fetchedItemCount = try await services.dataService.fetchLinkedItemsBackgroundTask()
BadgeCountHandler.updateBadgeCount(dataService: services.dataService)
task.setTaskCompleted(success: true)
} catch {
EventTracker.track(

View file

@ -1,18 +1,32 @@
#if os(iOS)
import CoreData
import Foundation
import Models
import Services
import SwiftUI
import Transmission
import Views
// swiftlint:disable file_length type_body_length
public struct ExpandedAudioPlayer: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.dismiss) private var dismiss
let delete: (_: NSManagedObjectID) -> Void
let archive: (_: NSManagedObjectID) -> Void
let viewArticle: (_: NSManagedObjectID) -> Void
@State var showVoiceSheet = false
@State var tabIndex: Int = 0
@State var showLabelsModal = false
@State var showNotebookView = false
@State var showOperationToast = false
@State var operationStatus: OperationStatus = .none
@State var operationMessage: String?
var playPauseButtonImage: String {
switch audioController.state {
@ -54,70 +68,83 @@
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
)
.buttonStyle(.plain)
)
}
}
var closeButton: some View {
Button(
action: {
dismiss()
},
label: {
ZStack {
Circle()
.foregroundColor(Color.appGrayText)
.frame(width: 36, height: 36)
.opacity(0.1)
ZStack {
Circle()
.foregroundColor(Color.appGrayText)
.frame(width: 36, height: 36)
.opacity(0.1)
Image(systemName: "chevron.down")
.font(.appCallout)
.frame(width: 36, height: 36)
}
}
)
}
var menuButton: some View {
Menu {
Menu(String(format: "Playback Speed (%.1f×)", audioController.playbackRate)) {
playbackRateButton(rate: 0.8, title: "0.8×", selected: audioController.playbackRate == 0.8)
playbackRateButton(rate: 0.9, title: "0.9×", selected: audioController.playbackRate == 0.9)
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
playbackRateButton(rate: 1.3, title: "1.3×", selected: audioController.playbackRate == 1.3)
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
playbackRateButton(rate: 2.2, title: "2.2×", selected: audioController.playbackRate == 2.2)
playbackRateButton(rate: 2.5, title: "2.5×", selected: audioController.playbackRate == 2.5)
}
Button(action: { showVoiceSheet = true }, label: { Label("Change Voice", systemImage: "person.wave.2") })
Button(action: { viewArticle() }, label: { Label("View Article", systemImage: "book") })
Button(action: { audioController.stop() }, label: { Label("Stop", systemImage: "xmark.circle") })
Button(action: { dismiss() }, label: { Label(LocalText.dismissButton, systemImage: "arrow.down.to.line") })
} label: {
ZStack {
Circle()
.foregroundColor(Color.appGrayText)
.frame(width: 36, height: 36)
.opacity(0.1)
Image(systemName: "ellipsis")
.font(.appCallout)
.frame(width: 36, height: 36)
}
Image(systemName: "chevron.down")
.font(.appCallout)
.frame(width: 36, height: 36)
}
.padding(8)
}
func viewArticle() {
if let objectID = audioController.itemAudioProperties?.objectID {
NSNotification.pushReaderItem(objectID: objectID)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
dismiss()
var toolbarItems: some ToolbarContent {
ToolbarItemGroup(placement: .barTrailing) {
Button(
action: { performDelete() },
label: {
Image
.toolbarTrash
.foregroundColor(Color.toolbarItemForeground)
}
).padding(.trailing, 5)
if !(audioController.itemAudioProperties?.isArchived ?? false) {
Button(
action: { performArchive() },
label: {
if audioController.itemAudioProperties?.isArchived ?? false {
Image
.toolbarUnarchive
.foregroundColor(Color.toolbarItemForeground)
} else {
Image
.toolbarArchive
.foregroundColor(Color.toolbarItemForeground)
}
}
).padding(.trailing, 5)
}
// Menu(content: {
// Button(
// action: { performViewArticle() },
// label: {
// Text("View article")
// }
// )
// }, label: {
// Image
// .utilityMenu
// .foregroundColor(ThemeManager.currentTheme.toolbarColor)
// })
}
}
func performViewArticle() {
if let objectID = audioController.itemAudioProperties?.objectID {
viewArticle(objectID)
}
}
func performDelete() {
if let objectID = audioController.itemAudioProperties?.objectID {
delete(objectID)
}
}
func performArchive() {
if let objectID = audioController.itemAudioProperties?.objectID {
archive(objectID)
}
}
@ -297,6 +324,7 @@
.resizable()
.frame(width: 18, height: 18)
})
.buttonStyle(.plain)
.padding(.trailing, 32)
Button(
@ -307,6 +335,7 @@
.font(Font.title.weight(.light))
}
)
.buttonStyle(.plain)
.frame(width: 16, height: 16)
.padding(.trailing, 16)
.foregroundColor(.themeAudioPlayerGray)
@ -324,6 +353,7 @@
.font(Font.title.weight(.light))
}
)
.buttonStyle(.plain)
.frame(width: 16, height: 16)
.padding(.trailing, 32 - 4) // -4 to account for the menu touch padding
.foregroundColor(.themeAudioPlayerGray)
@ -343,6 +373,7 @@
Text("\(String(format: "%.1f", audioController.playbackRate))×")
.font(.appCaption)
})
.buttonStyle(.plain)
.padding(4)
Spacer()
@ -353,6 +384,13 @@
func playerContent(_: LinkedItemAudioProperties) -> some View {
ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) {
OperationToast(operationMessage: $operationMessage, showOperationToast: $showOperationToast, operationStatus: $operationStatus)
.offset(y: -90)
} label: {
EmptyView()
}.buttonStyle(.plain)
if audioController.playbackError {
Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote)
}
@ -416,9 +454,12 @@
NavigationView {
innerBody
.background(Color.themeDisabledBG)
.navigationTitle(audioController.itemAudioProperties?.title ?? LocalText.textToSpeechGeneric)
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Hide") }))
.navigationTitle("")
.navigationBarItems(leading: Button(action: { dismiss() }, label: { closeButton }))
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
.toolbar {
toolbarItems
}
}
}

View file

@ -55,7 +55,8 @@
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
).buttonStyle(.plain)
)
}
}
@ -78,6 +79,7 @@
}
}
)
.buttonStyle(.plain)
.background(Color.clear)
.buttonStyle(PlainButtonStyle())
}

View file

@ -38,6 +38,6 @@ struct TabBarButton: View {
.frame(width: 28, height: 28)
.foregroundColor(selectedTab == key ? Color.blue : Color.themeTabButtonColor)
.frame(maxWidth: .infinity)
})
}).buttonStyle(.plain)
}
}

View file

@ -0,0 +1,66 @@
import Foundation
import SwiftUI
import Views
struct CustomToolBar: View {
let isFollowing: Bool
let isArchived: Bool
let moveToInboxAction: () -> Void
let archiveAction: () -> Void
let unarchiveAction: () -> Void
let shareAction: () -> Void
let deleteAction: () -> Void
var barColor: Color {
switch ThemeManager.currentTheme {
case .apollo:
return Color.themeMiddleGray
case .dark:
return Color.themeMiddleGray
case .light:
return Color.themeDarkWhiteGray
case .sepia:
return Color.themeDarkWhiteGray
case .system:
return Color.isDarkMode ? Color.themeMiddleGray : Color.themeDarkWhiteGray
}
}
var body: some View {
VStack {
barColor
.frame(height: 0.5)
.frame(maxWidth: .infinity)
HStack(spacing: 0) {
if isFollowing {
ToolBarButton(image: Image.tabLibrary, action: moveToInboxAction)
} else if isArchived {
ToolBarButton(image: Image.toolbarUnarchive, action: unarchiveAction)
} else {
ToolBarButton(image: Image.toolbarArchive, action: archiveAction)
}
ToolBarButton(image: Image.toolbarShare, action: shareAction)
ToolBarButton(image: Image.toolbarTrash, action: deleteAction)
}
.padding(.top, 8)
}
.padding(.bottom, 35)
.background(ThemeManager.currentBgColor)
}
}
struct ToolBarButton: View {
let image: Image
let action: () -> Void
var body: some View {
Button(action: {
action()
}, label: {
image
.frame(width: 28, height: 28)
.foregroundColor(ThemeManager.currentTheme.toolbarColor)
.frame(maxWidth: .infinity)
}).buttonStyle(.plain)
}
}

View file

@ -0,0 +1,24 @@
import Models
import Services
import SwiftUI
import Utils
import Views
struct DeleteAccountView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var authenticator: Authenticator
public var body: some View {
VStack(alignment: .center) {
Text("Deleting account...")
ProgressView()
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
authenticator.logout(dataService: dataService, isAccountDeletion: true)
}
}
}
}

View file

@ -129,6 +129,7 @@
.padding()
}
)
.buttonStyle(.plain)
.frame(width: 16, height: 16, alignment: .center)
.onTapGesture { isContextMenuOpen = true }
}

View file

@ -9,16 +9,16 @@
typealias DeleteHighlightAction = (String) -> Void
struct NotebookView: View {
@StateObject var viewModel: NotebookViewModel
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = NotebookViewModel()
@State var showAnnotationModal = false
@State var errorAlertMessage: String?
@State var showErrorAlertMessage = false
@State var noteAnnotation = ""
let itemObjectID: NSManagedObjectID
@Binding var hasHighlightMutations: Bool
@State var setLabelsHighlight: Highlight?
@State var showShareView: Bool = false
@ -168,7 +168,7 @@
annotation: $noteAnnotation,
onSave: {
viewModel.updateNoteAnnotation(
itemObjectID: itemObjectID,
itemObjectID: viewModel.item.objectID,
annotation: noteAnnotation,
dataService: dataService
)
@ -224,7 +224,7 @@
#endif
}
.task {
viewModel.load(itemObjectID: itemObjectID, dataService: dataService)
viewModel.load(itemObjectID: viewModel.item.objectID, dataService: dataService)
}
}
}

View file

@ -21,9 +21,15 @@ struct NoteItemParams: Identifiable {
}
@MainActor final class NotebookViewModel: ObservableObject {
let item: Models.LibraryItem
@Published var noteItem: NoteItemParams?
@Published var highlightItems = [HighlightListItemParams]()
init(item: Models.LibraryItem) {
self.item = item
}
func load(itemObjectID: NSManagedObjectID, dataService: DataService) {
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? Models.LibraryItem {
loadHighlights(item: linkedItem)
@ -102,7 +108,17 @@ struct NoteItemParams: Identifiable {
}
func highlightsAsMarkdown() -> String {
highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
var buffer = "\(item.unwrappedTitle)\n"
if let author = item.author {
buffer += "by: \(author)\n"
}
if let url = item.pageURLString {
buffer += "\(url)\n"
}
if let noteText = item.noteText {
buffer += "\n\n\(noteText)\n\n"
}
return buffer + "\n\n" + highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
}
private func loadHighlights(item: Models.LibraryItem) {

View file

@ -0,0 +1,26 @@
import CoreData
import Foundation
import Models
import Services
import SwiftUI
@MainActor
enum BadgeCountHandler {
@AppStorage("Filters::badgeFilter") public static var badgeFilter = "in:inbox"
public static func updateBadgeCount(dataService: DataService) {
// if let badgeFilterId = badgeFilterId {
dataService.backgroundContext.performAndWait {
if let filter = Filter.lookup(byFilter: badgeFilter, inContext: dataService.backgroundContext),
let internalFilter = InternalFilter.make(from: [filter]).first
{
let fetchRequest: NSFetchRequest<Models.LibraryItem> = LibraryItem.fetchRequest()
fetchRequest.predicate = internalFilter.predicate
if let count = try? dataService.backgroundContext.count(for: fetchRequest) {
UIApplication.shared.applicationIconBadgeNumber = count
}
}
}
}
}

View file

@ -38,8 +38,9 @@ struct LibraryFeatureCardNavigationLink: View {
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
}
)
.buttonStyle(.plain)
.confirmationDialog("", isPresented: $showFeatureActions) {
if FeaturedItemFilter(rawValue: viewModel.featureFilter) == .pinned {
if FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) == .pinned {
Button("Unpin", action: {
viewModel.unpinItem(dataService: dataService, item: item)
})
@ -53,7 +54,7 @@ struct LibraryFeatureCardNavigationLink: View {
Button("Remove", action: {
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
})
if FeaturedItemFilter(rawValue: viewModel.featureFilter) != .pinned {
if FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) != .pinned {
Button("Mark Read", action: {
viewModel.markRead(dataService: dataService, item: item)
})

View file

@ -9,24 +9,28 @@ import Utils
import Views
@MainActor final class LibraryItemFetcher: NSObject, ObservableObject {
let folder = "inbox"
@Published var items = [Models.LibraryItem]()
var itemsPublisher: Published<[Models.LibraryItem]>.Publisher { $items }
@Published var featureItems = [Models.LibraryItem]()
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
var limit = 6
var cursor: String?
var totalCount: Int?
// These are used to make sure we handle search result
// responses in the right order
var searchIdx = 0
var receivedIdx = 0
var syncCursor: String?
func setItems(_: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
func setItems(_ context: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
self.items = items
if let filter = FeaturedItemFilter(rawValue: featureFilter) {
updateFeatureFilter(context: context, filter: filter)
}
}
func loadCurrentViewer(dataService: DataService) async {
@ -45,37 +49,7 @@ import Views
}
}
func syncItems(dataService: DataService) async {
let syncStart = Date.now
let lastSyncDate = dataService.lastItemSyncTime
try? await dataService.syncOfflineItemsWithServerIfNeeded()
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
cursor: nil)
syncCursor = syncResult?.cursor
if let syncResult = syncResult, syncResult.hasMore {
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
// do nothing
}
} else {
dataService.lastItemSyncTime = syncStart
}
// If possible start prefetching new pages in the background
if
let itemIDs = syncResult?.updatedItemIDs,
let username = dataService.currentViewer?.username,
!itemIDs.isEmpty
{
Task.detached(priority: .background) {
await dataService.prefetchPages(itemIDs: itemIDs, username: username)
}
}
}
func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
func loadSearchQuery(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool, loadCursor: String? = nil) async {
let thisSearchIdx = searchIdx
searchIdx += 1
@ -83,11 +57,17 @@ import Views
return
}
let queryResult = try? await dataService.loadLinkedItems(
limit: 10,
searchQuery: searchQuery(filterState),
cursor: isRefresh ? nil : cursor
)
var queryResult: LinkedItemQueryResult?
do {
queryResult = try await dataService.loadLinkedItems(
limit: limit,
searchQuery: searchQuery(filterState),
cursor: isRefresh ? nil : loadCursor ?? cursor
)
} catch {
print("SYNCCURSOR ERROR loading library items: ", error)
}
if let appliedFilter = filterState.appliedFilter, let queryResult = queryResult {
let newItems: [Models.LibraryItem] = {
@ -110,37 +90,63 @@ import Views
}
receivedIdx = thisSearchIdx
limit = 15 // Once we have one successful fetch we increase the limit
cursor = queryResult.cursor
if let username = dataService.currentViewer?.username {
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
}
totalCount = queryResult.totalCount
} else {
updateFetchController(dataService: dataService, filterState: filterState)
}
}
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool, forceRemote: Bool = false) async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
group.addTask { await self.loadLabels(dataService: dataService) }
group.addTask { await self.syncItems(dataService: dataService) }
group.addTask { await self.updateFetchController(dataService: dataService, filterState: filterState) }
await group.waitForAll()
}
if let appliedFilter = filterState.appliedFilter {
let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
let shouldRemoteSearch = forceRemote || items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
if shouldRemoteSearch {
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
} else {
updateFetchController(dataService: dataService, filterState: filterState)
}
}
NotificationCenter.default.post(name: NSNotification.PerformSync, object: nil, userInfo: nil)
BadgeCountHandler.updateBadgeCount(dataService: dataService)
}
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
func loadNewItems(dataService: DataService, filterState: FetcherFilterState) async {
let lastSyncDate = dataService.lastItemSyncTime
_ = try? await dataService.syncLinkedItems(since: lastSyncDate, cursor: nil)
updateFetchController(dataService: dataService, filterState: filterState)
}
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, loadCursor: String? = nil) async {
var useCursor = loadCursor
if let appliedFilter = filterState.appliedFilter, appliedFilter.shouldRemoteSearch {
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
let idx = max(items.count, 0)
// If the cursor is greater than the index we want to use the cursor instead
// this can occur if there are non-contiguous items in our list causing older
// items to be synced back into those "holes" in the list
if let cursor = cursor, let currentCursor = Int(cursor) {
if currentCursor > idx {
useCursor = currentCursor.description
}
}
await loadSearchQuery(
dataService: dataService,
filterState: filterState,
isRefresh: false,
loadCursor: useCursor ?? idx.description
)
}
}
@ -204,7 +210,6 @@ import Views
sectionNameKeyPath: nil,
cacheName: nil
)
guard let fetchedResultsController = fetchedResultsController else {
return
}
@ -216,10 +221,14 @@ import Views
private func searchQuery(_ filterState: FetcherFilterState) -> String {
let sort = LinkedItemSort(rawValue: filterState.appliedSort) ?? .newest
var query = sort.queryString
var query = ""
if let queryString = filterState.appliedFilter?.filter {
query = "\(queryString) \(sort.queryString)"
query = "\(queryString)"
}
if !query.contains("sort:") {
query = "\(query) \(sort.queryString)"
}
if !filterState.searchTerm.isEmpty {
@ -251,6 +260,28 @@ import Views
return query
}
func refreshFeatureItems(dataService: DataService) {
if let featureFilter = FeaturedItemFilter(rawValue: self.featureFilter) {
updateFeatureFilter(context: dataService.viewContext, filter: featureFilter)
}
}
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
if let filter = filter {
Task {
featureFilter = filter.rawValue
featureItems = await loadFeatureItems(
context: context,
predicate: filter.predicate,
sort: filter.sortDescriptor
)
}
} else {
featureItems = []
}
}
}
extension LibraryItemFetcher: NSFetchedResultsControllerDelegate {

View file

@ -14,7 +14,7 @@ struct MacFeedCardNavigationLink: View {
var body: some View {
ZStack {
LibraryItemCard(item: item, viewer: dataService.currentViewer)
LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer)
NavigationLink(destination: LinkItemDetailView(
linkedItemObjectID: item.objectID,
isPDF: item.isPDF
@ -22,23 +22,19 @@ struct MacFeedCardNavigationLink: View {
EmptyView()
}).opacity(0)
}
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
}
}
struct FeedCardNavigationLink: View {
struct LibraryItemListNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
let item: Models.LibraryItem
let isInMultiSelectMode: Bool
@ObservedObject var item: Models.LibraryItem
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
ZStack {
LibraryItemCard(item: item, viewer: dataService.currentViewer)
LibraryItemCard(item: LibraryItemData.make(from: item), viewer: dataService.currentViewer)
PresentationLink(
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
@ -56,23 +52,16 @@ struct FeedCardNavigationLink: View {
}
)
}
.task {
await viewModel.itemAppeared(item: item, dataService: dataService)
}
}
}
struct GridCardNavigationLink: View {
struct LibraryItemGridCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@State private var scale = 1.0
let item: Models.LibraryItem
let actionHandler: (GridCardAction) -> Void
@Binding var isContextMenuOpen: Bool
@ObservedObject var item: Models.LibraryItem
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
@ -89,21 +78,12 @@ struct GridCardNavigationLink: View {
isPDF: item.isPDF
)
}, label: {
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
GridCard(item: LibraryItemData.make(from: item))
}
)
.task {
await viewModel.itemAppeared(item: item, dataService: dataService)
}
.buttonStyle(.plain)
.aspectRatio(1.0, contentMode: .fill)
.background(
Color.secondarySystemGroupedBackground
.onTapGesture {
if isContextMenuOpen {
isContextMenuOpen = false
}
}
)
.background(Color.systemBackground)
.cornerRadius(6)
}
}

View file

@ -0,0 +1,27 @@
import Foundation
import Services
class LibrarySyncManager {
var syncCursor: String?
func syncUpdates(dataService: DataService) async {
let syncStart = Date.now
let lastSyncDate = dataService.lastItemSyncTime
if lastSyncDate.timeIntervalSinceNow > -4 {
print("skipping sync as last sync was too recent: ", lastSyncDate)
return
}
try? await dataService.syncOfflineItemsWithServerIfNeeded()
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, cursor: nil)
if let syncResult = syncResult, syncResult.hasMore {
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
// do nothing
}
} else {
dataService.lastItemSyncTime = syncStart
}
}
}

View file

@ -0,0 +1,70 @@
// swiftlint:disable line_length
import Foundation
import Models
import Services
import SwiftUI
import Views
public struct FollowingViewModal: View {
@Environment(\.dismiss) private var dismiss
let message: String = """
We've created a new place for all your newsletters and feeds called Following. You can control the destination of
new items by changing the destination for your subscriptions in the Subscriptions view of your settings. By default
your existing newsletters will go into your library and your existing feeds will go into Following.
From the library you can swipe items left to right to move them into your library. In the reader view you can tap the
bookmark icon on the toolbar to move items into your library.
If you don't need the following tab you can disable it from the filters view in your settings.
- [Learn more about the following](https://docs.omnivore.app/using/following.html)
- [Tell your friends about Omnivore](https://omnivore.app/about)
"""
var closeButton: some View {
Button(action: {
dismiss()
}, label: {
ZStack {
Circle()
.foregroundColor(Color.circleButtonBackground)
.frame(width: 30, height: 30)
Image(systemName: "xmark")
.resizable(resizingMode: Image.ResizingMode.stretch)
.foregroundColor(Color.circleButtonForeground)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.bold))
.frame(width: 12, height: 12)
}
})
}
public var body: some View {
HStack {
Text("Your new Following tab")
.font(Font.system(size: 20, weight: .bold))
Spacer()
closeButton
}
.padding(.top, 16)
.padding(.horizontal, 16)
List {
Section {
let parsedMessage = try? AttributedString(markdown: message,
options: .init(interpretedSyntax: .inlineOnly))
Text(parsedMessage ?? "")
.multilineTextAlignment(.leading)
.foregroundColor(Color.appGrayTextContrast)
.accentColor(.blue)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
}
}
}
}

View file

@ -7,6 +7,163 @@ import UserNotifications
import Utils
import Views
struct FiltersHeader: View {
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
// if UIDevice.isIPhone {
Menu(
content: {
ForEach(viewModel.filters.filter { $0.folder == viewModel.currentFolder }) { filter in
Button(filter.name, action: {
viewModel.appliedFilter = filter
})
}
},
label: {
TextChipButton.makeMenuButton(
title: viewModel.appliedFilter?.name ?? "-",
color: .systemGray6
)
}
).buttonStyle(.plain)
// }
}
Menu(
content: {
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
}
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort",
color: .systemGray6
)
}
).buttonStyle(.plain)
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true })
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
ForEach(viewModel.negatedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
viewModel.negatedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
}
}
}
.padding(.top, 0)
.padding(.bottom, 10)
.padding(.leading, 15)
.listRowSpacing(0)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.frame(maxWidth: .infinity, minHeight: 38)
.background(Color.systemBackground)
.dynamicTypeSize(.small ... .accessibility1)
}
}
struct EmptyState: View {
@ObservedObject var viewModel: HomeFeedViewModel
@EnvironmentObject var dataService: DataService
@State var showSendNewslettersAlert = false
var followingEmptyState: some View {
VStack(alignment: .center, spacing: 20) {
if viewModel.stopUsingFollowingPrimer {
VStack(spacing: 10) {
Image.relaxedSlothLight
Text("You are all caught up.").foregroundColor(Color.extensionTextSubtle)
Button(action: {
Task {
await viewModel.loadItems(dataService: dataService, isRefresh: true, loadingBarStyle: .simple)
}
}, label: { Text("Refresh").bold() })
.foregroundColor(Color.blue)
}
} else {
Text("You don't have any Feed items.")
.font(Font.system(size: 18, weight: .bold))
Text("Add an RSS/Atom feed")
.foregroundColor(Color.blue)
.onTapGesture {
viewModel.showAddFeedView = true
}
Text("Send your newsletters to following")
.foregroundColor(Color.blue)
.onTapGesture {
showSendNewslettersAlert = true
}
Text("Hide the Following tab")
.foregroundColor(Color.blue)
.onTapGesture {
viewModel.showHideFollowingAlert = true
}
}
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
.alert("Update newsletter destination", isPresented: $showSendNewslettersAlert, actions: {
Button(action: {
Task {
await viewModel.modifyingNewsletterDestinationToFollowing(dataService: dataService)
}
}, label: { Text("OK") })
Button(LocalText.cancelGeneric, role: .cancel) { showSendNewslettersAlert = false }
}, message: {
// swiftlint:disable:next line_length
Text("Your email address destination folders will be modified to send to this tab.\n\nAll new newsletters will appear here. You can modify the destination for each individual email address and subscription in your settings.")
})
}
var body: some View {
if viewModel.isModifyingNewsletterDestination {
return AnyView(
VStack {
Text("Modifying newsletter destinations...")
ProgressView()
}.frame(maxWidth: .infinity, maxHeight: .infinity)
)
} else if viewModel.currentFolder == "following" {
return AnyView(followingEmptyState)
} else {
return AnyView(Group {
Spacer()
VStack(alignment: .center, spacing: 20) {
Text("No results found for this query")
.font(Font.system(size: 18, weight: .bold))
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
Spacer()
})
}
}
}
struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0
@ -28,18 +185,17 @@ struct AnimatingCellHeight: AnimatableModifier {
struct HomeFeedContainerView: View {
@State var hasHighlightMutations = false
@State var searchPresented = false
@State var addLinkPresented = false
@State var showAddLinkView = false
@State var isListScrolled = false
@State var listTitle = ""
@State var isEditMode: EditMode = .inactive
@State var showOpenAIVoices = false
@State var showExpandedAudioPlayer = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
@AppStorage(UserDefaultKey.openAIPrimerDisplayed.rawValue) var openAIPrimerDisplayed = false
@ObservedObject var viewModel: HomeFeedViewModel
@State private var selection = Set<String>()
@ -54,7 +210,7 @@ struct AnimatingCellHeight: AnimatableModifier {
var showFeatureCards: Bool {
isEditMode == .inactive &&
viewModel.listConfig.hasFeatureCards &&
(viewModel.currentListConfig?.hasFeatureCards ?? false) &&
!viewModel.hideFeatureSection &&
viewModel.fetcher.items.count > 0 &&
viewModel.searchTerm.isEmpty &&
@ -123,25 +279,46 @@ struct AnimatingCellHeight: AnimatableModifier {
LinkedItemMetadataEditView(item: item)
}
.sheet(item: $viewModel.itemForHighlightsView) { item in
NotebookView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations)
NotebookView(viewModel: NotebookViewModel(item: item), hasHighlightMutations: $hasHighlightMutations)
}
.sheet(isPresented: $viewModel.showAddFeedView) {
NavigationView {
LibraryAddFeedView(dismiss: {
viewModel.showAddFeedView = false
}, toastOperationHandler: nil)
}
}
.sheet(isPresented: $showAddLinkView) {
NavigationView {
LibraryAddLinkView()
}
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer()
}
.sheet(isPresented: $showOpenAIVoices) {
OpenAIVoicesModal(audioController: audioController)
}
.onAppear {
if !openAIPrimerDisplayed, !Voices.isOpenAIVoice(self.audioController.currentVoice) {
showOpenAIVoices = true
openAIPrimerDisplayed = true
}
ExpandedAudioPlayer(
delete: {
showExpandedAudioPlayer = false
audioController.stop()
viewModel.removeLibraryItem(dataService: dataService, objectID: $0)
},
archive: {
showExpandedAudioPlayer = false
audioController.stop()
viewModel.setLinkArchived(dataService: dataService, objectID: $0, archived: true)
},
viewArticle: { itemID in
if let article = try? dataService.viewContext.existingObject(with: itemID) as? Models.LibraryItem {
viewModel.pushFeedItem(item: article)
}
}
)
}
.toolbar {
toolbarItems
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
loadItems(isRefresh: false)
Task {
await viewModel.loadNewItems(dataService: dataService)
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in
guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return }
@ -151,114 +328,114 @@ struct AnimatingCellHeight: AnimatableModifier {
viewModel.selectedItem = linkedItem
viewModel.linkIsActive = true
}
.onOpenURL { url in
viewModel.linkRequest = nil
if let deepLink = DeepLink.make(from: url) {
switch deepLink {
case let .search(query):
viewModel.searchTerm = query
case let .savedSearch(named):
if let filter = viewModel.findFilter(dataService, named: named) {
viewModel.appliedFilter = filter
}
case let .webAppLinkRequest(requestID):
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withoutAnimation {
viewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
viewModel.presentWebContainer = true
}
}
}
}
}
.fullScreenCover(isPresented: $searchPresented) {
LibrarySearchView(homeFeedViewModel: self.viewModel)
}
.sheet(isPresented: $addLinkPresented) {
NavigationView {
LibraryAddLinkView()
}
}
.task {
if viewModel.fetcher.items.isEmpty {
loadItems(isRefresh: false)
}
await viewModel.loadFilters(dataService: dataService)
if viewModel.appliedFilter == nil {
viewModel.setDefaultFilter()
}
// Once the user has seen at least one following item we stop displaying the
// initial help view
if viewModel.currentFolder == "following", viewModel.fetcher.items.count > 0 {
viewModel.stopUsingFollowingPrimer = true
}
}
.environment(\.editMode, self.$isEditMode)
.navigationBarTitleDisplayMode(.inline)
}
var toolbarItems: some ToolbarContent {
Group {
ToolbarItem(placement: .barLeading) {
VStack(alignment: .leading) {
let showDate = isListScrolled && !listTitle.isEmpty
if let title = viewModel.appliedFilter?.name {
Text(title)
.font(Font.system(size: showDate ? 10 : 24, weight: .semibold))
if showDate, prefersListLayout, isListScrolled || !showFeatureCards {
Text(listTitle)
.font(Font.system(size: 15, weight: .regular))
.foregroundColor(Color.appGrayText)
ToolbarItemGroup(placement: .barLeading) {
if UIDevice.isIPhone || horizontalSizeClass != .compact {
VStack(alignment: .leading) {
let showDate = isListScrolled && !listTitle.isEmpty
if let title = viewModel.appliedFilter?.name {
Text(title)
.font(Font.system(size: showDate ? 10 : 24, weight: .semibold))
if showDate, prefersListLayout, isListScrolled || !showFeatureCards {
Text(listTitle)
.font(Font.system(size: 15, weight: .regular))
.foregroundColor(Color.appGrayText)
}
}
}
.frame(maxWidth: .infinity, alignment: .bottomLeading)
}
.frame(maxWidth: .infinity, alignment: .bottomLeading)
}
ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) {
ToolbarItemGroup(placement: .barTrailing) {
if prefersListLayout {
Button(
action: { isEditMode = isEditMode == .active ? .inactive : .active },
label: {
Image
.selectMultiple
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
}
if enableGrid {
Button(
action: { prefersListLayout.toggle() },
label: {
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
Image(systemName: prefersListLayout ? "square.grid.2x2" : "list.bullet")
.foregroundColor(Color.toolbarItemForeground)
}
)
).buttonStyle(.plain)
}
}
ToolbarItem(placement: .barTrailing) {
Button(
action: { searchPresented = true },
label: {
Image.magnifyingGlass
.foregroundColor(Color.appGrayTextContrast)
}
)
}
ToolbarItem(placement: .barTrailing) {
Button(
action: { isEditMode = isEditMode == .active ? .inactive : .active },
label: {
Image.selectMultiple
.foregroundColor(Color.appGrayTextContrast)
}
)
}
ToolbarItem(placement: .barTrailing) {
if viewModel.folder == "inbox" {
Button(
action: { addLinkPresented = true },
label: {
Image.addLink
.foregroundColor(Color.appGrayTextContrast)
action: {
if viewModel.currentFolder == "inbox" {
showAddLinkView = true
} else if viewModel.currentFolder == "following" {
viewModel.showAddFeedView = true
}
)
} else {
EmptyView()
}
},
label: {
Image.addLink
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
Button(
action: {
searchPresented = true
isEditMode = .inactive
},
label: {
Image
.magnifyingGlass
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
}
ToolbarItemGroup(placement: .bottomBar) {
if isEditMode == .active {
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
isEditMode = .inactive
}, label: { Image(systemName: "archivebox") })
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .delete, items: Array(selection))
isEditMode = .inactive
}, label: { Image(systemName: "trash") })
.alignmentGuide(HorizontalAlignment.center, computeValue: { dim in
dim[HorizontalAlignment.center]
})
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
isEditMode = .inactive
}, label: { Image(systemName: "archivebox") })
.alignmentGuide(HorizontalAlignment.center, computeValue: { dim in
dim[HorizontalAlignment.center]
})
Spacer()
Text("\(selection.count) selected").font(.footnote)
Spacer()
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
}
}
@ -281,7 +458,7 @@ struct AnimatingCellHeight: AnimatableModifier {
var body: some View {
VStack(spacing: 0) {
if let linkRequest = viewModel.linkRequest {
if let linkRequest = viewModel.linkRequest, viewModel.currentListConfig?.hasReadNowSection ?? false {
PresentationLink(
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
@ -357,9 +534,7 @@ struct AnimatingCellHeight: AnimatableModifier {
@Binding var isListScrolled: Bool
@Binding var prefersListLayout: Bool
@Binding var isEditMode: EditMode
@State private var showAddFeedView = false
@State private var showHideFeatureAlert = false
@State private var showHideFollowingAlert = false
@Binding var selection: Set<String>
@ObservedObject var viewModel: HomeFeedViewModel
@ -371,70 +546,12 @@ struct AnimatingCellHeight: AnimatableModifier {
@ObservedObject var networkMonitor = NetworkMonitor()
var filtersHeader: some View {
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
Menu(
content: {
ForEach(viewModel.filters) { filter in
Button(filter.name, action: {
viewModel.appliedFilter = filter
})
}
},
label: {
TextChipButton.makeMenuButton(
title: viewModel.appliedFilter?.name ?? "-",
color: .systemGray6
)
}
)
}
Menu(
content: {
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
}
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort",
color: .systemGray6
)
}
)
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true })
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
ForEach(viewModel.negatedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
viewModel.negatedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
}
}
}
.padding(.top, 0)
.padding(.bottom, 10)
.padding(.leading, 15)
.listRowSpacing(0)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.frame(maxWidth: .infinity, minHeight: 38)
.background(Color.systemBackground)
.overlay(Rectangle()
.padding(.leading, 15)
.frame(width: nil, height: 0.5, alignment: .bottom)
.foregroundColor(isListScrolled ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
.dynamicTypeSize(.small ... .accessibility1)
FiltersHeader(viewModel: viewModel)
.overlay(Rectangle()
.padding(.leading, 15)
.frame(width: nil, height: 0.5, alignment: .bottom)
.foregroundColor(isListScrolled && UIDevice.isIPhone ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
.dynamicTypeSize(.small ... .accessibility1)
}
func menuItems(for item: Models.LibraryItem) -> some View {
@ -450,17 +567,17 @@ struct AnimatingCellHeight: AnimatableModifier {
HStack {
Menu(content: {
Button(action: {
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
}, label: {
Text("Continue Reading")
})
Button(action: {
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}, label: {
Text("Pinned")
})
Button(action: {
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
}, label: {
Text("Newsletters")
})
@ -474,7 +591,7 @@ struct AnimatingCellHeight: AnimatableModifier {
HStack(alignment: .center) {
Image(systemName: "line.3.horizontal.decrease")
.font(Font.system(size: 13, weight: .regular))
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).title)
Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).title)
.font(Font.system(size: 13, weight: .medium))
}
.tint(Color(hex: "#007AFF"))
@ -483,7 +600,7 @@ struct AnimatingCellHeight: AnimatableModifier {
.background(Color(hex: "#007AFF")?.opacity(0.1))
.cornerRadius(5)
}.frame(maxWidth: .infinity, alignment: .leading)
})
}).buttonStyle(.plain)
Spacer()
}
.padding(.top, 10)
@ -491,17 +608,17 @@ struct AnimatingCellHeight: AnimatableModifier {
GeometryReader { geo in
ScrollView(.horizontal, showsIndicators: false) {
if viewModel.featureItems.count > 0 {
if viewModel.fetcher.featureItems.count > 0 {
HStack(alignment: .top, spacing: 15) {
Spacer(minLength: 1).frame(width: 1)
ForEach(viewModel.featureItems) { item in
ForEach(viewModel.fetcher.featureItems) { item in
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
}
Spacer(minLength: 1).frame(width: 1)
}
.padding(.top, 0)
} else {
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).emptyMessage)
Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).emptyMessage)
.padding(.horizontal, UIDevice.isIPad ? 20 : 10)
.font(Font.system(size: 14, weight: .regular))
.foregroundColor(Color(hex: "#898989"))
@ -578,7 +695,7 @@ struct AnimatingCellHeight: AnimatableModifier {
}
var redactedItems: some View {
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.unwrappedID) { _, item in
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
LibraryItemCard(item: item, viewer: dataService.currentViewer)
.listRowSeparatorTint(Color.thBorderColor)
@ -586,53 +703,12 @@ struct AnimatingCellHeight: AnimatableModifier {
}.redacted(reason: .placeholder)
}
var emptyState: some View {
if viewModel.folder == "following" {
return AnyView(
VStack(alignment: .center, spacing: 20) {
Text("You don't have any Feed items.")
.font(Font.system(size: 18, weight: .bold))
Text("Add an RSS/Atom feed")
.foregroundColor(Color.blue)
.onTapGesture {
showAddFeedView = true
}
Text("Hide the Following tab")
.foregroundColor(Color.blue)
.onTapGesture {
showHideFollowingAlert = true
}
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
)
} else {
return AnyView(Group {
Spacer()
VStack(alignment: .center, spacing: 20) {
Text("No results found for this query")
.font(Font.system(size: 18, weight: .bold))
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
Spacer()
})
}
}
var listItems: some View {
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { idx, item in
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
FeedCardNavigationLink(
LibraryItemListNavigationLink(
item: item,
isInMultiSelectMode: viewModel.isInMultiSelectMode,
viewModel: viewModel
)
.background(GeometryReader { geometry in
@ -652,13 +728,24 @@ struct AnimatingCellHeight: AnimatableModifier {
menuItems(for: item)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
ForEach(viewModel.listConfig.leadingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
if let listConfig = viewModel.currentListConfig {
ForEach(listConfig.leadingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
ForEach(viewModel.listConfig.trailingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
if let listConfig = viewModel.currentListConfig {
ForEach(listConfig.trailingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}
}
.onAppear {
if idx >= viewModel.fetcher.items.count - 5 {
Task {
await viewModel.loadMore(dataService: dataService)
}
}
}
}
@ -703,10 +790,18 @@ struct AnimatingCellHeight: AnimatableModifier {
}
}
if viewModel.showLoadingBar {
if viewModel.showLoadingBar == .redacted {
redactedItems
} else if viewModel.showLoadingBar == .simple {
VStack {
ProgressView()
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
.listRowSeparator(.hidden, edges: .all)
} else if viewModel.fetcher.items.isEmpty {
emptyState
EmptyState(viewModel: viewModel)
.listRowSeparator(.hidden, edges: .all)
} else {
listItems
@ -715,6 +810,7 @@ struct AnimatingCellHeight: AnimatableModifier {
}, header: {
filtersHeader
})
BottomView(viewModel: viewModel)
}
.padding(0)
.listStyle(.plain)
@ -733,11 +829,6 @@ struct AnimatingCellHeight: AnimatableModifier {
shouldScrollToTop = true
}
}
.sheet(isPresented: $showAddFeedView) {
NavigationView {
LibraryAddFeedView()
}
}
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
isPresented: $showHideFeatureAlert) {
Button("OK", role: .destructive) {
@ -746,11 +837,11 @@ struct AnimatingCellHeight: AnimatableModifier {
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
}
.alert("The Following tab will be hidden. You can add it back from the filter settings in your profile.",
isPresented: $showHideFollowingAlert) {
isPresented: $viewModel.showHideFollowingAlert) {
Button("OK", role: .destructive) {
viewModel.hideFollowingTab = true
}
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFollowingAlert = false }
Button(LocalText.cancelGeneric, role: .cancel) { viewModel.showHideFollowingAlert = false }
}
.introspectNavigationController { nav in
nav.navigationBar.shadowImage = UIImage()
@ -837,59 +928,16 @@ struct AnimatingCellHeight: AnimatableModifier {
}
var filtersHeader: some View {
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
Menu(
content: {
ForEach(viewModel.filters, id: \.self) { filter in
Button(filter.name, action: { viewModel.appliedFilter = filter })
}
},
label: {
TextChipButton.makeMenuButton(
title: viewModel.appliedFilter?.name ?? "-",
color: .systemGray6
)
}
)
}
Menu(
content: {
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
}
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort",
color: .systemGray6
)
}
)
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { viewModel.showLabelsSheet = true })
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
ForEach(viewModel.negatedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
viewModel.negatedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
}
.padding(0)
}
.listRowSeparator(.hidden)
}
.dynamicTypeSize(.small ... .accessibility1)
FiltersHeader(viewModel: viewModel)
.overlay(Rectangle()
.padding(.leading, 15)
.frame(width: nil, height: 0.5, alignment: .bottom)
.foregroundColor(isListScrolled && UIDevice.isIPhone ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
.dynamicTypeSize(.small ... .accessibility1)
}
func menuItems(for item: Models.LibraryItem) -> some View {
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
}
var body: some View {
@ -911,23 +959,39 @@ struct AnimatingCellHeight: AnimatableModifier {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
if viewModel.showLoadingBar {
ForEach(fakeLibraryItems(dataService: dataService)) { item in
GridCardNavigationLink(
item: item,
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
isContextMenuOpen: $isContextMenuOpen,
viewModel: viewModel
)
if viewModel.showLoadingBar == .redacted {
ForEach(fakeLibraryItems(dataService: dataService), id: \.id) { item in
GridCard(item: item)
.aspectRatio(1.0, contentMode: .fill)
.background(Color.systemBackground)
.cornerRadius(6)
}.redacted(reason: .placeholder)
} else if viewModel.showLoadingBar == .simple {
VStack {
ProgressView()
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
.listRowSeparator(.hidden, edges: .all)
} else {
ForEach(viewModel.fetcher.items) { item in
GridCardNavigationLink(
item: item,
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
isContextMenuOpen: $isContextMenuOpen,
viewModel: viewModel
)
if !viewModel.fetcher.items.isEmpty {
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.id) { idx, item in
LibraryItemGridCardNavigationLink(
item: item,
viewModel: viewModel
)
.contextMenu {
menuItems(for: item)
}
.onAppear {
if idx >= viewModel.fetcher.items.count - 5 {
Task {
await viewModel.loadMore(dataService: dataService)
}
}
}
}
}
}
Spacer()
@ -936,7 +1000,7 @@ struct AnimatingCellHeight: AnimatableModifier {
.padding()
.background(
GeometryReader {
Color(.systemGroupedBackground).preference(
Color(.systemBackground).preference(
key: ScrollViewOffsetPreferenceKey.self,
value: $0.frame(in: .global).origin.y
)
@ -950,11 +1014,21 @@ struct AnimatingCellHeight: AnimatableModifier {
}
}
if viewModel.fetcher.items.isEmpty {
EmptyState(viewModel: viewModel)
} else {
HStack {
Spacer()
BottomView(viewModel: viewModel).frame(maxWidth: 300)
Spacer()
}
}
if viewModel.fetcher.items.isEmpty, viewModel.isLoading {
LoadingSection()
}
}
.background(Color(.systemGroupedBackground))
.background(Color(.systemBackground))
Spacer()
}
@ -974,7 +1048,7 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey {
#if os(iOS)
// Allows us to present a sheet without animation
// Used to configure full screen modal view coming from share extension read now button action
private extension View {
public extension View {
func withoutAnimation(_ completion: @escaping () -> Void) {
UIView.setAnimationsEnabled(false)
completion()
@ -1009,20 +1083,75 @@ struct LinkDestination: View {
}
}
func fakeLibraryItems(dataService: DataService) -> [Models.LibraryItem] {
let temp = Models.LibraryItem(context: dataService.viewContext)
temp.id = UUID().uuidString
temp.wordsCount = 100
temp.author = "the author"
temp.siteName = "omnivore dot app"
temp.title = "This is a temporary title for a fake item"
temp.highlights = []
temp.imageURLString = "https://localhost/"
return Array(
repeatElement(temp, count: 20)
.map { item in
item.id = UUID().uuidString
return item
}
)
func fakeLibraryItems(dataService _: DataService) -> [LibraryItemData] {
Array(
repeatElement(0, count: 20)
.map { _ in
LibraryItemData(
id: UUID().uuidString,
title: "fake title that is kind of long so it looks better",
pageURLString: "",
isArchived: false,
author: "fake author",
deepLink: nil,
hasLabels: false,
noteText: nil,
readingProgress: 10,
wordsCount: 10,
isPDF: false,
highlights: nil,
sortedLabels: [],
imageURL: nil,
publisherDisplayName: "fake publisher",
descriptionText: "This is a fake description"
)
})
}
struct BottomView: View {
@ObservedObject var viewModel: HomeFeedViewModel
@EnvironmentObject var dataService: DataService
@State var autoLoading = false
var body: some View {
innerBody
.listRowSeparator(.hidden)
.onAppear {
Task {
autoLoading = true
await viewModel.loadMore(dataService: dataService)
autoLoading = false
}
}
}
var innerBody: some View {
if viewModel.fetcher.items.count < 3 {
AnyView(Color.clear)
} else {
AnyView(HStack {
if let totalCount = viewModel.fetcher.totalCount {
Text("\(viewModel.fetcher.items.count) of \(totalCount) items.")
}
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Button(action: {
Task {
await viewModel.loadMore(dataService: dataService)
}
}, label: {
if let totalCount = viewModel.fetcher.totalCount, viewModel.fetcher.items.count >= totalCount {
Text("Check for more")
} else {
Text("Fetch more")
}
})
.foregroundColor(Color.blue)
}
}.padding(10))
}
}
}

View file

@ -68,7 +68,7 @@ import Views
action: {
addLinkPresented = true
},
label: { Label("Add Link", systemImage: "plus") }
label: { Label("Add link", systemImage: "plus") }
)
}

View file

@ -5,33 +5,32 @@ import SwiftUI
import Utils
import Views
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
let folder: String
let fetcher: LibraryItemFetcher
let listConfig: LibraryListConfig
enum LoadingBarStyle {
case none
case redacted
case simple
}
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
let filterKey: String
@ObservedObject var fetcher: LibraryItemFetcher
let folderConfigs: [String: LibraryListConfig]
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: Models.LibraryItem?
@Published var itemUnderTitleEdit: Models.LibraryItem?
@Published var itemForHighlightsView: Models.LibraryItem?
@Published var linkRequest: LinkRequest?
@Published var presentWebContainer = false
@Published var showLoadingBar = false
@Published var isInMultiSelectMode = false
@Published var showLoadingBar = LoadingBarStyle.redacted
@Published var selectedLinkItem: NSManagedObjectID? // used by mac app only
@Published var selectedItem: Models.LibraryItem?
@Published var linkIsActive = false
@Published var showLabelsSheet = false
@Published var showFiltersModal = false
@Published var showCommunityModal = false
@Published var featureItems = [Models.LibraryItem]()
@Published var showSnackbar = false
@Published var showAddFeedView = false
@Published var showHideFollowingAlert = false
@Published var snackbarOperation: SnackbarOperation?
@Published var filters = [InternalFilter]()
@ -41,80 +40,98 @@ import Views
@Published var negatedLabels = [LinkedItemLabel]()
@Published var appliedSort = LinkedItemSort.newest.rawValue
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
@State var lastMoreFetched: Date?
@State var lastFiltersFetched: Date?
@State var isModifyingNewsletterDestination = false
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@AppStorage(UserDefaultKey.stopUsingFollowingPrimer.rawValue) var stopUsingFollowingPrimer = false
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
@Published var appliedFilter: InternalFilter? {
didSet {
let filterKey = UserDefaults.standard.string(forKey: "lastSelected-\(folder)-filter") ?? folder
UserDefaults.standard.setValue(appliedFilter?.name, forKey: filterKey)
if let filterName = appliedFilter?.name.lowercased() {
UserDefaults.standard.setValue(filterName, forKey: filterKey)
}
}
}
private var filterState: FetcherFilterState {
FetcherFilterState(folder: folder, searchTerm: searchTerm, selectedLabels: selectedLabels, negatedLabels: negatedLabels, appliedSort: appliedSort, appliedFilter: appliedFilter)
}
init(filterKey: String, fetcher: LibraryItemFetcher, folderConfigs: [String: LibraryListConfig]) {
self.filterKey = filterKey
init(folder: String, fetcher: LibraryItemFetcher, listConfig: LibraryListConfig) {
self.folder = folder
self.fetcher = fetcher
self.listConfig = listConfig
self.folderConfigs = folderConfigs
super.init()
}
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
if let filter = filter {
Task {
featureFilter = filter.rawValue
featureItems = await loadFeatureItems(
context: context,
predicate: filter.predicate,
sort: filter.sortDescriptor
)
}
} else {
featureItems = []
private var filterState: FetcherFilterState? {
if let appliedFilter = appliedFilter {
return FetcherFilterState(
folder: appliedFilter.folder,
searchTerm: searchTerm,
selectedLabels: selectedLabels,
negatedLabels: negatedLabels,
appliedSort: appliedSort,
appliedFilter: appliedFilter
)
}
return nil
}
var currentFolder: String? {
appliedFilter?.folder
}
var currentListConfig: LibraryListConfig? {
if let currentFolder = currentFolder {
return folderConfigs[currentFolder]
}
return nil
}
func loadFilters(dataService: DataService) async {
switch folder {
case "following":
updateFilters(newFilters: InternalFilter.DefaultFollowingFilters, defaultName: "rss")
default:
var hasLocalResults = false
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
let start = Date()
var hasLocalResults = false
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
// Load from disk
if let results = try? dataService.viewContext.fetch(fetchRequest) {
hasLocalResults = true
updateFilters(newFilters: InternalFilter.make(from: results), defaultName: "inbox")
}
let hasResults = hasLocalResults
Task.detached {
if let downloadedFilters = try? await dataService.filters() {
await self.updateFilters(newFilters: downloadedFilters, defaultName: "inbox")
} else if !hasResults {
await self.updateFilters(newFilters: InternalFilter.DefaultInboxFilters, defaultName: "inbox")
}
}
if let lastFiltersFetched, lastFiltersFetched.timeIntervalSinceNow > -100 {
print("skipping fetching filters as last fetch was too recent: ", lastFiltersFetched)
return
}
// Load from disk
if let results = try? dataService.viewContext.fetch(fetchRequest) {
hasLocalResults = true
updateFilters(newFilters: InternalFilter.make(from: results))
}
let hasResults = hasLocalResults
if let downloadedFilters = try? await dataService.filters() {
updateFilters(newFilters: downloadedFilters)
} else if !hasResults {
updateFilters(newFilters: InternalFilter.DefaultInboxFilters)
}
lastFiltersFetched = start
}
func itemAppeared(item: Models.LibraryItem, dataService: DataService) async {
if isLoading { return }
let itemIndex = fetcher.items.firstIndex(where: { $0.id == item.id })
let thresholdIndex = fetcher.items.index(fetcher.items.endIndex, offsetBy: -5)
func loadMore(dataService: DataService, loadCursor: String? = nil) async {
if let filterState = filterState {
if isLoading { return }
// Check if user has scrolled to the last five items in the list
// Make sure we aren't currently loading though, as this would get triggered when the first set
// of items are presented to the user.
if let itemIndex = itemIndex, itemIndex > thresholdIndex {
await loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: false)
let start = Date.now
if let lastMoreFetched, lastMoreFetched.timeIntervalSinceNow > -4 {
print("skipping fetching more as last fetch was too recent: ", lastMoreFetched)
return
}
isLoading = true
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, loadCursor: loadCursor)
isLoading = false
lastMoreFetched = start
}
}
@ -139,36 +156,65 @@ import Views
}
}
func updateFilters(newFilters: [InternalFilter], defaultName: String) {
let appliedFilterName = UserDefaults.standard.string(forKey: "lastSelected-\(filterState.folder)-filter") ?? defaultName
var defaultFilters: [InternalFilter] {
[InternalFilter.InboxUnreadFilter,
InternalFilter.InboxDeletedFilter,
InternalFilter.InboxDownloadedFilter]
+ InternalFilter.DefaultFollowingFilters
}
func updateFilters(newFilters: [InternalFilter]) {
let availableFolders = folderConfigs.keys
let appliedFilterName = UserDefaults.standard.string(forKey: filterKey)
filters = newFilters
.filter { $0.folder == filterState.folder }
.filter { availableFolders.contains($0.folder) }
.sorted(by: { $0.position < $1.position })
+ (folder == "inbox" ? [InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] : [InternalFilter.DownloadedFilter])
+ defaultFilters
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
appliedFilter = newFilter
}
}
func loadItems(dataService: DataService, isRefresh: Bool) async {
isLoading = true
showLoadingBar = isRefresh
func setDefaultFilter() {
let availableFolders = folderConfigs.keys
let appliedFilterName = UserDefaults.standard.string(forKey: filterKey)
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
appliedFilter = newFilter
return
}
await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
isLoading = false
showLoadingBar = false
if let defaultFilter = filters.first(where: { availableFolders.contains($0.folder) }) {
appliedFilter = defaultFilter
}
}
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
isLoading = true
func loadNewItems(dataService: DataService) async {
if let filterState = filterState {
await fetcher.loadNewItems(
dataService: dataService,
filterState: filterState
)
objectWillChange.send()
}
}
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
func loadItems(dataService: DataService, isRefresh: Bool, forceRemote: Bool = false, loadingBarStyle: LoadingBarStyle? = nil) async {
isLoading = true
showLoadingBar = isRefresh ? loadingBarStyle ?? .redacted : .none
if let filterState = filterState {
await fetcher.loadItems(
dataService: dataService,
filterState: filterState,
isRefresh: isRefresh,
forceRemote: forceRemote
)
}
isLoading = false
showLoadingBar = .none
}
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [Models.LibraryItem] {
@ -177,7 +223,14 @@ import Views
fetchRequest.predicate = predicate
fetchRequest.sortDescriptors = [sort]
return (try? context.fetch(fetchRequest)) ?? []
do {
let fetched = try context.fetch(fetchRequest)
return fetched
} catch {
print("ERROR FETCHING: ", error)
}
return []
// return (try? context.fetch(fetchRequest)) ?? []
}
func snackbar(_ message: String, undoAction: SnackbarUndoAction? = nil) {
@ -187,7 +240,7 @@ import Views
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
dataService.archiveLink(objectID: objectID, archived: archived)
snackbar(archived ? "Link archived" : "Link moved to Inbox")
snackbar(archived ? "Link archived" : "Link unarchived")
}
func removeLibraryItem(dataService: DataService, objectID: NSManagedObjectID) {
@ -221,7 +274,7 @@ import Views
dataService.setItemLabels(itemID: item.unwrappedID, labels: InternalLinkedItemLabel.make(Set(existingLabels + [label]) as NSSet))
item.update(inContext: dataService.viewContext)
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
fetcher.refreshFeatureItems(dataService: dataService)
}
}
@ -235,16 +288,12 @@ import Views
func pinItem(dataService: DataService, item: Models.LibraryItem) {
addLabel(dataService: dataService, item: item, label: "Pinned", color: "#0A84FF")
if featureFilter == FeaturedItemFilter.pinned.rawValue {
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}
fetcher.refreshFeatureItems(dataService: dataService)
}
func unpinItem(dataService: DataService, item: Models.LibraryItem) {
removeLabel(dataService: dataService, item: item, named: "Pinned")
if featureFilter == FeaturedItemFilter.pinned.rawValue {
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}
fetcher.refreshFeatureItems(dataService: dataService)
}
func markRead(dataService: DataService, item: Models.LibraryItem) {
@ -285,4 +334,34 @@ import Views
func findFilter(_: DataService, named: String) -> InternalFilter? {
filters.first(where: { $0.name == named })
}
func modifyingNewsletterDestinationToFollowing(dataService: DataService) async {
isModifyingNewsletterDestination = true
do {
var errorCount = 0
let objectIDs = try await dataService.newsletterEmails()
let newsletters = await dataService.viewContext.perform {
let newsletters = objectIDs.compactMap { dataService.viewContext.object(with: $0) as? NewsletterEmail }
return newsletters
}
for newsletter in newsletters {
if let emailId = newsletter.emailId, newsletter.folder != "following" {
do {
try await dataService.updateNewsletterEmail(emailID: emailId, folder: "following")
} catch {
print("error updating newsletter: ", error)
errorCount += 1
}
}
}
if errorCount > 0 {
snackbar("There was an error modifying \(errorCount) of your emails")
} else {
snackbar("Email destination modified")
}
} catch {
snackbar("Error modifying emails")
}
}
}

View file

@ -5,43 +5,16 @@ import Services
import SwiftUI
import Views
@MainActor final class LibraryAddFeedViewModel: NSObject, ObservableObject {
@Published var isLoading = false
@Published var errorMessage: String = ""
@Published var showErrorMessage: Bool = false
@Environment(\.dismiss) private var dismiss
func addLink(dataService: DataService, newLinkURL: String, dismiss: DismissAction) {
isLoading = true
Task {
if URL(string: newLinkURL) == nil {
error("Invalid link")
} else {
let result = try? await dataService.saveURL(id: UUID().uuidString, url: newLinkURL)
if result == nil {
error("Error adding link")
} else {
dismiss()
}
}
isLoading = false
}
}
func error(_ msg: String) {
errorMessage = msg
showErrorMessage = true
isLoading = false
}
}
struct LibraryAddFeedView: View {
@StateObject var viewModel = LibraryAddFeedViewModel()
@State var newLinkURL: String = ""
let dismiss: () -> Void
@State var feedURL: String = ""
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@State var prefetchContent = true
@State var folderSelection = "following"
@State var selectedLabels = [LinkedItemLabel]()
let toastOperationHandler: ToastOperationHandler?
enum FocusField: Hashable {
case addLinkEditor
@ -54,7 +27,7 @@ struct LibraryAddFeedView: View {
#if os(iOS)
Form {
innerBody
.navigationTitle("Add Link")
.navigationTitle("Add Feed URL")
.navigationBarTitleDisplayMode(.inline)
}
#else
@ -67,7 +40,6 @@ struct LibraryAddFeedView: View {
.onAppear {
focusedField = .addLinkEditor
}
.navigationTitle("Add Link")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -75,14 +47,23 @@ struct LibraryAddFeedView: View {
dismissButton
}
ToolbarItem(placement: .navigationBarTrailing) {
viewModel.isLoading ? AnyView(ProgressView()) : AnyView(addButton)
NavigationLink(
destination: LibraryScanFeedView(
dismiss: self.dismiss,
viewModel: LibraryAddFeedViewModel(
dataService: dataService,
feedURL: feedURL,
prefetchContent: prefetchContent,
folder: folderSelection,
selectedLabels: selectedLabels,
toastOperationHandler: toastOperationHandler
)
),
label: { Text("Add").bold().disabled(feedURL.isEmpty) }
)
}
}
#endif
.alert(viewModel.errorMessage,
isPresented: $viewModel.showErrorMessage) {
Button(LocalText.genericOk, role: .cancel) { viewModel.showErrorMessage = false }
}
}
var cancelButton: some View {
@ -102,48 +83,35 @@ struct LibraryAddFeedView: View {
var innerBody: some View {
Group {
TextField("Add Link", text: $newLinkURL)
#if os(iOS)
.keyboardType(.URL)
#endif
.autocorrectionDisabled(true)
.textFieldStyle(StandardTextFieldStyle())
.focused($focusedField, equals: .addLinkEditor)
Section {
TextField("Feed or site URL", text: $feedURL)
#if os(iOS)
.keyboardType(.URL)
#endif
.autocorrectionDisabled(true)
.textFieldStyle(StandardTextFieldStyle())
.focused($focusedField, equals: .addLinkEditor)
Button(action: {
if let url = pasteboardString {
newLinkURL = url
} else {
viewModel.error("No URL on pasteboard")
}
}, label: {
Text("Get from pasteboard")
})
Button(action: {
if let url = pasteboardString {
feedURL = url
} else {
// viewModel.error("No URL on pasteboard")
}
}, label: {
Text("Get from pasteboard")
})
}
#if os(macOS)
Spacer()
HStack {
cancelButton
Spacer()
addButton
}
.frame(maxWidth: .infinity)
#endif
}
}
var addButton: some View {
Button(
action: {
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
},
label: { Text("Add").bold() }
)
.keyboardShortcut(.defaultAction)
.onSubmit {
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
}
.disabled(viewModel.isLoading)
Section {
SubscriptionSettings(
feedURL: $feedURL,
prefetchContent: $prefetchContent,
folderSelection: $folderSelection,
selectedLabels: $selectedLabels
)
}
}.listStyle(.insetGrouped)
}
var dismissButton: some View {
@ -151,6 +119,53 @@ struct LibraryAddFeedView: View {
action: { dismiss() },
label: { Text(LocalText.genericClose) }
)
.disabled(viewModel.isLoading)
}
}
private struct SubscriptionSettings: View {
@Binding var feedURL: String
@Binding var prefetchContent: Bool
@Binding var folderSelection: String
@Binding var selectedLabels: [LinkedItemLabel]
@State var showLabelsSelector = false
var folderRow: some View {
HStack {
Picker("Destination Folder", selection: $folderSelection) {
Text("Inbox").tag("inbox")
Text("Following").tag("following")
}
.pickerStyle(MenuPickerStyle())
}
}
var labelRuleRow: some View {
HStack {
Text("Add Labels")
Spacer(minLength: 30)
Button(action: { showLabelsSelector = true }, label: {
if selectedLabels.count > 0 {
let labelNames = selectedLabels.map(\.unwrappedName)
Text("[\(labelNames.joined(separator: ","))]")
.lineLimit(1)
} else {
Text("Create Rule")
}
})
}
}
var body: some View {
Group {
// Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:") })
folderRow
// labelRuleRow
}
.sheet(isPresented: $showLabelsSelector) {
ApplyLabelsView(mode: .list(selectedLabels), onSave: { labels in
selectedLabels = labels
})
}
}
}

View file

@ -17,33 +17,6 @@ import Views
action: { viewModel.itemUnderLabelEdit = item },
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
)
// Button(action: {
// withAnimation(.linear(duration: 0.4)) {
// viewModel.setLinkArchived(
// dataService: dataService,
// objectID: item.objectID,
// archived: !item.isArchived
// )
// }
// }, label: {
// Label(
// item.isArchived ? "Unarchive" : "Archive",
// systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
// )
// })
// Button("Remove Item", role: .destructive) {
// viewModel.removeLink(dataService: dataService, objectID: item.objectID)
// }
// if let author = item.author {
// Button(
// action: {
// viewModel.filterState.searchTerm = "author:\"\(author)\""
// },
// label: {
// Label(String("More by \(author)"), systemImage: "person")
// }
// )
// }
} else {
Button(
action: { viewModel.recoverItem(dataService: dataService, itemID: item.unwrappedID) },

View file

@ -14,6 +14,7 @@ enum CardStyle {
struct LibraryListConfig {
var hasFeatureCards = false
var hasReadNowSection = false
var leadingSwipeActions = [SwipeAction]()
var trailingSwipeActions = [SwipeAction]()
var cardStyle = CardStyle.library

View file

@ -1,42 +0,0 @@
//
// File.swift
//
//
// Created by Jackson Harper on 6/29/23.
//
import Foundation
import Models
import SwiftUI
struct LibraryListView: View {
@StateObject private var libraryViewModel = HomeFeedViewModel(
folder: "inbox",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
)
var body: some View {
// ZStack {
// NavigationLink(
// destination: LinkDestination(selectedItem: libraryViewModel.selectedItem),
// isActive: $libraryViewModel.linkIsActive
// ) {
// EmptyView()
// }
HomeView(viewModel: libraryViewModel)
.tabItem {
Label {
Text("Library")
} icon: {
Image.tabLibrary
}
}
// }
}
}

View file

@ -0,0 +1,231 @@
import Foundation
import Models
import Services
import SwiftUI
import Utils
@MainActor
public class LibraryAddFeedViewModel: NSObject, ObservableObject {
let dataService: DataService
let feedURL: String
let prefetchContent: Bool
let folder: String
let selectedLabels: [LinkedItemLabel]
let toastOperationHandler: ToastOperationHandler?
@Published var isLoading = true
@Published var errorMessage: String = ""
@Published var showErrorMessage: Bool = false
@Published var feeds: [Feed] = []
@Published var selected: [String] = []
init(dataService: DataService, feedURL: String, prefetchContent: Bool, folder: String, selectedLabels: [LinkedItemLabel], toastOperationHandler: ToastOperationHandler?) {
self.dataService = dataService
self.feedURL = feedURL
self.prefetchContent = prefetchContent
self.folder = folder
self.selectedLabels = selectedLabels
self.toastOperationHandler = toastOperationHandler
}
func scanFeed() async {
isLoading = true
if let feedURL = URL(string: feedURL) {
let result = try? await dataService.scanFeed(feedURL: feedURL)
if let feeds = result {
self.feeds = feeds
selected = feeds.map(\.url)
} else {
feeds = []
error("Error adding feed")
}
} else {
error("invalid URL")
}
isLoading = false
}
func addFeeds() async {
if let toastOperationHandler = toastOperationHandler {
toastOperationHandler.update(OperationStatus.isPerforming, "Subscribing...")
let selected = self.selected
let addTask = Task.detached(priority: .background) {
_ = await withTaskGroup(of: Bool.self) { group in
for feedURL in selected {
group.addTask {
(try? await self.dataService.subscribeToFeed(feedURL: feedURL, folder: self.folder, fetchContent: self.prefetchContent)) ?? false
}
}
var successCount = 0
var failureCount = 0
for await value in group {
if value {
successCount += 1
} else {
failureCount += 1
}
}
let hasFailures = failureCount
DispatchQueue.main.async {
if hasFailures > 0 {
toastOperationHandler.update(OperationStatus.failure, "Failed to subscribe to \(hasFailures) feeds")
} else {
toastOperationHandler.update(OperationStatus.success, "Subscribed")
}
}
}
}
toastOperationHandler.performOperation(addTask)
} else {
_ = await withTaskGroup(of: Bool.self) { group in
for feedURL in selected {
group.addTask {
(try? await self.dataService.subscribeToFeed(feedURL: feedURL, folder: self.folder, fetchContent: self.prefetchContent)) ?? false
}
}
var successCount = 0
var failureCount = 0
for await value in group {
if value {
successCount += 1
} else {
failureCount += 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(4000)) {
if failureCount > 0 {
showInLibrarySnackbar("Failed to add \(failureCount) feeds")
} else {
showInLibrarySnackbar("Added \(successCount) feed\(successCount == 0 ? "" : "s")")
}
}
}
}
}
func setLabelsRule(dataService _: DataService, existingRule _: Rule?, ruleName _: String, filter _: String, labelIDs _: [String]) async {
// Task {
// operationMessage = "Creating label rule..."
// operationStatus = .isPerforming
// do {
// // Make sure the labels have been created
// await loadLabels(dataService: dataService)
// let existingLabelIDs = labels?.map(\.unwrappedID) ?? []
// if labelIDs.first(where: { !existingLabelIDs.contains($0) }) != nil {
// throw BasicError.message(messageText: "Label not created")
// }
//
// _ = try await dataService.createOrUpdateAddLabelsRule(
// existingID: existingRule?.id,
// name: ruleName,
// filter: filter,
// labelIDs: labelIDs
// )
// if let newRules = try? await dataService.rules() {
// if !newRules.contains(where: { $0.name == ruleName }) {
// throw BasicError.message(messageText: "Rule not created")
// }
// rules = newRules
// }
// operationMessage = "Rule created"
// operationStatus = .success
// } catch {
// operationMessage = "Failed to create label rule"
// operationStatus = .failure
// }
// }
}
func error(_ msg: String) {
errorMessage = msg
showErrorMessage = true
isLoading = false
}
}
@MainActor
public struct LibraryScanFeedView: View {
let dismiss: () -> Void
@StateObject var viewModel: LibraryAddFeedViewModel
func isSelected(_ url: String) -> Bool {
viewModel.selected.contains(url)
}
var innerBody: some View {
if viewModel.isLoading {
AnyView(ProgressView().frame(maxWidth: .infinity, alignment: .center))
} else if viewModel.feeds.count == 0 {
AnyView(Text("No feeds found for URL"))
} else {
AnyView(List {
Section("Choose the feeds to add") {
ForEach(viewModel.feeds, id: \.title) { feed in
Button(action: {
if !isSelected(feed.url) {
viewModel.selected.append(feed.url)
} else {
if let idx = viewModel.selected.firstIndex(of: feed.url) {
viewModel.selected.remove(at: idx)
}
}
}, label: {
HStack {
Text(feed.title)
Spacer()
if isSelected(feed.url) {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
})
}
}
})
}
}
public var body: some View {
Group {
#if os(iOS)
Form {
innerBody
.navigationTitle("Select Feeds")
.navigationBarTitleDisplayMode(.inline)
}.task {
await viewModel.scanFeed()
}
#else
innerBody
#endif
}
.toolbar {
ToolbarItem(placement: .barTrailing) {
if viewModel.selected.count > 0 {
Button(action: {
dismiss()
showInLibrarySnackbar("Adding feeds...")
Task {
await viewModel.addFeeds()
}
}, label: {
Text("Add").bold().disabled(viewModel.selected.count < 1)
})
} else {
Button(action: {
dismiss()
}, label: {
Text("Done").bold()
})
}
}
}
}
}

View file

@ -75,6 +75,7 @@
innerBody
}.introspectViewController { controller in
searchBar = Introspect.findChild(ofType: UISearchBar.self, in: controller.view)
searchBar?.smartQuotesType = .no
}
}

View file

@ -1,135 +0,0 @@
// swiftlint:disable line_length
#if os(iOS)
import Foundation
import Models
import Services
import SwiftUI
import Views
struct OpenAIVoiceItem {
let name: String
let key: String
}
public struct OpenAIVoicesModal: View {
@Environment(\.dismiss) private var dismiss
let audioController: AudioController
let message: String = """
We've added six new voices powered by OpenAI and enabled them for all users. If you are already using our Ultra Realistic voices, don't worry, trying these voices will not remove you from the ultra realistic beta.
[Tell your friends about Omnivore](https://omnivore.app)
"""
@State var playbackSample: String?
let voices = [
OpenAIVoiceItem(name: "Alloy", key: "openai-alloy"),
OpenAIVoiceItem(name: "Echo", key: "openai-echo"),
OpenAIVoiceItem(name: "Fable", key: "openai-fable"),
OpenAIVoiceItem(name: "Onyx", key: "openai-onyx"),
OpenAIVoiceItem(name: "Nova", key: "openai-nova"),
OpenAIVoiceItem(name: "Shimmer", key: "openai-shimmer")
]
var closeButton: some View {
Button(action: {
dismiss()
}, label: {
ZStack {
Circle()
.foregroundColor(Color.circleButtonBackground)
.frame(width: 30, height: 30)
Image(systemName: "xmark")
.resizable(resizingMode: Image.ResizingMode.stretch)
.foregroundColor(Color.circleButtonForeground)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.bold))
.frame(width: 12, height: 12)
}
})
}
public var body: some View {
HStack {
Text("New voices powered by OpenAI")
.font(Font.system(size: 20, weight: .bold))
Spacer()
closeButton
}
.padding(.top, 16)
.padding(.horizontal, 16)
List {
Section {
let parsedMessage = try? AttributedString(markdown: message,
options: .init(interpretedSyntax: .inlineOnly))
Text(parsedMessage ?? "")
.multilineTextAlignment(.leading)
.foregroundColor(Color.appGrayTextContrast)
.accentColor(.blue)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
}
Section {
ForEach(voices, id: \.self.name) { voice in
voiceRow(for: voice)
}
}
}
.environmentObject(audioController)
}
func voiceRow(for voice: OpenAIVoiceItem) -> some View {
Button(action: {
if audioController.isPlayingSample(voice: voice.key) {
playbackSample = nil
audioController.stopVoiceSample()
}
playbackSample = voice.key
audioController.currentVoice = voice.key
audioController.playVoiceSample(voice: voice.key)
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in
let playing = audioController.isPlayingSample(voice: voice.key)
if playing {
playbackSample = voice.key
} else if !playing {
// If the playback sample is something else, its taken ownership
// of the value so we just ignore it and shut down our timer.
if playbackSample == voice.key {
playbackSample = nil
}
timer.invalidate()
}
}
}, label: {
HStack {
if playbackSample == voice.key {
Image(systemName: "stop.circle")
.font(.appTitleTwo)
.padding(.trailing, 16)
} else {
Image(systemName: "play.circle")
.font(.appTitleTwo)
.padding(.trailing, 16)
}
Text(voice.name)
Spacer()
if audioController.currentVoice == voice.key {
if audioController.isPlaying, audioController.isLoading {
ProgressView()
} else {
Image(systemName: "checkmark")
}
}
}.contentShape(Rectangle())
})
.buttonStyle(PlainButtonStyle())
.frame(maxWidth: .infinity)
}
}
#endif

View file

@ -2,6 +2,7 @@
import Models
import Services
import SwiftUI
import Utils
import Views
@MainActor
@ -124,7 +125,7 @@ struct ApplyLabelsView: View {
label: {
HStack {
let trimmedLabelName = viewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines)
Image(systemName: "tag").foregroundColor(.blue)
Image.addLink.foregroundColor(.blue).foregroundColor(.blue)
Text(
viewModel.labelSearchFilter.count > 0 ?
"Create: \"\(trimmedLabelName)\" label" :
@ -195,15 +196,21 @@ struct ApplyLabelsView: View {
}
}
func isSystemLabel(_ label: LinkedItemLabel) -> Bool {
label.name == "RSS" || label.name == "Newsletter" || label.name == "Pinned"
}
extension Sequence where Element == LinkedItemLabel {
func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] {
let hideSystemLabels = PublicValet.hideLabels
if searchFilter.isEmpty || searchFilter == ZWSP {
return map { $0 } // return the identity of the sequence
}
if searchFilter.starts(with: ZWSP) {
let index = searchFilter.index(searchFilter.startIndex, offsetBy: 1)
let trimmed = searchFilter.suffix(from: index).lowercased()
return filter { ($0.name ?? "").lowercased().contains(trimmed) }
return filter { ($0.name ?? "").lowercased().contains(trimmed) && (!hideSystemLabels || !isSystemLabel($0)) }
}
return filter { ($0.name ?? "").lowercased().contains(searchFilter.lowercased()) }
}

View file

@ -2,11 +2,12 @@ import CoreData
import Models
import Services
import SwiftUI
import Utils
import Views
@MainActor final class FilterByLabelsViewModel: ObservableObject {
@Published var isLoading = false
@Published var errorMessage: String? = nil
@Published var errorMessage: String?
@Published var labels = [LinkedItemLabel]()
@Published var selectedLabels = [LinkedItemLabel]()
@Published var negatedLabels = [LinkedItemLabel]()
@ -14,7 +15,9 @@ import Views
@Published var labelSearchFilter = ""
func setLabels(_ labels: [LinkedItemLabel]) {
self.labels = labels.sorted { left, right in
let hideSystemLabels = PublicValet.hideLabels
self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in
let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces)
let bTrimmed = right.unwrappedName.trimmingCharacters(in: .whitespaces)
return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending

View file

@ -10,22 +10,33 @@ struct LabelsView: View {
@State private var showDeleteConfirmation = false
@State private var labelToRemove: LinkedItemLabel?
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultKey.hideSystemLabels.rawValue) var hideSystemLabels = false
var body: some View {
List {
ForEach(viewModel.labels, id: \.id) { label in
HStack {
TextChip(feedItemLabel: label).allowsHitTesting(false)
Spacer()
Button(
action: {
labelToRemove = label
showDeleteConfirmation = true
},
label: { Image(systemName: "trash") }
)
Section {
ForEach(viewModel.labels, id: \.id) { label in
HStack {
TextChip(feedItemLabel: label).allowsHitTesting(false)
Spacer()
if !isSystemLabel(label) {
Button(
action: {
labelToRemove = label
showDeleteConfirmation = true
},
label: { Image(systemName: "trash") }
)
}
}
}
createLabelButton
}
Section("Label settings") {
Toggle("Hide system labels", isOn: $hideSystemLabels)
}
createLabelButton
}
.navigationTitle(LocalText.labelsGeneric)
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
@ -43,6 +54,16 @@ struct LabelsView: View {
}
Button(LocalText.cancelGeneric, role: .cancel) { self.labelToRemove = nil }
}
.onChange(of: hideSystemLabels) { newValue in
PublicValet.hideLabels = newValue
Task {
await viewModel.loadLabels(dataService: dataService, item: nil)
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
.sheet(isPresented: $viewModel.showCreateLabelModal) {
CreateLabelView(viewModel: viewModel, newLabelName: viewModel.labelSearchFilter)
}
@ -53,25 +74,19 @@ struct LabelsView: View {
Button(
action: { viewModel.showCreateLabelModal = true },
label: {
HStack {
Label(title: {
let trimmedLabelName = viewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines)
Image(systemName: "tag").foregroundColor(.blue)
Text(
viewModel.labelSearchFilter.count > 0 ?
viewModel.labelSearchFilter.count > 0 && viewModel.labelSearchFilter != ZWSP ?
"Create: \"\(trimmedLabelName)\" label" :
LocalText.createLabelMessage
).foregroundColor(.blue)
.font(Font.system(size: 14))
Spacer()
}
)
}, icon: {
Image.addLink
})
}
)
.buttonStyle(PlainButtonStyle())
.disabled(viewModel.isLoading)
#if os(iOS)
.listRowSeparator(.hidden, edges: .bottom)
#endif
.padding(.vertical, 10)
}
}
@ -106,16 +121,6 @@ struct CreateLabelView: View {
var innerBody: some View {
VStack {
HStack {
if !newLabelName.isEmpty, newLabelColor != .clear {
TextChip(text: newLabelName, color: newLabelColor)
} else {
Text(LocalText.labelsViewAssignNameColor).font(.appBody)
}
Spacer()
}
.padding(.bottom, 8)
TextField(LocalText.labelNamePlaceholder, text: $newLabelName)
.textFieldStyle(StandardTextFieldStyle())
.onChange(of: newLabelName) { inputLabelName in

View file

@ -2,6 +2,7 @@ import CoreData
import Models
import Services
import SwiftUI
import Utils
@MainActor public final class LabelsViewModel: ObservableObject {
let labelNameMaxLength = 64
@ -16,7 +17,9 @@ import SwiftUI
public init() {}
func setLabels(_ labels: [LinkedItemLabel]) {
self.labels = labels.sorted { left, right in
let hideSystemLabels = PublicValet.hideLabels
self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in
let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces)
let bTrimmed = right.unwrappedName.trimmingCharacters(in: .whitespaces)
return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending

View file

@ -4,9 +4,7 @@ import Services
import SwiftUI
@MainActor struct LibrarySidebar: View {
@ObservedObject var inboxViewModel: HomeFeedViewModel
@ObservedObject var followingViewModel: HomeFeedViewModel
@ObservedObject var viewModel: HomeFeedViewModel
@EnvironmentObject var dataService: DataService
@State private var addLinkPresented = false
@ -21,114 +19,79 @@ import SwiftUI
@AppStorage("inboxMenuState") var inboxMenuState = "open"
@AppStorage("followingMenuState") var followingMenuState = "open"
func createInboxViewModel(_ filter: InternalFilter) -> HomeFeedViewModel {
let result = HomeFeedViewModel(
folder: "inbox",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
)
result.appliedFilter = filter
return result
}
func createFollowingViewModel(_ filter: InternalFilter) -> HomeFeedViewModel {
let result = HomeFeedViewModel(
folder: "following",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: false,
leadingSwipeActions: [.moveToInbox],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
)
result.appliedFilter = filter
return result
}
var innerBody: some View {
ZStack {
NavigationLink("", destination: HomeView(viewModel: inboxViewModel), isActive: $inboxActive)
NavigationLink("", destination: HomeView(viewModel: followingViewModel), isActive: $followingActive)
List {
Section {
Button(action: { inboxMenuState = inboxMenuState == "open" ? "closed" : "open" }, label: {
HStack {
Image.tabLibrary
Text("Library")
Spacer()
List {
Section {
Button(action: { inboxMenuState = inboxMenuState == "open" ? "closed" : "open" }, label: {
HStack {
Image.tabLibrary
Text("Library")
Spacer()
if inboxMenuState == "open" {
Image(systemName: "chevron.down")
} else {
Image(systemName: "chevron.right")
}
}
})
if inboxMenuState == "open" {
ForEach(inboxViewModel.filters, id: \.self) { filter in
Button(action: {
inboxViewModel.appliedFilter = filter
selectedFilter = filter
followingActive = false
inboxActive = true
}, label: {
HStack {
Spacer().frame(width: 35)
Text(filter.name)
.lineLimit(1)
}
})
.listRowBackground(
selectedFilter == filter && inboxActive
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
)
if inboxMenuState == "open" {
Image(systemName: "chevron.down")
} else {
Image(systemName: "chevron.right")
}
}
}
})
Section {
Button(action: { followingMenuState = followingMenuState == "open" ? "closed" : "open" }, label: {
HStack {
Image.tabFollowing
Text("Following")
Spacer()
if followingMenuState == "open" {
Image(systemName: "chevron.down")
} else {
Image(systemName: "chevron.right")
if inboxMenuState == "open" {
ForEach(viewModel.filters.filter { $0.folder == "inbox" }, id: \.self) { filter in
Button(action: {
viewModel.appliedFilter = filter
selectedFilter = filter
followingActive = false
inboxActive = true
}, label: {
HStack {
Spacer().frame(width: 35)
Text(filter.name)
.lineLimit(1)
}
}
})
})
.listRowBackground(
selectedFilter == filter && inboxActive
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
)
}
}
}
if followingMenuState == "open" {
ForEach(followingViewModel.filters, id: \.self) { filter in
Button(action: {
followingViewModel.appliedFilter = filter
selectedFilter = filter
inboxActive = false
followingActive = true
}, label: {
HStack {
Spacer().frame(width: 35)
Text(filter.name)
.lineLimit(1)
}
})
.listRowBackground(
selectedFilter == filter && followingActive
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
)
Section {
Button(action: { followingMenuState = followingMenuState == "open" ? "closed" : "open" }, label: {
HStack {
Image.tabFollowing
Text("Following")
Spacer()
if followingMenuState == "open" {
Image(systemName: "chevron.down")
} else {
Image(systemName: "chevron.right")
}
}
})
if followingMenuState == "open" {
ForEach(viewModel.filters.filter { $0.folder == "following" }, id: \.self) { filter in
Button(action: {
viewModel.appliedFilter = filter
selectedFilter = filter
inboxActive = false
followingActive = true
}, label: {
HStack {
Spacer().frame(width: 35)
Text(filter.name)
.lineLimit(1)
}
})
.listRowBackground(
selectedFilter == filter && followingActive
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
)
}
}
}
.listStyle(.sidebar)
@ -155,23 +118,18 @@ import SwiftUI
}
}
}.task {
await inboxViewModel.loadFilters(dataService: dataService)
await followingViewModel.loadFilters(dataService: dataService)
await viewModel.loadFilters(dataService: dataService)
if inboxActive {
selectedFilter = inboxViewModel.appliedFilter
selectedFilter = viewModel.appliedFilter
} else {
selectedFilter = followingViewModel.appliedFilter
selectedFilter = viewModel.appliedFilter
}
}.onChange(of: inboxViewModel.appliedFilter) { filter in
}.onChange(of: viewModel.appliedFilter) { filter in
// When the user uses the dropdown menu to change filter we need to update in the sidebar
if inboxActive, filter != selectedFilter {
selectedFilter = filter
}
}.onChange(of: followingViewModel.appliedFilter) { filter in
if followingActive, filter != selectedFilter {
selectedFilter = filter
}
}
}

View file

@ -1,46 +1,45 @@
import Foundation
import Models
import Services
import SwiftUI
@MainActor
public struct LibrarySplitView: View {
@EnvironmentObject var audioController: AudioController
@EnvironmentObject var dataService: DataService
@StateObject private var inboxViewModel = HomeFeedViewModel(
folder: "inbox",
@StateObject private var viewModel = HomeFeedViewModel(
filterKey: "lastSelected",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
folderConfigs: [
"inbox": LibraryListConfig(
hasFeatureCards: true,
hasReadNowSection: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
),
"following": LibraryListConfig(
hasFeatureCards: false,
hasReadNowSection: false,
leadingSwipeActions: [.moveToInbox],
trailingSwipeActions: [.delete],
cardStyle: .library
)
]
)
@StateObject private var followingViewModel = HomeFeedViewModel(
folder: "following",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: false,
leadingSwipeActions: [.moveToInbox],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
)
@State var selected = "home"
private let syncManager = LibrarySyncManager()
#if os(iOS)
public var body: some View {
NavigationView {
LibrarySidebar(inboxViewModel: inboxViewModel, followingViewModel: followingViewModel)
LibrarySidebar(viewModel: viewModel)
.navigationBarTitleDisplayMode(.inline)
.tag("inbox")
.navigationTitle("")
HomeFeedContainerView(viewModel: inboxViewModel)
HomeFeedContainerView(viewModel: viewModel)
.navigationViewStyle(.stack)
.navigationBarTitleDisplayMode(.inline)
.tag("following")
}
.navigationBarTitleDisplayMode(.inline)
.accentColor(.appGrayTextContrast)
@ -48,6 +47,37 @@ public struct LibrarySplitView: View {
$0.preferredPrimaryColumnWidth = 230
$0.displayModeButtonVisibility = .always
}
// .onOpenURL { url in
// inboxViewModel.linkRequest = nil
// if let deepLink = DeepLink.make(from: url) {
// switch deepLink {
// case let .search(query):
// inboxViewModel.searchTerm = query
// case let .savedSearch(named):
// if let filter = inboxViewModel.findFilter(dataService, named: named) {
// inboxViewModel.appliedFilter = filter
// }
// case let .webAppLinkRequest(requestID):
// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
// withoutAnimation {
// inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
// inboxViewModel.presentWebContainer = true
// }
// }
// }
// }
// // selectedTab = "inbox"
// }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
Task {
await syncManager.syncUpdates(dataService: dataService)
}
}
.onReceive(NSNotification.performSyncPublisher) { _ in
Task {
await syncManager.syncUpdates(dataService: dataService)
}
}
}
#endif

View file

@ -21,36 +21,54 @@ struct LibraryTabView: View {
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
@AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox"
@State var showExpandedAudioPlayer = false
private let syncManager = LibrarySyncManager()
@MainActor
public init() {
UITabBar.appearance().isHidden = true
}
@StateObject private var followingViewModel = HomeFeedViewModel(
folder: "following",
@StateObject private var inboxViewModel = HomeFeedViewModel(
filterKey: "lastSelectedFilter-inbox",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: false,
leadingSwipeActions: [.moveToInbox],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
folderConfigs: [
"inbox": LibraryListConfig(
hasFeatureCards: true,
hasReadNowSection: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
]
)
@StateObject private var libraryViewModel = HomeFeedViewModel(
folder: "inbox",
@StateObject private var followingViewModel = HomeFeedViewModel(
filterKey: "lastSelectedFilter-following",
fetcher: LibraryItemFetcher(),
listConfig: LibraryListConfig(
hasFeatureCards: true,
leadingSwipeActions: [.pin],
trailingSwipeActions: [.archive, .delete],
cardStyle: .library
)
folderConfigs: [
"following": LibraryListConfig(
hasFeatureCards: false,
hasReadNowSection: false,
leadingSwipeActions: [.moveToInbox],
trailingSwipeActions: [.delete],
cardStyle: .library
)
]
)
var currentViewModel: HomeFeedViewModel? {
switch selectedTab {
case "inbox":
return inboxViewModel
case "following":
return followingViewModel
default:
return nil
}
}
var body: some View {
VStack(spacing: 0) {
TabView(selection: $selectedTab) {
@ -63,7 +81,7 @@ struct LibraryTabView: View {
}
NavigationView {
HomeFeedContainerView(viewModel: libraryViewModel)
HomeFeedContainerView(viewModel: inboxViewModel)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("inbox")
@ -87,8 +105,50 @@ struct LibraryTabView: View {
.padding(0)
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer()
ExpandedAudioPlayer(
delete: {
showExpandedAudioPlayer = false
audioController.stop()
currentViewModel?.removeLibraryItem(dataService: dataService, objectID: $0)
},
archive: {
showExpandedAudioPlayer = false
audioController.stop()
currentViewModel?.setLinkArchived(dataService: dataService, objectID: $0, archived: true)
},
viewArticle: { itemID in
if let article = try? dataService.viewContext.existingObject(with: itemID) as? Models.LibraryItem {
currentViewModel?.pushFeedItem(item: article)
}
}
)
}
.navigationBarHidden(true)
.onReceive(NSNotification.performSyncPublisher) { _ in
Task {
await syncManager.syncUpdates(dataService: dataService)
}
}
.onOpenURL { url in
inboxViewModel.linkRequest = nil
if let deepLink = DeepLink.make(from: url) {
switch deepLink {
case let .search(query):
inboxViewModel.searchTerm = query
case let .savedSearch(named):
if let filter = inboxViewModel.findFilter(dataService, named: named) {
inboxViewModel.appliedFilter = filter
}
case let .webAppLinkRequest(requestID):
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
withoutAnimation {
inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
inboxViewModel.presentWebContainer = true
}
}
}
}
selectedTab = "inbox"
}
}
}

View file

@ -17,34 +17,11 @@ import Views
if let item = item {
pdfItem = PDFItem.make(item: item)
self.item = item
trackReadEvent(reader: item.isPDF ? "PDF" : "WEB")
}
trackReadEvent()
}
func handleArchiveAction(dataService: DataService) {
guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
dataService.archiveLink(objectID: objectID, archived: !isItemArchived)
showInLibrarySnackbar(!isItemArchived ? "Link archived" : "Link moved to Inbox")
}
func handleDeleteAction(dataService: DataService) {
guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
removeLibraryItemAction(dataService: dataService, objectID: objectID)
}
func updateItemReadStatus(dataService: DataService) {
guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
dataService.updateLinkReadingProgress(
itemID: itemID,
readingProgress: isItemRead ? 0 : 100,
anchorIndex: 0,
force: false
)
}
private func trackReadEvent() {
private func trackReadEvent(reader: String) {
guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return }
guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.downloadURL else { return }
@ -53,6 +30,7 @@ import Views
.linkRead(
linkID: itemID,
slug: slug,
reader: reader,
originalArticleURL: originalArticleURL
)
)

View file

@ -0,0 +1,33 @@
import Models
import Services
import SwiftUI
import Utils
import Views
struct LogoutView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var authenticator: Authenticator
@Environment(\.openURL) var openURL
let deletedAccountConfirmationMessage = "Your account has been deleted. Additional steps may be needed if Sign in with Apple was used to register."
public var body: some View {
VStack(alignment: .center) {
Text("Logging out...")
ProgressView()
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
authenticator.logout(dataService: dataService)
}
}
.alert(deletedAccountConfirmationMessage, isPresented: $authenticator.showAppleRevokeTokenAlert) {
Button("View Details") {
openURL(URL(string: "https://support.apple.com/en-us/HT210426")!)
}
Button(LocalText.dismissButton) { self.authenticator.showAppleRevokeTokenAlert = false }
}
}
}

View file

@ -0,0 +1,46 @@
import SwiftUI
enum OperationStatus {
case none
case isPerforming
case success
case failure
}
struct OperationToast: View {
@Binding var operationMessage: String?
@Binding var showOperationToast: Bool
@Binding var operationStatus: OperationStatus
var body: some View {
VStack {
HStack {
if operationStatus == .isPerforming {
Text(operationMessage ?? "Performing...")
Spacer()
ProgressView()
} else if operationStatus == .success {
Text(operationMessage ?? "Success")
Spacer()
} else if operationStatus == .failure {
Text(operationMessage ?? "Failure")
Spacer()
Button(action: { showOperationToast = false }, label: {
Text("Done").bold()
})
}
}
.padding(10)
.frame(minHeight: 50)
.frame(maxWidth: 380)
.background(Color(hex: "2A2A2A"))
.foregroundColor(Color(hex: "EBEBEB"))
.cornerRadius(4.0)
.tint(Color.green)
}
.padding(.bottom, 60)
.padding(.horizontal, 10)
.ignoresSafeArea(.all, edges: .bottom)
}
}

View file

@ -49,9 +49,11 @@
}
}
struct CreateRecommendationGroupView: View {
struct ClubsView: View {
@State var name = ""
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel = RecommendationsGroupsViewModel()
var nextButton: some View {
@ -99,6 +101,9 @@
)
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
#if os(iOS)
.navigationViewStyle(.stack)
.navigationBarTitleDisplayMode(.inline)
@ -131,7 +136,7 @@
}
.sheet(isPresented: $viewModel.showCreateSheet) {
NavigationView {
CreateRecommendationGroupView(viewModel: self.viewModel)
ClubsView(viewModel: self.viewModel)
}
}
.task { await viewModel.loadGroups(dataService: dataService) }
@ -144,11 +149,11 @@
Button(
action: { viewModel.showCreateSheet = true },
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Label(title: {
Text(LocalText.clubsCreate)
Spacer()
}
}, icon: {
Image.addLink
})
}
)
}

View file

@ -1,4 +1,3 @@
import Models
import Services
import SwiftUI
@ -9,8 +8,16 @@ import Views
@Published var isLoading = false
@Published var isCreating = false
@Published var networkError = false
@Published var hasBadgePermission = false
@Published var libraryFilters = [InternalFilter]()
@Published var badgeFilter = BadgeCountHandler.badgeFilter {
didSet {
BadgeCountHandler.badgeFilter = badgeFilter
}
}
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@ -25,10 +32,35 @@ import Views
isLoading = false
}
func loadBadgePermission() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
if settings.badgeSetting == .enabled {
self.hasBadgePermission = true
} else {
self.hasBadgePermission = false
}
print("notification settings: ", settings.badgeSetting.rawValue)
print("got the notification settings")
}
}
}
func requestBadgePermission() {
UNUserNotificationCenter.current().requestAuthorization(options: UNAuthorizationOptions.badge) { success, error in
DispatchQueue.main.async {
print("requested badge permission: ", success, error)
}
}
}
}
struct FiltersView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel = FiltersViewModel()
var body: some View {
@ -45,21 +77,50 @@ struct FiltersView: View {
#endif
}
.navigationTitle(LocalText.filtersGeneric)
.task { await viewModel.loadFilters(dataService: dataService) }
.task {
viewModel.loadBadgePermission()
await viewModel.loadFilters(dataService: dataService)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
}
private var innerBody: some View {
List {
Section {
Section(header: Text("User Interface")) {
Toggle("Hide following tab", isOn: $viewModel.hideFollowingTab)
Toggle("Hide feature section", isOn: $viewModel.hideFeatureSection)
}
Section(header: Text("Saved Searches")) {
ForEach(viewModel.libraryFilters) { filter in
Text(filter.name)
if viewModel.libraryFilters.count > 0 {
ForEach(viewModel.libraryFilters) { filter in
Text(filter.name)
}
} else {
Text("No saved searches found")
}
}
// Section(header: Text("Application Badge")) {
// Toggle("Display Badge Count", isOn: $viewModel.hasBadgePermission)
// .onChange(of: viewModel.hasBadgePermission) { _ in
// if viewModel.hasBadgePermission {
// viewModel.requestBadgePermission()
// } else {
// UIApplication.shared.applicationIconBadgeNumber = 0
// }
// }
//
// if viewModel.hasBadgePermission {
// NavigationLink(destination: {
// SelectBadgeFilterView(viewModel: viewModel)
// }, label: {
// Text(viewModel.badgeFilter)
// })
// }
// }
}
}
}

View file

@ -2,19 +2,28 @@ import Models
import PopupView
import Services
import SwiftUI
import Transmission
import Views
@MainActor final class NewsletterEmailsViewModel: ObservableObject {
@Published var isLoading = false
@Published var showAddressCopied = false
@Published var emails = [NewsletterEmail]()
@Published var showOperationToast = false
@Published var operationStatus: OperationStatus = .none
@Published var operationMessage: String?
func loadEmails(dataService: DataService) async {
isLoading = true
if let objectIDs = try? await dataService.newsletterEmails() {
do {
let objectIDs = try await dataService.newsletterEmails()
await dataService.viewContext.perform { [weak self] in
self?.emails = objectIDs.compactMap { dataService.viewContext.object(with: $0) as? NewsletterEmail }
}
} catch {
print("ERROR LOADING EMAILS: ", error)
}
isLoading = false
@ -33,22 +42,48 @@ import Views
isLoading = false
}
func updateEmail(dataService: DataService, email: NewsletterEmail, folder: String? = nil, description: String? = nil) async {
operationMessage = "Updating email..."
operationStatus = .isPerforming
do {
_ = try await dataService.updateNewsletterEmail(
emailID: email.unwrappedEmailId,
folder: folder,
description: description
)
await loadEmails(dataService: dataService)
operationMessage = "Email updated"
operationStatus = .success
} catch {
operationMessage = "Failed to update email"
operationStatus = .failure
}
}
}
struct NewsletterEmailsView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel = NewsletterEmailsViewModel()
@State var showSnackbar = false
@State var snackbarOperation: SnackbarOperation?
func snackbar(message: String) {
snackbarOperation = SnackbarOperation(message: message, undoAction: nil)
showSnackbar = true
}
var body: some View {
Group {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) {
OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus)
} label: {
EmptyView()
}.buttonStyle(.plain)
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showAddressCopied) {
MessageToast()
} label: {
EmptyView()
}.buttonStyle(.plain)
#if os(iOS)
Form {
innerBody
@ -60,65 +95,118 @@ struct NewsletterEmailsView: View {
.listStyle(InsetListStyle())
#endif
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
.task { await viewModel.loadEmails(dataService: dataService) }
.popup(isPresented: $showSnackbar) {
if let operation = snackbarOperation {
Snackbar(isShowing: $showSnackbar, operation: operation)
} else {
EmptyView()
.refreshable {
Task {
await viewModel.loadEmails(dataService: dataService)
}
} customize: {
$0
.type(.toast)
.autohideIn(2)
.position(.bottom)
.animation(.spring())
.closeOnTapOutside(true)
}
}
private var innerBody: some View {
Group {
Section(footer: Text(LocalText.newslettersDescription)) {
if !viewModel.emails.isEmpty {
ForEach(viewModel.emails) { email in
Section {
NewsletterEmailRow(viewModel: viewModel, email: email, folderSelection: email.folder ?? "inbox")
}
}
}
Section {
Text(LocalText.newslettersDescription)
Button(
action: {
Task { await viewModel.createEmail(dataService: dataService) }
},
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Label(title: {
Text(LocalText.createNewEmailMessage)
Spacer()
}
}, icon: {
Image.addLink
})
}
)
.disabled(viewModel.isLoading)
}
}
.navigationTitle(LocalText.emailsGeneric)
}
}
if !viewModel.emails.isEmpty {
Section(header: Text(LocalText.newsletterEmailsExisting)) {
ForEach(viewModel.emails) { newsletterEmail in
Button(
action: {
#if os(iOS)
UIPasteboard.general.string = newsletterEmail.email
#endif
struct NewsletterEmailRow: View {
@StateObject var viewModel: NewsletterEmailsViewModel
@EnvironmentObject var dataService: DataService
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([newsletterEmail.unwrappedEmail as NSString])
#endif
@State var email: NewsletterEmail
@State var folderSelection: String
snackbar(message: "Email copied")
},
label: { Text(newsletterEmail.unwrappedEmail) }
)
var body: some View {
VStack {
HStack {
Text(email.unwrappedEmail).bold()
Spacer()
Button(
action: {
#if os(iOS)
UIPasteboard.general.string = email.email
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([newsletterEmail.unwrappedEmail as NSString])
#endif
viewModel.showAddressCopied = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
viewModel.showAddressCopied = false
}
},
label: {
Text("Copy")
}
)
}
Divider()
Picker("Destination Folder", selection: $folderSelection) {
Text("Inbox").tag("inbox")
Text("Following").tag("following")
}
.pickerStyle(MenuPickerStyle())
.onChange(of: folderSelection) { newValue in
Task {
viewModel.showOperationToast = true
await viewModel.updateEmail(dataService: dataService, email: email, folder: newValue)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
viewModel.showOperationToast = false
}
}
}
}
.navigationTitle(LocalText.emailsGeneric)
}
}
struct MessageToast: View {
var body: some View {
VStack {
HStack {
Text("Address copied")
Spacer()
}
.padding(10)
.frame(minHeight: 50)
.frame(maxWidth: 380)
.background(Color(hex: "2A2A2A"))
.cornerRadius(4.0)
.tint(Color.green)
}
.padding(.bottom, 70)
.padding(.horizontal, 10)
.ignoresSafeArea(.all, edges: .bottom)
}
}

View file

@ -25,8 +25,10 @@ import Views
loadProfileCardData(name: name, username: username, profileImageURL: currentViewer.profileImageURL)
}
if let viewer = try? await dataService.fetchViewer() {
loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL)
if profileCardData.name.isEmpty {
if let viewer = try? await dataService.fetchViewer() {
loadProfileCardData(name: viewer.name, username: viewer.username, profileImageURL: viewer.profileImageURL)
}
}
}
@ -62,12 +64,13 @@ struct ProfileView: View {
@StateObject private var viewModel = ProfileContainerViewModel()
@State var shouldScrollToTop = false
@State private var showLogoutConfirmation = false
var body: some View {
#if os(iOS)
Form {
innerBody
List {
innerBody.tag("TOP")
}
.toolbar {
toolbarItems
@ -123,6 +126,7 @@ struct ProfileView: View {
Group {
Section {
ProfileCard(data: viewModel.profileCardData)
.tag("PROFILE")
.task {
await viewModel.loadProfileData(dataService: dataService)
}
@ -194,7 +198,7 @@ struct ProfileView: View {
primaryButton: .destructive(Text(LocalText.genericConfirm)) {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
authenticator.logout(dataService: dataService)
authenticator.beginLogout()
}
},
secondaryButton: .cancel()

View file

@ -28,6 +28,8 @@ import Views
struct PushNotificationDevicesView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel = PushNotificationDevicesViewModel()
var body: some View {
@ -43,6 +45,9 @@ struct PushNotificationDevicesView: View {
.listStyle(InsetListStyle())
#endif
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
.task { viewModel.loadDevices(dataService: dataService) }
}
@ -58,6 +63,14 @@ struct PushNotificationDevicesView: View {
}
private var innerBody: some View {
if viewModel.devices.isEmpty {
AnyView(Text("No devices registered"))
} else {
AnyView(deviceList)
}
}
private var deviceList: some View {
List {
Section(header: Text(LocalText.devicesTokensTitle)) {
ForEach(viewModel.devices) { device in

View file

@ -47,6 +47,8 @@
struct PushNotificationSettingsView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel = PushNotificationSettingsViewModel()
@State var desiredNotificationsEnabled: Bool = false
@ -63,6 +65,9 @@
.listStyle(InsetListStyle())
#endif
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
.task { viewModel.checkPushNotificationsStatus() }
}

View file

@ -0,0 +1,36 @@
import Foundation
import Models
import Services
import SwiftUI
@MainActor
public struct SelectBadgeFilterView: View {
@ObservedObject var viewModel: FiltersViewModel
public var body: some View {
List {
Section(header: Text("Filter")) {
ForEach(viewModel.libraryFilters) { filter in
Button {
viewModel.badgeFilter = filter.filter
} label: {
HStack {
Text(filter.name)
Spacer()
if isSelected(filter) {
Image(systemName: "checkmark")
}
}.onTapGesture {}
}.contentShape(Rectangle())
}
}
Section {
Text("Your selected filter will be used to display a badge value on the application icon.")
}
}.navigationTitle("Badge Filter")
}
func isSelected(_ filter: InternalFilter) -> Bool {
viewModel.badgeFilter == filter.filter
}
}

View file

@ -1,173 +0,0 @@
import Models
import Services
import SwiftUI
import Views
@MainActor final class SubscriptionsViewModel: ObservableObject {
@Published var isLoading = true
@Published var subscriptions = [Subscription]()
@Published var popularSubscriptions = [Subscription]()
@Published var hasNetworkError = false
@Published var subscriptionNameToCancel: String?
func loadSubscriptions(dataService: DataService) async {
isLoading = true
do {
subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active }
} catch {
hasNetworkError = true
}
isLoading = false
}
func cancelSubscription(dataService: DataService) async -> Bool {
guard let subscriptionName = subscriptionNameToCancel else { return false }
do {
try await dataService.deleteSubscription(subscriptionName: subscriptionName)
let index = subscriptions.firstIndex { $0.name == subscriptionName }
if let index = index {
subscriptions.remove(at: index)
}
return true
} catch {
appLogger.debug("failed to remove subscription")
return false
}
}
}
struct SubscriptionsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = SubscriptionsViewModel()
@State private var deleteConfirmationShown = false
@State private var progressViewOpacity = 0.0
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
.opacity(progressViewOpacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
progressViewOpacity = 1
}
}
.task { await viewModel.loadSubscriptions(dataService: dataService) }
} else if viewModel.hasNetworkError {
VStack {
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
Button(
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
label: { Text(LocalText.genericRetry) }
)
.buttonStyle(RoundedRectButtonStyle())
}
} else if viewModel.subscriptions.isEmpty {
VStack(alignment: .center) {
Spacer()
Text(LocalText.subscriptionsNone)
Spacer()
}
} else {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
}
}
.navigationTitle("Subscriptions")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private var innerBody: some View {
Group {
ForEach(viewModel.subscriptions, id: \.subscriptionID) { subscription in
SubscriptionCell(subscription: subscription)
.swipeActions(edge: .trailing) {
Button(
role: .destructive,
action: {
deleteConfirmationShown = true
viewModel.subscriptionNameToCancel = subscription.name
},
label: {
Image(systemName: "trash")
}
)
}
}
}
.alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) {
Button("Yes", role: .destructive) {
Task {
let unsubscribed = await viewModel.cancelSubscription(dataService: dataService)
// Snackbar.show(message: unsubscribed ? "Subscription cancelled." : "Could not unsubscribe.")
}
}
Button("No", role: .cancel) {
viewModel.subscriptionNameToCancel = nil
}
}
.navigationTitle(LocalText.subscriptionsGeneric)
}
}
struct SubscriptionCell: View {
let subscription: Subscription
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text(subscription.name)
.font(.appCallout)
.lineSpacing(1.25)
.foregroundColor(.appGrayTextContrast)
.fixedSize(horizontal: false, vertical: true)
if let updatedDate = subscription.updatedAt {
Text("Last received: \(updatedDate.formatted())")
.font(.appCaption)
.foregroundColor(.appGrayText)
.fixedSize(horizontal: false, vertical: true)
}
}
.multilineTextAlignment(.leading)
.padding(.vertical, 8)
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)
}
}
}

View file

@ -0,0 +1,606 @@
import CoreData
import Models
import Services
import SwiftUI
import Transmission
import Views
@MainActor
struct ToastOperationHandler {
let performOperation: (_: Sendable?) -> Void
let update: (_: OperationStatus, _: String) -> Void
}
typealias OperationStatusHandler = (_: OperationStatus) -> Void
@MainActor final class SubscriptionsViewModel: ObservableObject {
@Published var isLoading = true
@Published var feeds = [Subscription]()
@Published var newsletters = [Subscription]()
@Published var rules: [Rule]?
@Published var labels: [LinkedItemLabel]?
@Published var hasNetworkError = false
@Published var subscriptionNameToCancel: String?
@Published var presentingSubscription: Subscription?
@Published var showOperationToast = false
@Published var operationStatus: OperationStatus = .none
@Published var operationMessage: String?
func loadSubscriptions(dataService: DataService) async {
isLoading = true
do {
let subscriptions = try await dataService.subscriptions().filter { $0.status == SubscriptionStatus.active }
feeds = subscriptions.filter { $0.type == .feed }
newsletters = subscriptions.filter { $0.type == .newsletter }
hasNetworkError = false
} catch {
print("error fetching subscriptions: ", error)
hasNetworkError = true
}
do {
// Also try to get the rules for auto labeling
rules = try await dataService.rules()
} catch {
print("error fetching rules and labels", error)
rules = []
}
await loadLabels(dataService: dataService)
isLoading = false
}
func loadLabels(dataService: DataService) async {
_ = try? await dataService.labels()
await loadLabelsFromStore(dataService: dataService)
}
func loadLabelsFromStore(dataService: DataService) async {
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
let fetchedLabels = await dataService.viewContext.perform {
try? fetchRequest.execute()
}
labels = fetchedLabels ?? []
}
func cancelSubscription(dataService: DataService, subscription: Subscription) async {
operationMessage = "Unsubscribing..."
operationStatus = .isPerforming
do {
try await dataService.deleteSubscription(subscriptionName: subscription.name, subscriptionId: subscription.subscriptionID)
var list = subscription.type == .feed ? feeds : newsletters
let index = list.firstIndex { $0.subscriptionID == subscription.subscriptionID }
if let index = index {
list.remove(at: index)
switch subscription.type {
case .feed:
feeds = list
case .newsletter:
newsletters = list
}
}
operationMessage = "Unsubscribed"
operationStatus = .success
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
self.showOperationToast = false
}
} catch {
appLogger.debug("failed to remove subscription")
operationMessage = "Failed to unsubscribe"
operationStatus = .failure
}
}
func updateSubscription(dataService: DataService, subscription: Subscription, folder: String? = nil, fetchContent: Bool? = nil) async {
operationMessage = "Updating subscription..."
operationStatus = .isPerforming
do {
try await dataService.updateSubscription(subscription.subscriptionID, folder: folder, fetchContent: fetchContent)
operationMessage = "Subscription updated"
operationStatus = .success
} catch {
operationMessage = "Failed to update subscription"
operationStatus = .failure
}
}
func setLabelsRule(dataService: DataService, existingRule: Rule?, ruleName: String, filter: String, labelIDs: [String]) async {
Task {
operationMessage = "Creating label rule..."
operationStatus = .isPerforming
do {
// Make sure the labels have been created
await loadLabels(dataService: dataService)
let existingLabelIDs = labels?.map(\.unwrappedID) ?? []
if labelIDs.first(where: { !existingLabelIDs.contains($0) }) != nil {
throw BasicError.message(messageText: "Label not created")
}
_ = try await dataService.createOrUpdateAddLabelsRule(
existingID: existingRule?.id,
name: ruleName,
filter: filter,
labelIDs: labelIDs
)
if let newRules = try? await dataService.rules() {
if !newRules.contains(where: { $0.name == ruleName }) {
throw BasicError.message(messageText: "Rule not created")
}
rules = newRules
}
operationMessage = "Rule created"
operationStatus = .success
} catch {
operationMessage = "Failed to create label rule"
operationStatus = .failure
}
}
}
}
struct SubscriptionsView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel = SubscriptionsViewModel()
@State private var deleteConfirmationShown = false
@State private var showDeleteCompleted = false
@State private var showAddFeedView = false
var body: some View {
Group {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) {
OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus)
} label: {
EmptyView()
}.buttonStyle(.plain)
if viewModel.feeds.isEmpty, viewModel.newsletters.isEmpty, viewModel.isLoading {
ProgressView()
} else if viewModel.hasNetworkError {
VStack {
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
Button(
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
label: { Text(LocalText.genericRetry) }
)
.buttonStyle(RoundedRectButtonStyle())
}
} else if viewModel.feeds.isEmpty, viewModel.newsletters.isEmpty {
VStack(alignment: .center) {
Spacer()
Text(LocalText.subscriptionsNone)
Spacer()
}
} else {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
.sheet(isPresented: $showAddFeedView) {
let handler = ToastOperationHandler(performOperation: { sendable in
self.viewModel.showOperationToast = true
Task {
_ = await sendable
viewModel.isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
Task {
await self.viewModel.loadSubscriptions(dataService: dataService)
self.viewModel.showOperationToast = false
}
}
}
}, update: { state, text in
viewModel.operationStatus = state
viewModel.operationMessage = text
})
NavigationView {
LibraryAddFeedView(dismiss: {
showAddFeedView = false
}, toastOperationHandler: handler)
.navigationViewStyle(.stack)
}
.navigationViewStyle(.stack)
}
.task {
await viewModel.loadSubscriptions(dataService: dataService)
}
.refreshable {
Task {
await viewModel.loadSubscriptions(dataService: dataService)
}
}
.onDisappear {
viewModel.showOperationToast = false
}
.navigationTitle("Subscriptions")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private var innerBody: some View {
Group {
Section("Feeds") {
if viewModel.feeds.count <= 0, !viewModel.isLoading {
VStack(alignment: .center, spacing: 20) {
Text("You don't have any Feed items.")
.font(Font.system(size: 18, weight: .bold))
Text("Add an RSS/Atom feed")
.foregroundColor(Color.blue)
.onTapGesture {
showAddFeedView = true
}
}
.frame(minHeight: 80)
.frame(maxWidth: .infinity)
.padding()
} else {
ForEach(viewModel.feeds, id: \.subscriptionID) { subscription in
PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium])) {
SubscriptionSettingsView(
subscription: subscription,
viewModel: viewModel,
dataService: dataService,
prefetchContent: subscription.fetchContent,
folderSelection: subscription.folder,
unsubscribe: { _ in
viewModel.operationStatus = .isPerforming
viewModel.showOperationToast = true
Task {
await viewModel.cancelSubscription(dataService: dataService, subscription: subscription)
}
}
)
} label: {
SubscriptionCell(subscription: subscription)
}
}
Button(action: { showAddFeedView = true }, label: {
Label(title: {
Text("Add a feed")
}, icon: {
Image.addLink
})
})
}
}
if viewModel.newsletters.count > 0, !viewModel.isLoading {
Section("Newsletters") {
ForEach(viewModel.newsletters, id: \.subscriptionID) { subscription in
PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium])) {
SubscriptionSettingsView(
subscription: subscription,
viewModel: viewModel,
dataService: dataService,
prefetchContent: subscription.fetchContent,
folderSelection: subscription.folder,
unsubscribe: { _ in
viewModel.operationStatus = .isPerforming
viewModel.showOperationToast = true
Task {
await viewModel.cancelSubscription(dataService: dataService, subscription: subscription)
}
}
)
} label: {
SubscriptionCell(subscription: subscription)
}
}
}
}
}
.navigationTitle(LocalText.subscriptionsGeneric)
}
}
struct SubscriptionRow<Content: View>: View {
let subscription: Subscription
let useImageSpacer: Bool
@ViewBuilder let trailingButton: Content
var body: some View {
HStack {
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 {
Color.clear.frame(width: 40, height: 40, alignment: .top)
} else {
Color.clear
.frame(width: 40, height: 40)
.cornerRadius(2)
}
}
} else if useImageSpacer {
Color.clear
.frame(width: 40, height: 40)
.cornerRadius(2)
}
}.padding(.trailing, 10)
VStack(alignment: .leading, spacing: 6) {
Text(subscription.name)
.font(.appCallout)
.lineSpacing(1.25)
.foregroundColor(.appGrayTextContrast)
.fixedSize(horizontal: false, vertical: true)
if let updatedDate = subscription.updatedAt {
Text("Last received: \(updatedDate.formatted())")
.font(.appCaption)
.foregroundColor(.appGrayText)
.fixedSize(horizontal: false, vertical: true)
}
}
.multilineTextAlignment(.leading)
.padding(.vertical, 8)
Spacer()
trailingButton
}.frame(minHeight: 50)
}
}
struct SubscriptionCell: View {
let subscription: Subscription
var body: some View {
SubscriptionRow(subscription: subscription, useImageSpacer: true, trailingButton: {
Image(systemName: "ellipsis")
})
}
}
@MainActor
struct SubscriptionSettingsView: View {
let subscription: Subscription
let viewModel: SubscriptionsViewModel
let dataService: DataService
@State var prefetchContent = false
@State var deleteConfirmationShown = false
@State var showDeleteCompleted = false
@State var folderSelection: String = ""
@State var showLabelsSelector = false
@State var isLoadingRule = false
let unsubscribe: (_: Subscription) -> Void
@Environment(\.dismiss) private var dismiss
var existingRule: Rule? {
viewModel.rules?.first { $0.name == ruleName }
}
var ruleName: String {
if let url = subscription.url, subscription.type == .newsletter {
return "system.autoLabel.(\(url))"
}
return "system.autoLabel.(\(subscription.name))"
}
var ruleFilter: String {
if let url = subscription.url, subscription.type == .newsletter {
return "rss:\"\(url)\""
}
return "subscription:\"\(subscription.name)\""
}
var ruleLabels: [LinkedItemLabel]? {
if let labelIDs = existingRule?.actions.flatMap(\.params) {
return Array(labelIDs.compactMap { labelID in
viewModel.labels?.first(where: { $0.unwrappedID == labelID })
})
}
return nil
}
var folderRow: some View {
HStack {
Picker("Destination Folder", selection: $folderSelection) {
Text("Inbox").tag("inbox")
Text("Following").tag("following")
}
.pickerStyle(MenuPickerStyle())
.onChange(of: folderSelection) { newValue in
Task {
viewModel.showOperationToast = true
await viewModel.updateSubscription(dataService: dataService, subscription: subscription, folder: newValue)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
viewModel.showOperationToast = false
}
}
}
.onChange(of: prefetchContent) { newValue in
Task {
viewModel.showOperationToast = true
await viewModel.updateSubscription(
dataService: dataService,
subscription: subscription,
fetchContent: newValue
)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
viewModel.showOperationToast = false
}
}
}
}
}
var feedRow: some View {
VStack {
Text("Feed URL")
.frame(maxWidth: .infinity, alignment: .leading)
Text(subscription.url ?? "")
.foregroundColor(Color.appGrayText)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
}
.contextMenu(ContextMenu(menuItems: {
Button(action: {
#if os(iOS)
UIPasteboard.general.string = subscription.url
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([subscription.url as NSString])
#endif
}, label: { Text("Copy URL") })
}))
}
var emailRow: some View {
VStack {
Text("Received by")
.frame(maxWidth: .infinity, alignment: .leading)
Text(subscription.newsletterEmailAddress ?? "")
.foregroundColor(Color.appGrayText)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
}
.contextMenu(ContextMenu(menuItems: {
Button(action: {
#if os(iOS)
UIPasteboard.general.string = subscription.newsletterEmailAddress
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([subscription.newsletterEmailAddress as NSString])
#endif
}, label: { Text("Copy Address") })
}))
}
var labelRuleRow: some View {
HStack {
Text("Add Labels")
Spacer()
if isLoadingRule || viewModel.rules != nil {
Button(action: { showLabelsSelector = true }, label: {
if let ruleLabels = ruleLabels {
let labelNames = ruleLabels.map(\.unwrappedName)
Text("[\(labelNames.joined(separator: ","))]")
} else {
Text("Create Rule")
}
}).tint(Color.blue)
} else {
ProgressView()
}
}
}
var body: some View {
VStack {
SubscriptionRow(subscription: subscription, useImageSpacer: false, trailingButton: {
Button(action: {
dismiss()
}, label: {
ZStack {
Circle()
.foregroundColor(Color.circleButtonBackground)
.frame(width: 30, height: 30)
Image(systemName: "xmark")
.resizable(resizingMode: Image.ResizingMode.stretch)
.foregroundColor(Color.circleButtonForeground)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.bold))
.frame(width: 12, height: 12)
}
})
})
.padding(.top, 15)
.padding(.horizontal, 15)
List {
// if subscription.type != .newsletter {
// Toggle(isOn: $prefetchContent, label: { Text("Prefetch Content:") })
// .onAppear {
// prefetchContent = subscription.fetchContent
// }
// }
folderRow
labelRuleRow
if subscription.type == .feed {
feedRow
}
if subscription.type == .newsletter {
emailRow
}
}.listStyle(.insetGrouped)
Spacer()
Button("Unsubscribe", role: .destructive) { deleteConfirmationShown = true }
.frame(maxWidth: .infinity)
.buttonStyle(RoundedRectButtonStyle(color: Color.red, textColor: Color.white))
}
.frame(width: UIDevice.isIPad ? 400 : nil, height: UIDevice.isIPad ? 300 : nil)
.alert("Are you sure you want to cancel this subscription?", isPresented: $deleteConfirmationShown) {
Button("Yes", role: .destructive) {
dismiss()
unsubscribe(subscription)
}
Button("No", role: .cancel) {
viewModel.subscriptionNameToCancel = nil
}
}
.sheet(isPresented: $showLabelsSelector) {
ApplyLabelsView(mode: .list(ruleLabels ?? []), onSave: { labels in
Task {
isLoadingRule = true
viewModel.showOperationToast = true
await viewModel.setLabelsRule(
dataService: dataService,
existingRule: existingRule,
ruleName: ruleName,
filter: ruleFilter,
labelIDs: labels.map(\.unwrappedID)
)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
viewModel.showOperationToast = false
isLoadingRule = true
}
}
})
}
}
}

View file

@ -6,6 +6,7 @@
struct TextToSpeechLanguageView: View {
@EnvironmentObject var audioController: AudioController
@Environment(\.dismiss) private var dismiss
var body: some View {
Group {
@ -20,6 +21,9 @@
.listStyle(InsetListStyle())
#endif
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
}
private var innerBody: some View {

View file

@ -7,6 +7,7 @@
// swiftlint:disable line_length
struct TextToSpeechView: View {
@EnvironmentObject var audioController: AudioController
@Environment(\.dismiss) private var dismiss
var body: some View {
Group {
@ -20,6 +21,9 @@
innerBody
}
}.navigationTitle(LocalText.textToSpeechGeneric)
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
}
private var innerBody: some View {

View file

@ -8,6 +8,7 @@
struct TextToSpeechVoiceSelectionView: View {
@EnvironmentObject var audioController: AudioController
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel = TextToSpeechVoiceSelectionViewModel()
@ -87,6 +88,8 @@
} else if !value {
audioController.useUltraRealisticVoices = false
}
}.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
dismiss()
}
}

View file

@ -51,10 +51,17 @@ struct InnerRootView: View {
@ViewBuilder private var innerBody: some View {
if authenticator.isLoggedIn {
PrimaryContentView()
.task {
try? await dataService.syncOfflineItemsWithServerIfNeeded()
}
} else {
WelcomeView()
.accessibilityElement()
.accessibilityIdentifier("welcomeView")
if authenticator.isLoggingOut {
LogoutView()
} else {
WelcomeView()
.accessibilityElement()
.accessibilityIdentifier("welcomeView")
}
}
}

View file

@ -11,6 +11,7 @@ import Views
let isMacApp = true
#endif
@MainActor
public final class RootViewModel: ObservableObject {
let services = Services()

View file

@ -90,6 +90,16 @@ struct SelfHostSettingsView: View {
.accentColor(.blue)
.frame(maxWidth: .infinity, alignment: .leading)
Button(action: {
AppEnvironment.setCustom(serverBaseURL: "https://api-prod.omnivore.app",
webAppBaseURL: "https://omnivore.app",
ttsBaseURL: "https://tts.omnivore.app")
dataService.switchAppEnvironment(appEnvironment: AppEnvironment.prod)
dismiss()
}, label: {
Text("Reset self hosting settings")
})
#if os(macOS)
Spacer()
HStack {

View file

@ -20,7 +20,6 @@ struct WebReader: PlatformViewRepresentable {
@Binding var showNavBarActionID: UUID?
@Binding var shareActionID: UUID?
@Binding var annotation: String
@Binding var showBottomBar: Bool
@Binding var showHighlightAnnotationModal: Bool
func makeCoordinator() -> WebReaderCoordinator {
@ -91,9 +90,6 @@ struct WebReader: PlatformViewRepresentable {
context.coordinator.webViewActionHandler = webViewActionHandler
context.coordinator.updateNavBarVisibility = navBarVisibilityUpdater
context.coordinator.scrollPercentHandler = scrollPercentHandler
context.coordinator.updateShowBottomBar = { newValue in
self.showBottomBar = newValue
}
context.coordinator.articleContentID = articleContent.id
loadContent(webView: webView)

View file

@ -3,6 +3,7 @@ import Models
import PopupView
import Services
import SwiftUI
import Transmission
import Utils
import Views
import WebKit
@ -28,7 +29,6 @@ struct WebReaderContainerView: View {
@State var showExpandedAudioPlayer = false
@State var shareActionID: UUID?
@State var annotation = String()
@State var showBottomBar = false
@State private var bottomBarOpacity = 0.0
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@ -88,7 +88,6 @@ struct WebReaderContainerView: View {
private func tapHandler() {
withAnimation(.easeIn(duration: 0.08)) {
navBarVisible = !navBarVisible
showBottomBar = navBarVisible
showNavBarActionID = UUID()
}
}
@ -112,13 +111,11 @@ struct WebReaderContainerView: View {
case "pageTapped":
withAnimation {
navBarVisible = !navBarVisible
showBottomBar = navBarVisible
showNavBarActionID = UUID()
}
case "dismissNavBars":
withAnimation {
navBarVisible = false
showBottomBar = false
showNavBarActionID = UUID()
}
default:
@ -132,73 +129,45 @@ struct WebReaderContainerView: View {
return AnyView(ProgressView()
.padding(.horizontal))
} else {
return AnyView(Button(
action: {
switch audioController.state {
case .playing:
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.pause()
return
return AnyView(
Button(
action: {
switch audioController.state {
case .playing:
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.pause()
return
}
fallthrough
case .paused:
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.unpause()
return
}
fallthrough
default:
audioController.play(itemAudioProperties: item.audioProperties)
}
fallthrough
case .paused:
if audioController.itemAudioProperties?.itemID == self.item.unwrappedID {
audioController.unpause()
return
}
fallthrough
default:
audioController.play(itemAudioProperties: item.audioProperties)
},
label: {
textToSpeechButtonImage
}
},
label: {
textToSpeechButtonImage
}
))
).buttonStyle(.plain)
)
}
}
var textToSpeechButtonImage: some View {
if audioController.playbackError || audioController.state == .stopped || audioController.itemAudioProperties?.itemID != self.item.id {
return AnyView(Image.headphones)
return AnyView(Image.audioPlay.frame(width: 48, height: 48))
}
let name = audioController.isPlayingItem(itemID: item.unwrappedID) ? "pause.circle" : "play.circle"
return AnyView(Image(systemName: name).font(.appNavbarIcon))
if audioController.isPlayingItem(itemID: item.unwrappedID) {
return AnyView(Image.audioPause.frame(width: 48, height: 48))
}
return AnyView(Image.audioPlay.frame(width: 48, height: 48))
}
#endif
var bottomButtons: some View {
HStack(alignment: .center) {
Button(action: archive, label: {
item.isArchived ? Image.unarchive : Image.archive
}).frame(width: 48, height: 48)
.padding(.leading, 8)
Divider().opacity(0.8)
Button(action: delete, label: {
Image.remove
}).frame(width: 48, height: 48)
Divider().opacity(0.8)
Button(action: editLabels, label: {
Image.label
}).frame(width: 48, height: 48)
Divider().opacity(0.8)
Button(action: recommend, label: {
Image(systemName: "sparkles")
}).frame(width: 48, height: 48)
// We don't have a single note function yet
// Divider()
//
// Button(action: addNote, label: {
// Image(systemName: "note")
// }).frame(width: 48, height: 48)
.padding(.trailing, 8)
}.foregroundColor(.appGrayTextContrast)
}
func audioMenuItem() -> some View {
Button(
action: {
@ -291,6 +260,8 @@ struct WebReaderContainerView: View {
.padding(.vertical)
}
)
.buttonStyle(.plain)
Spacer()
#endif
@ -300,6 +271,7 @@ struct WebReaderContainerView: View {
Image.label
}
)
.buttonStyle(.plain)
.padding(.trailing, 4)
Button(
@ -308,6 +280,7 @@ struct WebReaderContainerView: View {
Image.notebook
}
)
.buttonStyle(.plain)
.padding(.trailing, 4)
#if os(iOS)
@ -325,6 +298,7 @@ struct WebReaderContainerView: View {
Image.readerSettings
}
)
.buttonStyle(.plain)
.padding(.horizontal, 5)
.popover(isPresented: $showPreferencesPopover) {
webPreferencesPopoverView
@ -351,6 +325,7 @@ struct WebReaderContainerView: View {
#endif
}
)
.buttonStyle(.plain)
#if os(macOS)
.frame(maxWidth: 100)
.padding(.trailing, 16)
@ -361,7 +336,7 @@ struct WebReaderContainerView: View {
.tint(Color(hex: "#2A2A2A"))
.frame(height: readerViewNavBarHeight)
.frame(maxWidth: .infinity)
.foregroundColor(ThemeManager.currentTheme.isDark ? .white : .black)
.foregroundColor(ThemeManager.currentTheme.toolbarColor)
.background(ThemeManager.currentBgColor)
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { labels in
@ -380,7 +355,7 @@ struct WebReaderContainerView: View {
#if os(iOS)
.sheet(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
NotebookView(
itemObjectID: item.objectID,
viewModel: NotebookViewModel(item: item),
hasHighlightMutations: $hasPerformedHighlightMutations
)
}
@ -401,6 +376,12 @@ struct WebReaderContainerView: View {
var body: some View {
ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $viewModel.showOperationToast) {
OperationToast(operationMessage: $viewModel.operationMessage, showOperationToast: $viewModel.showOperationToast, operationStatus: $viewModel.operationStatus)
} label: {
EmptyView()
}.buttonStyle(.plain)
if let articleContent = viewModel.articleContent {
WebReader(
item: item,
@ -431,7 +412,6 @@ struct WebReaderContainerView: View {
showNavBarActionID: $showNavBarActionID,
shareActionID: $shareActionID,
annotation: $annotation,
showBottomBar: $showBottomBar,
showHighlightAnnotationModal: $showHighlightAnnotationModal
)
.background(ThemeManager.currentBgColor)
@ -473,7 +453,17 @@ struct WebReaderContainerView: View {
.ignoresSafeArea(.all, edges: .bottom)
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer()
ExpandedAudioPlayer(delete: { _ in
showExpandedAudioPlayer = false
audioController.stop()
delete()
}, archive: { _ in
showExpandedAudioPlayer = false
audioController.stop()
archive()
}, viewArticle: { _ in
showExpandedAudioPlayer = false
})
}
#endif
.alert(errorAlertMessage ?? LocalText.readerError, isPresented: $showErrorAlertMessage) {
@ -592,30 +582,26 @@ struct WebReaderContainerView: View {
.offset(y: navBarVisible ? 0 : -150)
Spacer()
if showBottomBar {
bottomButtons
.frame(height: 48)
.background(Color.webControlButtonBackground)
.cornerRadius(6)
.padding(.bottom, 34)
.shadow(color: .gray.opacity(0.13), radius: 8, x: 0, y: 4)
.opacity(bottomBarOpacity)
.onAppear {
withAnimation(Animation.linear(duration: 0.25)) { self.bottomBarOpacity = 1 }
}
.onDisappear {
self.bottomBarOpacity = 0
}
}
if let audioProperties = audioController.itemAudioProperties {
MiniPlayerViewer(itemAudioProperties: audioProperties)
.padding(.top, 10)
.padding(.bottom, 40)
.padding(.bottom, navBarVisible ? 10 : 40)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
}
}
if navBarVisible {
CustomToolBar(
isFollowing: item.folder == "following",
isArchived: item.isArchived,
moveToInboxAction: moveToInbox,
archiveAction: archive,
unarchiveAction: archive,
shareAction: share,
deleteAction: delete
)
}
}
#endif
@ -656,8 +642,28 @@ struct WebReaderContainerView: View {
}
}
func moveToInbox() {
Task {
viewModel.showOperationToast = true
viewModel.operationMessage = "Moving to library..."
viewModel.operationStatus = .isPerforming
do {
try await dataService.moveItem(itemID: item.unwrappedID, folder: "inbox")
viewModel.operationMessage = "Moved to library"
viewModel.operationStatus = .success
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1500)) {
viewModel.showOperationToast = false
}
} catch {
viewModel.operationMessage = "Error moving"
viewModel.operationStatus = .failure
}
}
}
func archive() {
dataService.archiveLink(objectID: item.objectID, archived: !item.isArchived)
let isArchived = item.isArchived
dataService.archiveLink(objectID: item.objectID, archived: !isArchived)
#if os(iOS)
pop()
#endif
@ -687,10 +693,10 @@ struct WebReaderContainerView: View {
}
func delete() {
removeLibraryItemAction(dataService: dataService, objectID: item.objectID)
pop()
#if os(iOS)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
pop()
removeLibraryItemAction(dataService: dataService, objectID: item.objectID)
}
#endif
}

View file

@ -20,7 +20,6 @@ final class WebReaderCoordinator: NSObject {
var previousShowNavBarActionID: UUID?
var previousShareActionID: UUID?
var updateNavBarVisibility: (Bool) -> Void = { _ in }
var updateShowBottomBar: (Bool) -> Void = { _ in }
var articleContentID = UUID()
private var yOffsetAtStartOfDrag: Double?
private var lastYOffset: Double = 0
@ -122,10 +121,10 @@ extension WebReaderCoordinator: WKNavigationDelegate {
scrollView.contentInset.top = navBarVisible ? readerViewNavBarHeight : 0
}
// if at bottom show the controls
if yOffset + scrollView.visibleSize.height > scrollView.contentSize.height - 140 {
updateShowBottomBar(true)
} else {
updateShowBottomBar(false)
navBarVisible = true
scrollView.contentInset.top = navBarVisible ? readerViewNavBarHeight : 0
}
let percent = Int(((yOffset + scrollView.visibleSize.height) / scrollView.contentSize.height) * 100)

View file

@ -25,6 +25,7 @@ import Views
.linkRead(
linkID: item.unwrappedID,
slug: item.unwrappedSlug,
reader: "WEB",
originalArticleURL: item.unwrappedPageURLString
)
)
@ -54,6 +55,8 @@ public struct WebReaderLoadingContainer: View {
PDFWrapperView(pdfURL: pdfURL)
}
#endif
} else if item.state == "CONTENT_NOT_FETCHED" {
ProgressView()
} else {
WebReaderContainerView(item: item, pop: { dismiss() })
#if os(iOS)

View file

@ -16,6 +16,10 @@ struct SafariWebLink: Identifiable {
@Published var isDownloadingAudio: Bool = false
@Published var audioDownloadTask: Task<Void, Error>?
@Published var operationMessage: String?
@Published var showOperationToast: Bool = false
@Published var operationStatus: OperationStatus = .none
@Published var showSnackbar: Bool = false
var snackbarOperation: SnackbarOperation?

View file

@ -149,6 +149,7 @@ struct WelcomeView: View {
.sheet(isPresented: $showAboutPage) {
if let url = URL(string: "https://omnivore.app/about") {
SafariView(url: url)
.ignoresSafeArea(.all, edges: .bottom)
}
}
.onTapGesture {

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B81" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Filter" representedClassName="Filter" syncable="YES" codeGenerationType="class">
<attribute name="defaultFilter" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="filter" optional="YES" attributeType="String"/>
@ -96,8 +96,10 @@
</entity>
<entity name="NewsletterEmail" representedClassName="NewsletterEmail" syncable="YES" codeGenerationType="class">
<attribute name="confirmationCode" optional="YES" attributeType="String"/>
<attribute name="descriptionNote" optional="YES" attributeType="String"/>
<attribute name="email" attributeType="String"/>
<attribute name="emailId" attributeType="String"/>
<attribute name="folder" optional="YES" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="emailId"/>

View file

@ -5,10 +5,12 @@ import Utils
public struct LinkedItemQueryResult {
public let itemIDs: [NSManagedObjectID]
public let cursor: String?
public let totalCount: Int?
public init(itemIDs: [NSManagedObjectID], cursor: String?) {
public init(itemIDs: [NSManagedObjectID], cursor: String?, totalCount: Int?) {
self.itemIDs = itemIDs
self.cursor = cursor
self.totalCount = totalCount
}
}
@ -17,13 +19,21 @@ public struct LinkedItemSyncResult {
public let cursor: String?
public let hasMore: Bool
public let mostRecentUpdatedAt: Date?
public let oldestUpdatedAt: Date?
public let isEmpty: Bool
public init(updatedItemIDs: [String], cursor: String?, hasMore: Bool, mostRecentUpdatedAt: Date?, isEmpty: Bool) {
public init(updatedItemIDs: [String],
cursor: String?,
hasMore: Bool,
mostRecentUpdatedAt: Date?,
oldestUpdatedAt: Date?,
isEmpty: Bool)
{
self.updatedItemIDs = updatedItemIDs
self.cursor = cursor
self.hasMore = hasMore
self.mostRecentUpdatedAt = mostRecentUpdatedAt
self.oldestUpdatedAt = oldestUpdatedAt
self.isEmpty = isEmpty
}
}
@ -32,6 +42,7 @@ public struct LinkedItemAudioProperties {
public let itemID: String
public let objectID: NSManagedObjectID
public let title: String
public var isArchived: Bool
public let byline: String?
public let imageURL: URL?
public let language: String?
@ -240,6 +251,7 @@ public extension LibraryItem {
itemID: unwrappedID,
objectID: objectID,
title: unwrappedTitle,
isArchived: isArchived,
byline: formattedByline,
imageURL: imageURL,
language: language,

View file

@ -1,8 +1,10 @@
import CoreData
import Foundation
import Models
import Utils
public struct PDFItem {
public let item: Models.LibraryItem
public let objectID: NSManagedObjectID
public let itemID: String
public let pdfURL: URL?
@ -21,6 +23,7 @@ public struct PDFItem {
guard item.isPDF else { return nil }
return PDFItem(
item: item,
objectID: item.objectID,
itemID: item.unwrappedID,
pdfURL: URL(string: item.unwrappedPageURLString),

View file

@ -6,5 +6,4 @@ public enum ServerSyncStatus: Int {
case needsDeletion
case needsCreation
case needsUpdate
case needsMove
}

View file

@ -1,10 +1,14 @@
import Foundation
import SwiftGraphQL
public struct Subscription {
public let createdAt: Date?
public let description: String?
public let subscriptionID: String
public let name: String
public let type: SubscriptionType
public let folder: String
public let fetchContent: Bool
public let newsletterEmailAddress: String?
public let status: SubscriptionStatus
public let unsubscribeHttpUrl: String?
@ -18,6 +22,9 @@ public struct Subscription {
description: String?,
subscriptionID: String,
name: String,
type: SubscriptionType,
folder: String,
fetchContent: Bool,
newsletterEmailAddress: String?,
status: SubscriptionStatus,
unsubscribeHttpUrl: String?,
@ -30,6 +37,9 @@ public struct Subscription {
self.description = description
self.subscriptionID = subscriptionID
self.name = name
self.type = type
self.folder = folder
self.fetchContent = fetchContent
self.newsletterEmailAddress = newsletterEmailAddress
self.status = status
self.unsubscribeHttpUrl = unsubscribeHttpUrl
@ -45,3 +55,8 @@ public enum SubscriptionStatus {
case deleted
case unsubscribed
}
public enum SubscriptionType {
case newsletter
case feed
}

View file

@ -172,7 +172,9 @@ public extension FeaturedItemFilter {
var predicate: NSPredicate {
let undeletedPredicate = NSPredicate(
format: "%K != %i", #keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
format: "%K != %i AND %K != \"DELETED\"",
#keyPath(LibraryItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
#keyPath(LibraryItem.state)
)
let notInArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(LibraryItem.isArchived), Int(truncating: false) as NSNumber

View file

@ -35,6 +35,7 @@ public enum VoiceCategory: String, CaseIterable {
case itIT = "Italian (Italy)"
case esES = "Spanish (Spain)"
case jaJP = "Japanese (Japan)"
case nlNL = "Dutch (Netherlands)"
case ptBR = "Portuguese (Brazil)"
case taIN = "Tamil (India)"
case taLK = "Tamil (Sri Lanka)"
@ -80,6 +81,7 @@ public enum Voices {
VoiceLanguage(key: "it", name: "Italian", defaultVoice: "it-IT-BenignoNeural", categories: [.itIT]),
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES]),
VoiceLanguage(key: "nl", name: "Dutch", defaultVoice: "nl-NL-XiaochenNeural", categories: [.nlNL]),
VoiceLanguage(key: "pt", name: "Portuguese", defaultVoice: "pt-BR-AntonioNeural", categories: [.ptBR]),
VoiceLanguage(key: "ta", name: "Tamil", defaultVoice: "ta-IN-PallaviNeural", categories: [.taIN, .taLK, .taMY, .taSG])
]
@ -113,7 +115,8 @@ public enum Voices {
VoicePair(firstKey: "ta-LK-KumarNeural", secondKey: "ta-LK-SaranyaNeural", firstName: "Kumar", secondName: "Saranya", language: "ta-LK", category: .taLK),
VoicePair(firstKey: "ta-MY-KaniNeural", secondKey: "ta-MY-SuryaNeural", firstName: "Kani", secondName: "Surya", language: "ta-MY", category: .taMY),
VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG),
VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT)
VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT),
VoicePair(firstKey: "nl-NL-MaartenNeural", secondKey: "nl-NL-FennaNeural", firstName: "Maarten", secondName: "Fenna", language: "nl-NL", category: .nlNL)
]
public static let UltraPairs = [

View file

@ -1,3 +1,4 @@
import CoreData
import Foundation
import GoogleSignIn
import Models
@ -18,6 +19,7 @@ public final class Authenticator: ObservableObject {
}
@Published public internal(set) var isLoggedIn: Bool
@Published public internal(set) var isLoggingOut = false
@Published public var showAppleRevokeTokenAlert = false
let networker: Networker
@ -37,13 +39,21 @@ public final class Authenticator: ObservableObject {
ValetKey.authToken.value()
}
public func beginLogout() {
isLoggingOut = true
isLoggedIn = false
}
public func logout(dataService: DataService, isAccountDeletion: Bool = false) {
dataService.resetLocalStorage()
clearCreds()
Authenticator.unregisterIntercomUser?()
isLoggedIn = false
showAppleRevokeTokenAlert = isAccountDeletion
EventTracker.reset()
isLoggedIn = false
isLoggingOut = false
}
public func clearCreds() {

View file

@ -3,27 +3,20 @@ import Foundation
import Models
import Utils
struct PendingLink {
let itemID: String
let retryCount: Int
}
extension DataService {
func prefetchPage(pendingLink: PendingLink, username: String) async {
let content = try? await loadArticleContent(username: username, itemID: pendingLink.itemID, useCache: true)
public func prefetchPage(itemID: String, retryCount: Int, username: String) async {
let content = try? await loadArticleContent(username: username, itemID: itemID, useCache: true)
if content?.contentStatus == .processing, pendingLink.retryCount < 7 {
let retryDelayInNanoSeconds = UInt64(pendingLink.retryCount * 2 * 1_000_000_000)
if content?.contentStatus == .processing, retryCount < 4 {
let retryDelayInNanoSeconds = UInt64(retryCount * 2 * 1_000_000_000)
do {
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
logger.debug("fetching content for \(pendingLink.itemID). retry count: \(pendingLink.retryCount)")
logger.debug("fetching content for \(itemID). retry count: \(retryCount)")
await prefetchPage(
pendingLink: PendingLink(
itemID: pendingLink.itemID,
retryCount: pendingLink.retryCount + 1
),
itemID: itemID,
retryCount: retryCount + 1,
username: username
)
} catch {

View file

@ -1,3 +1,4 @@
import AsyncAlgorithms
import CoreData
import CoreImage
import Foundation
@ -24,6 +25,9 @@ public final class DataService: ObservableObject {
public let appEnvironment: AppEnvironment
public let networker: Networker
public let prefetchQueue = OperationQueue()
public let itemLoaderChannel = AsyncChannel<String>()
var persistentContainer: PersistentContainer
public var backgroundContext: NSManagedObjectContext
@ -128,6 +132,17 @@ public final class DataService: ObservableObject {
}
}
func deleteAllEntities(entityName: String, inContext context: NSManagedObjectContext) {
let deleteFetch = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
let deleteRequest = NSBatchDeleteRequest(fetchRequest: deleteFetch)
do {
try context.execute(deleteRequest)
try context.save()
} catch {
print("Error deleting all \(entityName) items.", error)
}
}
private func clearDownloadedFiles() {
let relevantTypes = ["pdf", "mp3", "speechMarks"]
let fileMgr = FileManager()
@ -176,6 +191,17 @@ public final class DataService: ObservableObject {
}
public func resetLocalStorage() {
viewContext.perform {
// We want to specify the order of deleting items to better handle relationships
let entities = ["LibraryItem", "Viewer", "Filter", "Highlight", "NewsletterEmail",
"LinkedItemLabel", "RecentSearchItem", "Recommendation", "RecommendationGroup", "UserProfile"]
entities.forEach { entityName in
self.deleteAllEntities(entityName: entityName, inContext: self.viewContext)
}
}
UserDefaults.standard.set(nil, forKey: UserDefaultKey.lastSelectedTabItem.rawValue)
lastItemSyncTime = Date(timeIntervalSinceReferenceDate: 0)
clearCoreData()

View file

@ -15,7 +15,7 @@ extension DataService {
// Send update to server
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, archived: archived)
let message = archived ? "Link archived" : "Link moved to Inbox"
let message = archived ? "Link archived" : "Link unarchived"
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
showInLibrarySnackbar(message)
}

View file

@ -20,6 +20,8 @@ public extension DataService {
InternalNewsletterEmail(
emailId: try $0.id(),
email: try $0.address(),
folder: try $0.folder(),
descriptionNote: try $0.description(),
confirmationCode: try $0.confirmationCode()
)
}))

View file

@ -4,7 +4,7 @@ import Models
import SwiftGraphQL
public extension DataService {
func deleteSubscription(subscriptionName: String) async throws {
func deleteSubscription(subscriptionName: String, subscriptionId: String) async throws {
enum MutationResult {
case success(id: String)
case error(errorMessage: String)
@ -20,7 +20,7 @@ public extension DataService {
}
let mutation = Selection.Mutation {
try $0.unsubscribe(name: subscriptionName, selection: selection)
try $0.unsubscribe(name: subscriptionName, subscriptionId: OptionalArgument(subscriptionId), selection: selection)
}
let path = appEnvironment.graphqlPath

View file

@ -0,0 +1,47 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func fetchContent(itemID: String) async throws {
enum MutationResult {
case result(success: Bool)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.FetchContentResult> {
try $0.on(
fetchContentError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
fetchContentSuccess: .init { .result(success: try $0.success()) }
)
}
let mutation = Selection.Mutation {
try $0.fetchContent(id: itemID, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .result(success: success):
if success {
continuation.resume()
} else {
continuation.resume(throwing: BasicError.message(messageText: "Operation failed"))
}
case let .error(errorMessage: errorMessage):
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
}
}
}
}
}

View file

@ -20,10 +20,10 @@ public extension DataService {
}
}
try await syncMoveToFolder(itemID: itemID, folder: folder)
syncMoveToFolder(itemID: itemID, folder: folder)
}
func syncMoveToFolder(itemID: String, folder: String) async throws {
func syncMoveToFolder(itemID: String, folder: String) {
enum MutationResult {
case result(success: Bool)
case error(errorMessage: String)
@ -48,23 +48,22 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
send(mutation, to: path, headers: headers) { queryResult in
let data = try? queryResult.get()
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
switch payload.data {
case let .result(success: success):
if success {
continuation.resume()
} else {
continuation.resume(throwing: BasicError.message(messageText: "operation failed"))
}
case let .error(errorMessage: errorMessage):
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
context.perform {
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
do {
try context.save()
logger.debug("LinkedItem updated succesfully")
} catch {
context.rollback()
logger.debug("Failed to sync library item move: \(error.localizedDescription)")
}
}
}

View file

@ -0,0 +1,92 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public struct Rule {
public let id: String
public let name: String
public let actions: [RuleAction]
}
public enum RuleActionType {
case addLabel
case archive
case markAsRead
case sendNotification
static func from(_ other: Enums.RuleActionType) -> RuleActionType {
switch other {
case Enums.RuleActionType.addLabel:
return .addLabel
case Enums.RuleActionType.archive:
return .archive
case Enums.RuleActionType.markAsRead:
return .markAsRead
case Enums.RuleActionType.sendNotification:
return .sendNotification
}
}
}
public struct RuleAction {
public let params: [String]
public let type: RuleActionType
}
let actionSelection = Selection.RuleAction {
RuleAction(params: try $0.params(), type: RuleActionType.from(try $0.type()))
}
let ruleSelection = Selection.Rule {
Rule(id: try $0.id(), name: try $0.name(), actions: try $0.actions(selection: actionSelection.list))
}
public extension DataService {
func createOrUpdateAddLabelsRule(existingID: String?, name: String, filter: String, labelIDs: [String]) async throws -> Rule {
enum MutationResult {
case result(rule: Rule)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.SetRuleResult> {
try $0.on(
setRuleError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
setRuleSuccess: .init { .result(rule: try $0.rule(selection: ruleSelection)) }
)
}
let mutation = Selection.Mutation {
try $0.setRule(
input: InputObjects.SetRuleInput(
actions: [InputObjects.RuleActionInput(params: labelIDs, type: .addLabel)],
enabled: true,
eventTypes: [.pageCreated],
filter: filter,
id: OptionalArgument(existingID),
name: name
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .result(rule: rule):
continuation.resume(returning: rule)
case let .error(errorMessage: errorMessage):
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
}
}
}
}
}

View file

@ -0,0 +1,56 @@
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func subscribeToFeed(feedURL: String, folder: String? = nil, fetchContent: Bool? = nil) async throws -> Bool {
enum MutationResult {
case success(subscriptionIds: [String])
case error(errorMessage: String)
}
let subscriptionIdSelection = Selection.Subscription {
try $0.id()
}
let selection = Selection<MutationResult, Unions.SubscribeResult> {
try $0.on(
subscribeError: .init {
.error(errorMessage: try $0.errorCodes().first?.rawValue ?? "unknown error")
},
subscribeSuccess: .init {
.success(subscriptionIds: try $0.subscriptions(selection: subscriptionIdSelection.list))
}
)
}
let mutation = Selection.Mutation {
try $0.subscribe(input: InputObjects.SubscribeInput(
fetchContent: OptionalArgument(fetchContent),
folder: OptionalArgument(folder),
url: feedURL
), selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { mutationResult in
guard let payload = try? mutationResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "failed to add feed \(feedURL)"))
return
}
switch payload.data {
case .success:
print("subscribed to feed:", feedURL)
continuation.resume(returning: true)
case .error:
print("failed to subscribe to feed:", feedURL)
continuation.resume(throwing: BasicError.message(messageText: "failed to add feed \(feedURL)"))
}
}
}
}
}

View file

@ -0,0 +1,53 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func updateNewsletterEmail(
emailID: String, folder: String? = nil, description: String? = nil
) async throws -> InternalNewsletterEmail {
enum MutationResult {
case result(email: InternalNewsletterEmail)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.UpdateNewsletterEmailResult> {
try $0.on(
updateNewsletterEmailError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
updateNewsletterEmailSuccess: .init { .result(email: try $0.newsletterEmail(selection: newsletterEmailSelection)) }
)
}
let mutation = Selection.Mutation {
try $0.updateNewsletterEmail(
input: InputObjects.UpdateNewsletterEmailInput(
description: OptionalArgument(description),
folder: OptionalArgument(folder),
id: emailID
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .result(email: email):
continuation.resume(returning: email)
case let .error(errorMessage: errorMessage):
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
}
}
}
}
}

View file

@ -0,0 +1,50 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func updateSubscription(_ subscriptionID: String, folder: String? = nil, fetchContent: Bool? = nil) async throws {
enum MutationResult {
case success(subscriptionID: String)
case error(errorMessage: String)
}
let selection = Selection<MutationResult, Unions.UpdateSubscriptionResult> {
try $0.on(
updateSubscriptionError: .init { .error(errorMessage: try $0.errorCodes().first?.rawValue ?? "Unknown Error") },
updateSubscriptionSuccess: .init { .success(subscriptionID: try $0.subscription(selection: subscriptionSelection).subscriptionID) }
)
}
let mutation = Selection.Mutation {
try $0.updateSubscription(
input: InputObjects.UpdateSubscriptionInput(
fetchContent: OptionalArgument(fetchContent),
folder: OptionalArgument(folder),
id: subscriptionID
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case .success:
continuation.resume()
case let .error(errorMessage: errorMessage):
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
}
}
}
}
}

View file

@ -151,21 +151,18 @@ public extension DataService {
case .needsUpdate:
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncLinkArchiveStatus(itemID: item.unwrappedID, archived: item.isArchived)
syncLinkReadingProgress(
itemID: item.unwrappedID,
readingProgress: item.readingProgress,
anchorIndex: Int(item.readingProgressAnchor),
force: item.isPDF
)
case .needsMove:
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncLinkArchiveStatus(itemID: item.unwrappedID, archived: item.isArchived)
syncLinkReadingProgress(
itemID: item.unwrappedID,
readingProgress: item.readingProgress,
anchorIndex: Int(item.readingProgressAnchor),
force: item.isPDF
)
// If the items folder might have changed, sync that.
if let itemID = item.id, let folder = item.folder, folder != "following" {
syncMoveToFolder(itemID: itemID, folder: folder)
}
}
}
}
@ -193,9 +190,6 @@ public extension DataService {
} else {
highlight.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
}
case .needsMove:
// Highlights can't be moved
break
}
}
}

View file

@ -4,15 +4,10 @@ import Models
import Utils
public extension DataService {
func prefetchPages(itemIDs: [String], username: String) async {
await withTaskGroup(of: Void.self) { group in
for itemID in itemIDs {
group.addTask {
await self.prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
}
}
await group.waitForAll()
}
func prefetchPages(itemIDs _: [String], username _: String) async {
// for itemID in itemIDs {
// prefetchQueue.addOperation(PrefetchJob)
// }
}
func loadArticleContentWithRetries(

View file

@ -15,17 +15,28 @@ public extension DataService {
LibraryItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
if fetchResult.items.persist(context: backgroundContext) == nil {
throw BasicError.message(messageText: "CoreData error")
if !fetchResult.newItems.isEmpty {
if fetchResult.newItems.persist(context: backgroundContext) == nil {
throw BasicError.message(messageText: "CoreData error")
}
}
let newestChange = fetchResult.items.max { $0.updatedAt < $1.updatedAt }
if !fetchResult.updatedItems.isEmpty {
if fetchResult.updatedItems.persist(context: backgroundContext) == nil {
throw BasicError.message(messageText: "CoreData error")
}
}
let newestChange = fetchResult.updatedItems.max { $0.updatedAt < $1.updatedAt }
let oldestChange = fetchResult.updatedItems.min { $0.updatedAt < $1.updatedAt }
let result = LinkedItemSyncResult(
updatedItemIDs: fetchResult.items.map(\.id),
updatedItemIDs: fetchResult.updatedItems.map(\.id),
cursor: fetchResult.cursor,
hasMore: fetchResult.hasMoreItems,
mostRecentUpdatedAt: newestChange?.updatedAt,
isEmpty: fetchResult.deletedItemIDs.isEmpty && fetchResult.items.isEmpty
oldestUpdatedAt: oldestChange?.updatedAt,
isEmpty: fetchResult.deletedItemIDs.isEmpty && fetchResult.updatedItems.isEmpty
)
return result
@ -42,16 +53,12 @@ public extension DataService {
searchQuery: String?,
cursor: String?
) async throws -> LinkedItemQueryResult {
// Send offline changes to server before fetching items
// try? await syncOfflineItemsWithServerIfNeeded()
let fetchResult = try await fetchLinkedItems(limit: limit, searchQuery: searchQuery, cursor: cursor)
guard let itemIDs = fetchResult.items.persist(context: backgroundContext) else {
throw BasicError.message(messageText: "CoreData error")
}
return LinkedItemQueryResult(itemIDs: itemIDs, cursor: fetchResult.cursor)
return LinkedItemQueryResult(itemIDs: itemIDs, cursor: fetchResult.cursor, totalCount: fetchResult.totalCount)
}
/// Requests a single `LinkedItem` from the server and stores it in CoreData

View file

@ -43,12 +43,14 @@ extension DataService {
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
htmlContent: try $0.content(),
originalHtml: nil,
language: try $0.language(),
wordsCount: try $0.wordsCount(),
downloadURL: try $0.url(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [],
highlights: try $0.highlights(selection: highlightSelection.list)
),
htmlContent: try $0.content(),
highlights: try $0.highlights(selection: highlightSelection.list)

View file

@ -6,11 +6,13 @@ import SwiftGraphQL
struct InternalLinkedItemQueryResult {
let items: [InternalLibraryItem]
let cursor: String?
let totalCount: Int?
}
struct InternalLinkedItemUpdatesQueryResult {
let items: [InternalLibraryItem]
let deletedItemIDs: [String]
let newItems: [InternalLibraryItem]
let updatedItems: [InternalLibraryItem]
let cursor: String?
let hasMoreItems: Bool
let totalCount: Int
@ -19,6 +21,7 @@ struct InternalLinkedItemUpdatesQueryResult {
private struct SyncItemEdge {
let itemID: String
let isDeletedItem: Bool
let isUpdatedItem: Bool
let item: InternalLibraryItem?
}
@ -90,21 +93,25 @@ extension DataService {
switch payload.data {
case let .success(result: result):
var items = [InternalLibraryItem]()
var newItems = [InternalLibraryItem]()
var updatedItems = [InternalLibraryItem]()
var deletedItemIDs = [String]()
for edge in result.edges {
if edge.isDeletedItem {
deletedItemIDs.append(edge.itemID)
} else if let item = edge.item, edge.isUpdatedItem {
updatedItems.append(item)
} else if let item = edge.item {
items.append(item)
newItems.append(item)
}
}
continuation.resume(
returning: InternalLinkedItemUpdatesQueryResult(
items: items,
deletedItemIDs: deletedItemIDs,
newItems: newItems,
updatedItems: updatedItems,
cursor: result.cursor,
hasMoreItems: result.hasMoreItems,
totalCount: result.totalCount
@ -144,6 +151,9 @@ extension DataService {
items: try $0.edges(selection: searchItemEdgeSelection.list),
cursor: try $0.pageInfo(selection: Selection.PageInfo {
try $0.endCursor()
}),
totalCount: try $0.pageInfo(selection: Selection.PageInfo {
try $0.totalCount()
})
)
)
@ -275,12 +285,14 @@ private let libraryArticleSelection = Selection.Article {
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
htmlContent: try $0.content(),
originalHtml: nil,
language: try $0.language(),
wordsCount: try $0.wordsCount(),
downloadURL: try $0.url(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [],
highlights: try $0.highlights(selection: highlightSelection.list)
)
}
@ -288,6 +300,7 @@ private let syncItemEdgeSelection = Selection.SyncUpdatedItemEdge {
SyncItemEdge(
itemID: try $0.itemId(),
isDeletedItem: try $0.updateReason() == .deleted,
isUpdatedItem: try $0.updateReason() == .updated,
item: try $0.node(selection: searchItemSelection.nullable)
)
}
@ -316,12 +329,14 @@ private let searchItemSelection = Selection.SearchItem {
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue,
htmlContent: try $0.content(),
originalHtml: nil,
language: try $0.language(),
wordsCount: try $0.wordsCount(),
downloadURL: try $0.url(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [],
highlights: try $0.highlights(selection: highlightSelection.list.nullable) ?? []
)
}

Some files were not shown because too many files have changed in this diff Show more