Compare commits

..

No commits in common. "main" and "1.7.6" have entirely different histories.
main ... 1.7.6

767 changed files with 24585 additions and 107753 deletions

View file

@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "swift" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

View file

@ -1,35 +0,0 @@
name: Validate Translations
on:
pull_request:
types: [synchronize, opened, reopened, labeled, unlabeled, edited]
jobs:
main:
name: Validate Translations
runs-on: macOS-latest
steps:
- name: git checkout
uses: actions/checkout@v3
- name: ruby versions
run: |
ruby --version
gem --version
bundler --version
- name: ruby setup
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true
# additional steps here, if needed
- name: Clone SwiftPolyglot
run: git clone https://github.com/appdecostudio/SwiftPolyglot.git --branch 0.2.0
- name: Build and Run SwiftPolyglot
run: |
swift build --package-path ./SwiftPolyglot --configuration release
swift run --package-path ./SwiftPolyglot swiftpolyglot "en,eu,be,ca,zh-Hans,zh-Hant,nl,en-GB,fr,de,it,ja,ko,nb,pl,pt-BR,es,tr,uk"

View file

@ -13,7 +13,7 @@ import Models
import Network
// Sample code was sending this from a thread to another, let asume @Sendable for this
extension NSExtensionContext: @unchecked @retroactive Sendable {}
extension NSExtensionContext: @unchecked Sendable {}
final class ActionRequestHandler: NSObject, NSExtensionRequestHandling, Sendable {
enum Error: Swift.Error {

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,12 @@
{
"originHash" : "b7af8c2ab18771d4cebfbeb66d91559df500516a12027cd67834b2a576eb3df0",
"pins" : [
{
"identity" : "bodega",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mergesort/Bodega",
"state" : {
"revision" : "bfd8871e9c2590d31b200e54c75428a71483afdf",
"version" : "2.1.3"
}
},
{
"identity" : "buttonkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Dean151/ButtonKit",
"state" : {
"revision" : "d567519b297777c38dee56ef10201fef4962ff75",
"version" : "0.4.1"
"revision" : "f0554077c178088ba11557bbdbb71775cc6a1b84",
"version" : "2.1.0"
}
},
{
@ -24,17 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/divadretlaw/EmojiText",
"state" : {
"revision" : "174a7bc7bd75650ad1acb5679dbb754296093de0",
"version" : "4.0.0"
}
},
{
"identity" : "giphy-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Giphy/giphy-ios-sdk",
"state" : {
"revision" : "fb61ec12738133eb3b9bf62ed11d1bf93d9b4b20",
"version" : "2.2.10"
"revision" : "a4ddf5077c241170e8ac0d3a9480c511e27c1ae9",
"version" : "2.8.0"
}
},
{
@ -43,16 +24,7 @@
"location" : "https://github.com/evgenyneu/keychain-swift",
"state" : {
"branch" : "master",
"revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608"
}
},
{
"identity" : "libwebp-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libwebp-Xcode",
"state" : {
"revision" : "b2b1d20a90b14d11f6ef4241da6b81c1d3f171e4",
"version" : "1.3.2"
"revision" : "c1fde55798b164cad44b5e23cfa2f0f1ebcd76af"
}
},
{
@ -60,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/LRUCache",
"state" : {
"revision" : "542f0449556327415409ededc9c43a4bd0a397dc",
"version" : "1.0.7"
"revision" : "6d2b5246c9c98dcd498552bb22f08d55b12a8371",
"version" : "1.0.4"
}
},
{
@ -69,17 +41,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke",
"state" : {
"revision" : "0ead44350d2737db384908569c012fe67c421e4d",
"version" : "12.8.0"
"revision" : "3f666f120b63ea7de57d42e9a7c9b47f8e7a290b",
"version" : "12.1.6"
}
},
{
"identity" : "purchases-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios",
"location" : "https://github.com/RevenueCat/purchases-ios.git",
"state" : {
"revision" : "7d55b964114a3d4a76791227cdc28577617596db",
"version" : "4.43.2"
"revision" : "4601c1e0c246f3d74094229737e894a9f2339e6a",
"version" : "4.25.7"
}
},
{
@ -96,35 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/stephencelis/SQLite.swift.git",
"state" : {
"revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8",
"version" : "0.15.3"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-cmark.git",
"state" : {
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53",
"version" : "0.5.0"
}
},
{
"identity" : "swift-markdown",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-markdown",
"state" : {
"revision" : "8f79cb175981458a0a27e76cb42fee8e17b1a993",
"version" : "0.5.0"
}
},
{
"identity" : "swiftsdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TelemetryDeck/SwiftSDK",
"state" : {
"revision" : "13a26cf125b70d695913eb9bea9f9b9c29da5790",
"version" : "2.3.0"
"revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb",
"version" : "0.14.1"
}
},
{
@ -132,46 +77,28 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "3c2c7e1e72b8abd96eafbae80323c5c1e5317437",
"version" : "2.7.5"
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
"version" : "2.6.1"
}
},
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/swiftui-introspect",
"location" : "https://github.com/siteline/SwiftUI-Introspect.git",
"state" : {
"revision" : "668a65735751432b640260c56dfa621cec568368",
"version" : "1.2.0"
"revision" : "9da0f9b7bffe96a7c98a0128f1e214f62728a39a",
"version" : "0.11.1"
}
},
{
"identity" : "wishkit-ios",
"identity" : "swiftui-shimmer",
"kind" : "remoteSourceControl",
"location" : "https://github.com/wishkit/wishkit-ios.git",
"location" : "https://github.com/markiv/SwiftUI-Shimmer",
"state" : {
"revision" : "2b5eb8d1fb13706f8c14767a5239e34e403375f1",
"version" : "4.2.2"
}
},
{
"identity" : "wishkit-ios-shared",
"kind" : "remoteSourceControl",
"location" : "https://github.com/wishkit/wishkit-ios-shared.git",
"state" : {
"revision" : "118c9c482e4ad57c65d664283516425b98616483",
"version" : "1.4.3"
}
},
{
"identity" : "wrappinghstack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/dkk/WrappingHStack",
"state" : {
"revision" : "425d9488ba55f58f0b34498c64c054c77fc2a44b",
"version" : "2.2.11"
"revision" : "965a7cbcbf094cbcf22b9251a2323bdc3432e171",
"version" : "1.1.0"
}
}
],
"version" : 3
"version" : 2
}

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
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 = "E9DF41F929830FEC0003AAD2"
BuildableName = "IceCubesActionExtension.appex"
BlueprintName = "IceCubesActionExtension"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.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">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.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 = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
LastUpgradeVersion = "1420"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BuildableName = "IceCubesApp.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
@ -45,7 +45,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BuildableName = "IceCubesApp.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
@ -62,7 +62,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BuildableName = "IceCubesApp.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>

View file

@ -1,114 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9F7788C42BE652B1004E6BEF"
BuildableName = "IceCubesAppWidgetsExtensionExtension.appex"
BlueprintName = "IceCubesAppWidgetsExtensionExtension"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.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">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
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 = "9F2A5415296AB631009B2D7C"
BuildableName = "IceCubesNotifications.appex"
BlueprintName = "IceCubesNotifications"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.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 = "1"
BundleIdentifier = "com.thomasricouard.IceCubesApp"
RemotePath = "/Users/dimillian/Library/Developer/CoreSimulator/Devices/8EF923D0-4CF1-49B6-B287-5F05AD5440C1/data/Containers/Bundle/Application/C447A1D1-9BC9-49C9-8FA5-130E8403972F/Ice Cubes.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.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 = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
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 = "9FAD858729743F7400496AB1"
BuildableName = "IceCubesShareExtension.appex"
BlueprintName = "IceCubesShareExtension"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.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">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.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 = "9FBFE638292A715500C250E9"
BuildableName = "Ice Cubes.app"
BlueprintName = "IceCubesApp"
ReferencedContainer = "container:IceCubesApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -6,10 +6,8 @@ import Env
import Explore
import LinkPresentation
import Lists
import MediaUI
import Models
import Notifications
import StatusKit
import Status
import SwiftUI
import Timeline
@ -24,8 +22,6 @@ extension View {
AccountDetailView(account: account)
case let .accountSettingsWithAccount(account, appAccount):
AccountSettingsView(account: account, appAccount: appAccount)
case let .accountMediaGridView(account, initialMedia):
AccountDetailMediaGridView(account: account, initialMediaStatuses: initialMedia)
case let .statusDetail(id):
StatusDetailView(statusId: id)
case let .statusDetailWithStatus(status):
@ -35,20 +31,9 @@ extension View {
case let .conversationDetail(conversation):
ConversationDetailView(conversation: conversation)
case let .hashTag(tag, accountId):
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), scrollToTopSignal: .constant(0), canFilterTimeline: false)
case let .list(list):
TimelineView(timeline: .constant(.list(list: list)),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
case let .linkTimeline(url, title):
TimelineView(timeline: .constant(.link(url: url, title: title)),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
TimelineView(timeline: .constant(.list(list: list)), scrollToTopSignal: .constant(0), canFilterTimeline: false)
case let .following(id):
AccountsListView(mode: .following(accountId: id))
case let .followers(id):
@ -60,23 +45,9 @@ extension View {
case let .accountsList(accounts):
AccountsListView(mode: .accountsList(accounts: accounts))
case .trendingTimeline:
TimelineView(timeline: .constant(.trending),
pinnedFilters: .constant([]),
selectedTagGroup: .constant(nil),
canFilterTimeline: false)
case let .trendingLinks(cards):
TrendingLinksListView(cards: cards)
TimelineView(timeline: .constant(.trending), scrollToTopSignal: .constant(0), canFilterTimeline: false)
case let .tagsList(tags):
TagsListView(tags: tags)
case .notificationsRequests:
NotificationsRequestsListView()
case let .notificationForAccount(accountId):
NotificationsListView(lockedType: nil,
lockedAccountId: accountId)
case .blockedAccounts:
AccountsListView(mode: .blocked)
case .mutedAccounts:
AccountsListView(mode: .muted)
}
}
}
@ -85,31 +56,19 @@ extension View {
sheet(item: sheetDestinations) { destination in
switch destination {
case let .replyToStatusEditor(status):
StatusEditor.MainView(mode: .replyTo(status: status))
StatusEditorView(mode: .replyTo(status: status))
.withEnvironments()
case let .newStatusEditor(visibility):
StatusEditor.MainView(mode: .new(text: nil, visibility: visibility))
.withEnvironments()
case let .prefilledStatusEditor(text, visibility):
StatusEditor.MainView(mode: .new(text: text, visibility: visibility))
.withEnvironments()
case let .imageURL(urls, visibility):
StatusEditor.MainView(mode: .imageURL(urls: urls, visibility: visibility))
StatusEditorView(mode: .new(visibility: visibility))
.withEnvironments()
case let .editStatusEditor(status):
StatusEditor.MainView(mode: .edit(status: status))
StatusEditorView(mode: .edit(status: status))
.withEnvironments()
case let .quoteStatusEditor(status):
StatusEditor.MainView(mode: .quote(status: status))
.withEnvironments()
case let .quoteLinkStatusEditor(link):
StatusEditor.MainView(mode: .quoteLink(link: link))
StatusEditorView(mode: .quote(status: status))
.withEnvironments()
case let .mentionStatusEditor(account, visibility):
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility))
.withEnvironments()
case .listCreate:
ListCreateView()
StatusEditorView(mode: .mention(account: account, visibility: visibility))
.withEnvironments()
case let .listEdit(list):
ListEditView(list: list)
@ -130,64 +89,36 @@ extension View {
StatusEditHistoryView(statusId: status)
.withEnvironments()
case .settings:
SettingsTabs(isModal: true)
SettingsTabs(popToRootTab: .constant(.settings))
.withEnvironments()
.preferredColorScheme(Theme.shared.selectedScheme == .dark ? .dark : .light)
case .accountPushNotficationsSettings:
if let subscription = PushNotificationsService.shared.subscriptions.first(where: { $0.account.token == AppAccountsManager.shared.currentAccount.oauthToken }) {
NavigationSheet { PushNotificationsView(subscription: subscription) }
PushNotificationsView(subscription: subscription)
.withEnvironments()
} else {
EmptyView()
}
case .about:
NavigationSheet { AboutView() }
.withEnvironments()
case .support:
NavigationSheet { SupportAppView() }
.withEnvironments()
case let .report(status):
ReportView(status: status)
.withEnvironments()
case let .shareImage(image, status):
ActivityView(image: image, status: status)
.withEnvironments()
case let .editTagGroup(tagGroup, onSaved):
EditTagGroupView(tagGroup: tagGroup, onSaved: onSaved)
.withEnvironments()
case .timelineContentFilter:
NavigationSheet { TimelineContentFilterView() }
.presentationDetents([.medium])
.presentationBackground(.thinMaterial)
.withEnvironments()
case .accountEditInfo:
EditAccountView()
.withEnvironments()
case .accountFiltersList:
FiltersListView()
EditTagGroupView(editingTagGroup: tagGroup, onSaved: onSaved)
.withEnvironments()
}
}
}
func withEnvironments() -> some View {
environment(CurrentAccount.shared)
.environment(UserPreferences.shared)
.environment(CurrentInstance.shared)
.environment(Theme.shared)
.environment(AppAccountsManager.shared)
.environment(PushNotificationsService.shared)
.environment(AppAccountsManager.shared.currentClient)
.environment(QuickLook.shared)
}
func withModelContainer() -> some View {
modelContainer(for: [
Draft.self,
LocalTimeline.self,
TagGroup.self,
RecentTag.self,
])
environmentObject(CurrentAccount.shared)
.environmentObject(UserPreferences.shared)
.environmentObject(CurrentInstance.shared)
.environmentObject(Theme.shared)
.environmentObject(AppAccountsManager.shared)
.environmentObject(PushNotificationsService.shared)
.environmentObject(AppAccountsManager.shared.currentClient)
}
}
@ -224,15 +155,9 @@ struct ActivityView: UIViewControllerRepresentable {
}
func makeUIViewController(context _: UIViewControllerRepresentableContext<ActivityView>) -> UIActivityViewController {
UIActivityViewController(activityItems: [image, LinkDelegate(image: image, status: status)],
return UIActivityViewController(activityItems: [image, LinkDelegate(image: image, status: status)],
applicationActivities: nil)
}
func updateUIViewController(_: UIActivityViewController, context _: UIViewControllerRepresentableContext<ActivityView>) {}
}
extension URL: @retroactive Identifiable {
public var id: String {
absoluteString
}
}

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.icecubesapp</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.user-fonts</key>
<array>
<string>app-usage</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(BUNDLE_ID_PREFIX).IceCubesApp</string>
</array>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).IceCubesApp</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,306 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import Network
import RevenueCat
import SwiftUI
import Timeline
@main
struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager.shared
@StateObject private var currentInstance = CurrentInstance.shared
@StateObject private var currentAccount = CurrentAccount.shared
@StateObject private var userPreferences = UserPreferences.shared
@StateObject private var pushNotificationsService = PushNotificationsService.shared
@StateObject private var watcher = StreamWatcher()
@StateObject private var quickLook = QuickLook()
@StateObject private var theme = Theme.shared
@StateObject private var sidebarRouterPath = RouterPath()
@State private var selectedTab: Tab = .timeline
@State private var popToRootTab: Tab = .other
@State private var sideBarLoadedTabs: Set<Tab> = Set()
@State private var isSupporter: Bool = false
private var availableTabs: [Tab] {
appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()
}
var body: some Scene {
WindowGroup {
appView
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environmentObject(appAccountsManager)
.environmentObject(appAccountsManager.currentClient)
.environmentObject(quickLook)
.environmentObject(currentAccount)
.environmentObject(currentInstance)
.environmentObject(userPreferences)
.environmentObject(theme)
.environmentObject(watcher)
.environmentObject(pushNotificationsService)
.environment(\.isSupporter, isSupporter)
.fullScreenCover(item: $quickLook.url, content: { url in
QuickLookPreview(selectedURL: url, urls: quickLook.urls)
.edgesIgnoringSafeArea(.bottom)
.background(TransparentBackground())
})
.onChange(of: pushNotificationsService.handledNotification) { notification in
if notification != nil {
pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != notification?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == notification?.account.token.accessToken })
{
appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
selectedTab = .notifications
pushNotificationsService.handledNotification = notification
}
} else {
selectedTab = .notifications
}
}
}
}
.commands {
appMenu
}
.onChange(of: scenePhase) { scenePhase in
handleScenePhase(scenePhase: scenePhase)
}
.onChange(of: appAccountsManager.currentClient) { newClient in
setNewClientsInEnv(client: newClient)
if newClient.isAuth {
watcher.watch(streams: [.user, .direct])
}
}
}
@ViewBuilder
private var appView: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
sidebarView
} else {
tabBarView
}
}
private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab,
let token = appAccountsManager.currentAccount.oauthToken
{
return watcher.unreadNotificationsCount + userPreferences.getNotificationsCount(for: token)
}
return 0
}
private var sidebarView: some View {
SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab,
tabs: availableTabs,
routerPath: sidebarRouterPath)
{
GeometryReader { _ in
HStack(spacing: 0) {
ZStack {
if selectedTab == .profile {
ProfileTab(popToRootTab: $popToRootTab)
}
ForEach(availableTabs) { tab in
if tab == selectedTab || sideBarLoadedTabs.contains(tab) {
tab
.makeContentView(popToRootTab: $popToRootTab)
.opacity(tab == selectedTab ? 1 : 0)
.transition(.opacity)
.id("\(tab)\(appAccountsManager.currentAccount.id)")
.onAppear {
sideBarLoadedTabs.insert(tab)
}
} else {
EmptyView()
}
}
}
if appAccountsManager.currentClient.isAuth,
userPreferences.showiPadSecondaryColumn
{
Divider().edgesIgnoringSafeArea(.all)
notificationsSecondaryColumn
}
}
}
}.onChange(of: $appAccountsManager.currentAccount.id) { _ in
sideBarLoadedTabs.removeAll()
}
}
private var notificationsSecondaryColumn: some View {
NotificationsTab(popToRootTab: $popToRootTab, lockedType: nil)
.environment(\.isSecondaryColumn, true)
.frame(maxWidth: .secondaryColumnWidth)
.id(appAccountsManager.currentAccount.id)
}
private var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
}, set: { newTab in
if newTab == selectedTab {
/// Stupid hack to trigger onChange binding in tab views.
popToRootTab = .other
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
popToRootTab = selectedTab
}
}
HapticManager.shared.fireHaptic(of: .tabSelection)
SoundEffectManager.shared.playSound(of: .tabSelection)
selectedTab = newTab
DispatchQueue.main.async {
if selectedTab == .notifications,
let token = appAccountsManager.currentAccount.oauthToken
{
userPreferences.setNotification(count: 0, token: token)
watcher.unreadNotificationsCount = 0
}
}
})) {
ForEach(availableTabs) { tab in
tab.makeContentView(popToRootTab: $popToRootTab)
.tabItem {
if userPreferences.showiPhoneTabLabel {
tab.label
.labelStyle(TitleAndIconLabelStyle())
} else {
tab.label
.labelStyle(IconOnlyLabelStyle())
}
}
.tag(tab)
.badge(badgeFor(tab: tab))
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .tabBar)
}
}
.id(appAccountsManager.currentClient.id)
}
private func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
Task {
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
watcher.watch(streams: [.user, .direct])
}
}
private func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(streams: [.user, .direct])
UIApplication.shared.applicationIconBadgeNumber = 0
Task {
await userPreferences.refreshServerPreferences()
}
default:
break
}
}
private func setupRevenueCat() {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
Purchases.shared.getCustomerInfo { info, _ in
if info?.entitlements["Supporter"]?.isActive == true {
isSupporter = true
}
}
}
private func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications()
}
@CommandsBuilder
private var appMenu: some Commands {
CommandGroup(replacing: .newItem) {
Button("menu.new-post") {
sidebarRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
}
}
CommandGroup(replacing: .textFormatting) {
Menu("menu.font") {
Button("menu.font.bigger") {
if theme.fontSizeScale < 1.5 {
theme.fontSizeScale += 0.1
}
}
Button("menu.font.smaller") {
if theme.fontSizeScale > 0.5 {
theme.fontSizeScale -= 0.1
}
}
}
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
let themeObserver = ThemeObserverViewController(nibName: nil, bundle: nil)
func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient)
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
return true
}
func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken
Task {
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
}
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
}
class ThemeObserverViewController: UIViewController {
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
print(traitCollection.userInterfaceStyle.rawValue)
}
}

View file

@ -1,180 +0,0 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import MediaUI
import Network
import RevenueCat
import StatusKit
import SwiftUI
import Timeline
@MainActor
struct AppView: View {
@Environment(AppAccountsManager.self) private var appAccountsManager
@Environment(UserPreferences.self) private var userPreferences
@Environment(Theme.self) private var theme
@Environment(StreamWatcher.self) private var watcher
@Environment(\.openWindow) var openWindow
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Binding var selectedTab: AppTab
@Binding var appRouterPath: RouterPath
@State var iosTabs = iOSTabs.shared
@State var sidebarTabs = SidebarTabs.shared
@State var selectedTabScrollToTop: Int = -1
var body: some View {
#if os(visionOS)
tabBarView
#else
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
sidebarView
} else {
tabBarView
}
#endif
}
var availableTabs: [AppTab] {
guard appAccountsManager.currentClient.isAuth else {
return AppTab.loggedOutTab()
}
if UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact {
return iosTabs.tabs
} else if UIDevice.current.userInterfaceIdiom == .vision {
return AppTab.visionOSTab()
}
return sidebarTabs.tabs.map { $0.tab }
}
@ViewBuilder
var tabBarView: some View {
TabView(selection: .init(get: {
selectedTab
}, set: { newTab in
updateTab(with: newTab)
})) {
ForEach(availableTabs) { tab in
tab.makeContentView(selectedTab: $selectedTab)
.tabItem {
if userPreferences.showiPhoneTabLabel {
tab.label
.environment(\.symbolVariants, tab == selectedTab ? .fill : .none)
} else {
Image(systemName: tab.iconName)
}
}
.tag(tab)
.badge(badgeFor(tab: tab))
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .tabBar)
}
}
.id(appAccountsManager.currentClient.id)
.withSheetDestinations(sheetDestinations: $appRouterPath.presentedSheet)
.environment(\.selectedTabScrollToTop, selectedTabScrollToTop)
}
private func updateTab(with newTab: AppTab) {
if newTab == .post {
#if os(visionOS)
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
#else
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
#endif
return
}
HapticManager.shared.fireHaptic(.tabSelection)
SoundEffectManager.shared.playSound(.tabSelection)
if selectedTab == newTab {
selectedTabScrollToTop = newTab.rawValue
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
selectedTabScrollToTop = -1
}
} else {
selectedTabScrollToTop = -1
}
selectedTab = newTab
}
private func badgeFor(tab: AppTab) -> Int {
if tab == .notifications, selectedTab != tab,
let token = appAccountsManager.currentAccount.oauthToken
{
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0)
}
return 0
}
#if !os(visionOS)
var sidebarView: some View {
SideBarView(selectedTab: .init(get: {
selectedTab
}, set: { newTab in
updateTab(with: newTab)
}), tabs: availableTabs)
{
HStack(spacing: 0) {
if #available(iOS 18.0, *) {
baseTabView
#if targetEnvironment(macCatalyst)
.tabViewStyle(.sidebarAdaptable)
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
tabview.sidebar.isHidden = true
}
#else
.tabViewStyle(.tabBarOnly)
#endif
} else {
baseTabView
}
if horizontalSizeClass == .regular,
appAccountsManager.currentClient.isAuth,
userPreferences.showiPadSecondaryColumn
{
Divider().edgesIgnoringSafeArea(.all)
notificationsSecondaryColumn
}
}
}
.environment(appRouterPath)
.environment(\.selectedTabScrollToTop, selectedTabScrollToTop)
}
#endif
private var baseTabView: some View {
TabView(selection: $selectedTab) {
ForEach(availableTabs) { tab in
tab
.makeContentView(selectedTab: $selectedTab)
.toolbar(horizontalSizeClass == .regular ? .hidden : .visible, for: .tabBar)
.tabItem {
tab.label
}
.tag(tab)
}
}
#if !os(visionOS)
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
tabview.tabBar.isHidden = horizontalSizeClass == .regular
tabview.customizableViewControllers = []
tabview.moreNavigationController.isNavigationBarHidden = true
}
#endif
}
var notificationsSecondaryColumn: some View {
NotificationsTab(selectedTab: .constant(.notifications)
, lockedType: nil)
.environment(\.isSecondaryColumn, true)
.frame(maxWidth: .secondaryColumnWidth)
.id(appAccountsManager.currentAccount.id)
}
}

View file

@ -1,70 +0,0 @@
import Env
import SwiftUI
extension IceCubesApp {
@CommandsBuilder
var appMenu: some Commands {
CommandGroup(replacing: .appSettings) {
Button("menu.settings") {
appRouterPath.presentedSheet = .settings
}
.keyboardShortcut(",", modifiers: .command)
}
CommandGroup(replacing: .newItem) {
Button("menu.new-window") {
openWindow(id: "MainWindow")
}
.keyboardShortcut("n", modifiers: .shift)
Button("menu.new-post") {
#if targetEnvironment(macCatalyst)
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
#else
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
#endif
}
.keyboardShortcut("n", modifiers: .command)
}
CommandGroup(replacing: .textFormatting) {
Menu("menu.font") {
Button("menu.font.bigger") {
if theme.fontSizeScale < 1.5 {
theme.fontSizeScale += 0.1
}
}
Button("menu.font.smaller") {
if theme.fontSizeScale > 0.5 {
theme.fontSizeScale -= 0.1
}
}
}
}
CommandMenu("tab.timeline") {
Button("timeline.latest") {
NotificationCenter.default.post(name: .refreshTimeline, object: nil)
}
.keyboardShortcut("r", modifiers: .command)
Button("timeline.home") {
NotificationCenter.default.post(name: .homeTimeline, object: nil)
}
.keyboardShortcut("h", modifiers: .shift)
Button("timeline.trending") {
NotificationCenter.default.post(name: .trendingTimeline, object: nil)
}
.keyboardShortcut("t", modifiers: .shift)
Button("timeline.federated") {
NotificationCenter.default.post(name: .federatedTimeline, object: nil)
}
.keyboardShortcut("f", modifiers: .shift)
Button("timeline.local") {
NotificationCenter.default.post(name: .localTimeline, object: nil)
}
.keyboardShortcut("l", modifiers: .shift)
}
CommandGroup(replacing: .help) {
Button("menu.help.github") {
let url = URL(string: "https://github.com/Dimillian/IceCubesApp/issues")!
UIApplication.shared.open(url)
}
}
}
}

View file

@ -1,169 +0,0 @@
import AppIntents
import Env
import MediaUI
import StatusKit
import SwiftUI
extension IceCubesApp {
var appScene: some Scene {
WindowGroup(id: "MainWindow") {
AppView(selectedTab: $selectedTab, appRouterPath: $appRouterPath)
.applyTheme(theme)
.onAppear {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setupRevenueCat()
refreshPushSubs()
}
.environment(appAccountsManager)
.environment(appAccountsManager.currentClient)
.environment(quickLook)
.environment(currentAccount)
.environment(currentInstance)
.environment(userPreferences)
.environment(theme)
.environment(watcher)
.environment(pushNotificationsService)
.environment(appIntentService)
.environment(\.isSupporter, isSupporter)
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
if #available(iOS 18.0, *) {
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.presentationSizing(.page)
.withEnvironments()
} else {
MediaUIView(selectedAttachment: selectedMediaAttachment,
attachments: quickLook.mediaAttachments)
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.withEnvironments()
}
}
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if newValue != nil {
pushNotificationsService.handledNotification = nil
if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
let account = appAccountsManager.availableAccounts.first(where:
{ $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
{
appAccountsManager.currentAccount = account
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
selectedTab = .notifications
pushNotificationsService.handledNotification = newValue
}
} else {
selectedTab = .notifications
}
}
}
.onChange(of: appIntentService.handledIntent) { _, _ in
if let intent = appIntentService.handledIntent?.intent {
handleIntent(intent)
appIntentService.handledIntent = nil
}
}
.withModelContainer()
}
.commands {
appMenu
}
.onChange(of: scenePhase) { _, newValue in
handleScenePhase(scenePhase: newValue)
}
.onChange(of: appAccountsManager.currentClient) { _, newValue in
setNewClientsInEnv(client: newValue)
if newValue.isAuth {
watcher.watch(streams: [.user, .direct])
}
}
#if targetEnvironment(macCatalyst)
.windowResize()
#elseif os(visionOS)
.defaultSize(width: 800, height: 1200)
#endif
}
@SceneBuilder
var otherScenes: some Scene {
WindowGroup(for: WindowDestinationEditor.self) { destination in
Group {
switch destination.wrappedValue {
case let .newStatusEditor(visibility):
StatusEditor.MainView(mode: .new(text: nil, visibility: visibility))
case let .prefilledStatusEditor(text, visibility):
StatusEditor.MainView(mode: .new(text: text, visibility: visibility))
case let .editStatusEditor(status):
StatusEditor.MainView(mode: .edit(status: status))
case let .quoteStatusEditor(status):
StatusEditor.MainView(mode: .quote(status: status))
case let .replyToStatusEditor(status):
StatusEditor.MainView(mode: .replyTo(status: status))
case let .mentionStatusEditor(account, visibility):
StatusEditor.MainView(mode: .mention(account: account, visibility: visibility))
case let .quoteLinkStatusEditor(link):
StatusEditor.MainView(mode: .quoteLink(link: link))
case .none:
EmptyView()
}
}
.withEnvironments()
.environment(\.isCatalystWindow, true)
.environment(RouterPath())
.withModelContainer()
.applyTheme(theme)
.frame(minWidth: 300, minHeight: 400)
}
.defaultSize(width: 600, height: 800)
.windowResizability(.contentMinSize)
WindowGroup(for: WindowDestinationMedia.self) { destination in
Group {
switch destination.wrappedValue {
case let .mediaViewer(attachments, selectedAttachment):
MediaUIView(selectedAttachment: selectedAttachment,
attachments: attachments)
case .none:
EmptyView()
}
}
.withEnvironments()
.withModelContainer()
.applyTheme(theme)
.environment(\.isCatalystWindow, true)
.frame(minWidth: 300, minHeight: 400)
}
.defaultSize(width: 1200, height: 1000)
.windowResizability(.contentMinSize)
}
private func handleIntent(_: any AppIntent) {
if let postIntent = appIntentService.handledIntent?.intent as? PostIntent {
#if os(visionOS) || os(macOS)
openWindow(value: WindowDestinationEditor.prefilledStatusEditor(text: postIntent.content ?? "",
visibility: userPreferences.postVisibility))
#else
appRouterPath.presentedSheet = .prefilledStatusEditor(text: postIntent.content ?? "",
visibility: userPreferences.postVisibility)
#endif
} else if let tabIntent = appIntentService.handledIntent?.intent as? TabIntent {
selectedTab = tabIntent.tab.toAppTab
} else if let imageIntent = appIntentService.handledIntent?.intent as? PostImageIntent,
let urls = imageIntent.images?.compactMap({ $0.fileURL })
{
appRouterPath.presentedSheet = .imageURL(urls: urls,
visibility: userPreferences.postVisibility)
}
}
}
extension Scene {
func windowResize() -> some Scene {
if #available(iOS 18.0, *) {
return self.windowResizability(.contentSize)
} else {
return self.defaultSize(width: 1100, height: 1400)
}
}
}

View file

@ -1,128 +0,0 @@
import Account
import AppAccount
import AVFoundation
import DesignSystem
import Env
import KeychainSwift
import MediaUI
import Network
import RevenueCat
import StatusKit
import SwiftUI
import Timeline
import WishKit
@main
struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) var scenePhase
@Environment(\.openWindow) var openWindow
@State var appAccountsManager = AppAccountsManager.shared
@State var currentInstance = CurrentInstance.shared
@State var currentAccount = CurrentAccount.shared
@State var userPreferences = UserPreferences.shared
@State var pushNotificationsService = PushNotificationsService.shared
@State var appIntentService = AppIntentService.shared
@State var watcher = StreamWatcher.shared
@State var quickLook = QuickLook.shared
@State var theme = Theme.shared
@State var selectedTab: AppTab = .timeline
@State var appRouterPath = RouterPath()
@State var isSupporter: Bool = false
var body: some Scene {
appScene
otherScenes
}
func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
Task {
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
watcher.watch(streams: [.user, .direct])
}
}
func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase {
case .background:
watcher.stopWatching()
case .active:
watcher.watch(streams: [.user, .direct])
UNUserNotificationCenter.current().setBadgeCount(0)
userPreferences.reloadNotificationsCount(tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
Task {
await userPreferences.refreshServerPreferences()
}
default:
break
}
}
func setupRevenueCat() {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
Purchases.shared.getCustomerInfo { info, _ in
if info?.entitlements["Supporter"]?.isActive == true {
isSupporter = true
}
}
}
func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications()
}
}
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers)
try? AVAudioSession.sharedInstance().setActive(true)
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
Telemetry.setup()
Telemetry.signal("app.launched")
WishKit.configure(with: "AF21AE07-3BA9-4FE2-BFB1-59A3B3941730")
return true
}
func application(_: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
PushNotificationsService.shared.pushToken = deviceToken
Task {
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(forceCreate: false)
}
}
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
UserPreferences.shared.reloadNotificationsCount(tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
return .noData
}
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
builder.remove(menu: .document)
builder.remove(menu: .toolbar)
builder.remove(menu: .sidebar)
}
}

View file

@ -0,0 +1,96 @@
import QuickLook
import SwiftUI
import UIKit
extension URL: Identifiable {
public var id: String {
absoluteString
}
}
struct QuickLookPreview: UIViewControllerRepresentable {
let selectedURL: URL
let urls: [URL]
func makeUIViewController(context _: Context) -> UIViewController {
return AppQLPreviewController(selectedURL: selectedURL, urls: urls)
}
func updateUIViewController(
_: UIViewController, context _: Context
) {}
}
class AppQLPreviewController: UIViewController {
let selectedURL: URL
let urls: [URL]
var qlController: QLPreviewController?
init(selectedURL: URL, urls: [URL]) {
self.selectedURL = selectedURL
self.urls = urls
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if qlController == nil {
qlController = QLPreviewController()
qlController?.dataSource = self
qlController?.delegate = self
qlController?.currentPreviewItemIndex = urls.firstIndex(of: selectedURL) ?? 0
present(qlController!, animated: true)
}
}
}
extension AppQLPreviewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in _: QLPreviewController) -> Int {
return urls.count
}
func previewController(_: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
return urls[index] as QLPreviewItem
}
}
extension AppQLPreviewController: QLPreviewControllerDelegate {
func previewController(_: QLPreviewController, editingModeFor _: QLPreviewItem) -> QLPreviewItemEditingMode {
.createCopy
}
func previewControllerWillDismiss(_: QLPreviewController) {
dismiss(animated: true)
}
func previewControllerDidDismiss(_: QLPreviewController) {
dismiss(animated: true)
}
}
struct TransparentBackground: UIViewControllerRepresentable {
public func makeUIViewController(context _: Context) -> UIViewController {
return TransparentController()
}
public func updateUIViewController(_: UIViewController, context _: Context) {}
class TransparentController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
}
override func willMove(toParent parent: UIViewController?) {
super.willMove(toParent: parent)
parent?.view?.backgroundColor = .clear
parent?.modalPresentationStyle = .overCurrentContext
}
}
}

View file

@ -2,14 +2,14 @@ import DesignSystem
import Env
import Models
import Network
import StatusKit
import Status
import SwiftUI
public struct ReportView: View {
@Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme
@Environment(Client.self) private var client
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
let status: Status
@State private var commentText: String = ""
@ -35,11 +35,9 @@ public struct ReportView: View {
}
.navigationTitle("report.title")
.navigationBarTitleDisplayMode(.inline)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
#endif
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
@ -65,7 +63,13 @@ public struct ReportView: View {
}
}
CancelToolbarItem()
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Text("action.cancel")
}
}
}
}
}

View file

@ -1,49 +1,32 @@
import DesignSystem
import Env
import Models
import Observation
import SafariServices
import SwiftUI
import AppAccount
import WebKit
extension View {
@MainActor func withSafariRouter() -> some View {
func withSafariRouter() -> some View {
modifier(SafariRouter())
}
}
@MainActor
private struct SafariRouter: ViewModifier {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var preferences
@Environment(RouterPath.self) private var routerPath
@Environment(AppAccountsManager.self) private var appAccount
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var routerPath: RouterPath
#if !os(visionOS)
@State private var safariManager = InAppSafariManager()
#endif
@StateObject private var safariManager = InAppSafariManager()
func body(content: Content) -> some View {
content
.environment(\.openURL, OpenURLAction { url in
// Open internal URL.
guard !isSecondaryColumn else { return .discarded }
return routerPath.handle(url: url)
routerPath.handle(url: url)
})
.onOpenURL { url in
// Open external URL (from icecubesapp://)
guard !isSecondaryColumn else { return }
if url.absoluteString == "icecubesapp://subclub" {
#if !os(visionOS)
safariManager.dismiss()
#endif
return
}
let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://")
let urlString = url.absoluteString.replacingOccurrences(of: "icecubesapp://", with: "https://")
guard let url = URL(string: urlString), url.host != nil else { return }
_ = routerPath.handleDeepLink(url: url)
_ = routerPath.handle(url: url)
}
.onAppear {
routerPath.urlHandler = { url in
@ -56,56 +39,31 @@ private struct SafariRouter: ViewModifier {
UIApplication.shared.open(url)
return .handled
}
} else if url.query()?.contains("callback=") == false,
url.host() == AppInfo.premiumInstance,
let accountName = appAccount.currentAccount.accountName {
let newURL = url.appending(queryItems: [
.init(name: "callback", value: "icecubesapp://subclub"),
.init(name: "id", value: "@\(accountName)")
])
#if !os(visionOS)
return safariManager.open(newURL)
#else
return .systemAction
#endif
}
#if !targetEnvironment(macCatalyst)
guard preferences.preferredBrowser == .inAppSafari else { return .systemAction }
guard preferences.preferredBrowser == .inAppSafari, !ProcessInfo.processInfo.isiOSAppOnMac else { return .systemAction }
// SFSafariViewController only supports initial URLs with http:// or https:// schemes.
guard let scheme = url.scheme, ["https", "http"].contains(scheme.lowercased()) else {
return .systemAction
}
#if os(visionOS)
return .systemAction
#else
return safariManager.open(url)
#endif
#else
return .systemAction
#endif
}
}
#if !os(visionOS)
.background {
WindowReader { window in
safariManager.windowScene = window.windowScene
self.safariManager.windowScene = window.windowScene
}
}
#endif
}
}
#if !os(visionOS)
@MainActor
@Observable private class InAppSafariManager: NSObject, SFSafariViewControllerDelegate {
private class InAppSafariManager: NSObject, ObservableObject, SFSafariViewControllerDelegate {
var windowScene: UIWindowScene?
let viewController: UIViewController = .init()
var window: UIWindow?
@MainActor
func open(_ url: URL) -> OpenURLAction.Result {
guard let windowScene else { return .systemAction }
guard let windowScene = windowScene else { return .systemAction }
window = setupWindow(windowScene: windowScene)
@ -124,15 +82,8 @@ private struct SafariRouter: ViewModifier {
return .handled
}
func dismiss() {
viewController.presentedViewController?.dismiss(animated: true)
window?.resignKey()
window?.isHidden = false
window = nil
}
func setupWindow(windowScene: UIWindowScene) -> UIWindow {
let window = window ?? UIWindow(windowScene: windowScene)
let window = self.window ?? UIWindow(windowScene: windowScene)
window.rootViewController = viewController
window.makeKeyAndVisible()
@ -148,15 +99,12 @@ private struct SafariRouter: ViewModifier {
return window
}
nonisolated func safariViewControllerDidFinish(_: SFSafariViewController) {
Task { @MainActor in
func safariViewControllerDidFinish(_: SFSafariViewController) {
window?.resignKey()
window?.isHidden = false
window = nil
}
}
}
#endif
}
private struct WindowReader: UIViewRepresentable {
var onUpdate: (UIWindow) -> Void

View file

@ -4,61 +4,40 @@ import DesignSystem
import Env
import Models
import SwiftUI
import SwiftUIIntrospect
@MainActor
struct SideBarView<Content: View>: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var appAccounts: AppAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var userPreferences: UserPreferences
@Environment(AppAccountsManager.self) private var appAccounts
@Environment(CurrentAccount.self) private var currentAccount
@Environment(Theme.self) private var theme
@Environment(StreamWatcher.self) private var watcher
@Environment(UserPreferences.self) private var userPreferences
@Environment(RouterPath.self) private var routerPath
@Binding var selectedTab: AppTab
var tabs: [AppTab]
@Binding var selectedTab: Tab
@Binding var popToRootTab: Tab
var tabs: [Tab]
@ObservedObject var routerPath = RouterPath()
@ViewBuilder var content: () -> Content
@State private var sidebarTabs = SidebarTabs.shared
private func badgeFor(tab: AppTab) -> Int {
if tab == .notifications, selectedTab != tab,
private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab,
let token = appAccounts.currentAccount.oauthToken
{
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0)
return watcher.unreadNotificationsCount + userPreferences.getNotificationsCount(for: token)
}
return 0
}
private func makeIconForTab(tab: AppTab) -> some View {
private func makeIconForTab(tab: Tab) -> some View {
ZStack(alignment: .topTrailing) {
HStack {
SideBarIcon(systemIconName: tab.iconName,
isSelected: tab == selectedTab)
if userPreferences.isSidebarExpanded {
Text(tab.title)
.font(.headline)
.foregroundColor(tab == selectedTab ? theme.tintColor : theme.labelColor)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(width: (userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth) - 24, height: 50)
.background(tab == selectedTab ? theme.primaryBackgroundColor : .clear,
in: RoundedRectangle(cornerRadius: 8))
.cornerRadius(8)
.shadow(color: tab == selectedTab ? .black.opacity(0.2) : .clear, radius: 5)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(tab == selectedTab ? theme.labelColor.opacity(0.1) : .clear, lineWidth: 1)
)
let badge = badgeFor(tab: tab)
if badge > 0 {
makeBadgeView(count: badge)
}
}
.contentShape(Rectangle())
.frame(width: .sidebarWidth, height: 50)
}
private func makeBadgeView(count: Int) -> some View {
@ -70,32 +49,27 @@ struct SideBarView<Content: View>: View {
.font(.caption2)
}
.frame(width: 24, height: 24)
.offset(x: 5, y: -5)
.offset(x: 14, y: -14)
}
private var postButton: some View {
Button {
#if targetEnvironment(macCatalyst) || os(visionOS)
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
#else
routerPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
#endif
} label: {
Image(systemName: "square.and.pencil")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 30)
.offset(x: 2, y: -2)
}
.buttonStyle(.borderedProminent)
.help(AppTab.post.title)
.keyboardShortcut("n", modifiers: .command)
}
private func makeAccountButton(account: AppAccount, showBadge: Bool) -> some View {
Button {
if account.id == appAccounts.currentAccount.id {
selectedTab = .profile
SoundEffectManager.shared.playSound(.tabSelection)
SoundEffectManager.shared.playSound(of: .tabSelection)
} else {
var transation = Transaction()
transation.disablesAnimations = true
@ -105,69 +79,50 @@ struct SideBarView<Content: View>: View {
}
} label: {
ZStack(alignment: .topTrailing) {
if userPreferences.isSidebarExpanded {
AppAccountView(viewModel: .init(appAccount: account,
isCompact: false,
isInSettings: false),
isParentPresented: .constant(false))
} else {
AppAccountView(viewModel: .init(appAccount: account,
isCompact: true,
isInSettings: false),
isParentPresented: .constant(false))
}
if !userPreferences.isSidebarExpanded,
showBadge,
AppAccountView(viewModel: .init(appAccount: account, isCompact: true))
if showBadge,
let token = account.oauthToken,
let notificationsCount = userPreferences.notificationsCount[token],
notificationsCount > 0
userPreferences.getNotificationsCount(for: token) > 0
{
makeBadgeView(count: notificationsCount)
makeBadgeView(count: userPreferences.getNotificationsCount(for: token))
}
}
.padding(.leading, userPreferences.isSidebarExpanded ? 16 : 0)
}
.help(accountButtonTitle(accountName: account.accountName))
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth, height: 50)
.frame(width: .sidebarWidth, height: 50)
.padding(.vertical, 8)
.background(selectedTab == .profile && account.id == appAccounts.currentAccount.id ?
theme.secondaryBackgroundColor : .clear)
}
private func accountButtonTitle(accountName: String?) -> LocalizedStringKey {
if let accountName {
"tab.profile-account-\(accountName)"
} else {
AppTab.profile.title
}
}
private var tabsView: some View {
ForEach(tabs) { tab in
if tab != .profile && sidebarTabs.isEnabled(tab) {
Button {
// ensure keyboard is always dismissed when selecting a tab
hideKeyboard()
if tab == selectedTab {
popToRootTab = .other
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
popToRootTab = tab
}
}
selectedTab = tab
SoundEffectManager.shared.playSound(.tabSelection)
SoundEffectManager.shared.playSound(of: .tabSelection)
if tab == .notifications {
if let token = appAccounts.currentAccount.oauthToken {
userPreferences.notificationsCount[token] = 0
userPreferences.setNotification(count: 0, token: token)
}
watcher.unreadNotificationsCount = 0
}
} label: {
makeIconForTab(tab: tab)
}
.help(tab.title)
}
.background(tab == selectedTab ? theme.secondaryBackgroundColor : .clear)
}
}
var body: some View {
@Bindable var routerPath = routerPath
HStack(spacing: 0) {
if horizontalSizeClass == .regular {
ScrollView {
VStack(alignment: .center) {
if appAccounts.availableAccounts.isEmpty {
@ -181,38 +136,25 @@ struct SideBarView<Content: View>: View {
}
}
}
postButton
.padding(.top, 12)
Spacer()
}
}
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
.frame(width: .sidebarWidth)
.scrollContentBackground(.hidden)
.background(.thinMaterial)
.safeAreaInset(edge: .bottom, content: {
HStack(spacing: 16) {
postButton
.padding(.vertical, 24)
.padding(.leading, userPreferences.isSidebarExpanded ? 18 : 0)
if userPreferences.isSidebarExpanded {
Text("menu.new-post")
.font(.subheadline)
.foregroundColor(theme.labelColor)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
.background(.thinMaterial)
})
Divider().edgesIgnoringSafeArea(.all)
}
Divider()
.edgesIgnoringSafeArea(.top)
content()
}
.background(.thinMaterial)
.edgesIgnoringSafeArea(.bottom)
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
}
}
private struct SideBarIcon: View {
@Environment(Theme.self) private var theme
@EnvironmentObject private var theme: Theme
let systemIconName: String
let isSelected: Bool
@ -224,19 +166,17 @@ private struct SideBarIcon: View {
.font(.title2)
.fontWeight(.medium)
.foregroundColor(isSelected ? theme.tintColor : theme.labelColor)
.symbolVariant(isSelected ? .fill : .none)
.scaleEffect(isHovered ? 0.8 : 1.0)
.onHover { isHovered in
withAnimation(.interpolatingSpring(stiffness: 300, damping: 15)) {
self.isHovered = isHovered
}
}
.frame(width: 50, height: 40)
}
}
extension View {
@MainActor func hideKeyboard() {
func hideKeyboard() {
let resign = #selector(UIResponder.resignFirstResponder)
UIApplication.shared.sendAction(resign, to: nil, from: nil, for: nil)
}

View file

@ -4,29 +4,44 @@ import Env
import Explore
import Models
import Network
import Shimmer
import SwiftUI
@MainActor
struct ExploreTab: View {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var preferences
@Environment(CurrentAccount.self) private var currentAccount
@Environment(Client.self) private var client
@State private var routerPath = RouterPath()
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var client: Client
@StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routerPath.path) {
ExploreView()
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.toolbar {
ToolbarTab(routerPath: $routerPath)
statusEditorToolbarItem(routerPath: routerPath,
visibility: preferences.postVisibility)
if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
}
}
if UIDevice.current.userInterfaceIdiom == .pad && !preferences.showiPadSecondaryColumn {
SecondaryColumnToolbarItem()
}
}
}
.withSafariRouter()
.environment(routerPath)
.onChange(of: client.id) {
.environmentObject(routerPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .explore {
routerPath.path = []
}
}
.onChange(of: client.id) { _ in
routerPath.path = []
}
.onAppear {

View file

@ -5,16 +5,17 @@ import DesignSystem
import Env
import Models
import Network
import Shimmer
import SwiftUI
@MainActor
struct MessagesTab: View {
@Environment(Theme.self) private var theme
@Environment(StreamWatcher.self) private var watcher
@Environment(Client.self) private var client
@Environment(CurrentAccount.self) private var currentAccount
@Environment(AppAccountsManager.self) private var appAccount
@State private var routerPath = RouterPath()
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var appAccount: AppAccountsManager
@StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routerPath.path) {
@ -22,18 +23,27 @@ struct MessagesTab: View {
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbar {
ToolbarTab(routerPath: $routerPath)
if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
}
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
}
}
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(client.id)
}
.onChange(of: client.id) {
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .messages {
routerPath.path = []
}
}
.onChange(of: client.id) { _ in
routerPath.path = []
}
.onAppear {
routerPath.client = client
}
.withSafariRouter()
.environment(routerPath)
.environmentObject(routerPath)
}
}

View file

@ -1,24 +0,0 @@
import AppAccount
import DesignSystem
import Env
import SwiftUI
@MainActor
struct NavigationSheet<Content: View>: View {
@Environment(\.dismiss) private var dismiss
var content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
NavigationStack {
content()
.toolbar {
CloseToolbarItem()
}
}
}
}

View file

@ -1,46 +0,0 @@
import AppAccount
import DesignSystem
import Env
import Network
import SwiftUI
@MainActor
struct NavigationTab<Content: View>: View {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(AppAccountsManager.self) private var appAccount
@Environment(CurrentAccount.self) private var currentAccount
@Environment(UserPreferences.self) private var userPreferences
@Environment(Theme.self) private var theme
@Environment(Client.self) private var client
var content: () -> Content
@State private var routerPath = RouterPath()
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
NavigationStack(path: $routerPath.path) {
content()
.withEnvironments()
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.withSafariRouter()
.toolbar {
ToolbarTab(routerPath: $routerPath)
}
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
.onChange(of: client.id) {
routerPath.path = []
}
.onAppear {
routerPath.client = client
}
.withSafariRouter()
}
.environment(routerPath)
}
}

View file

@ -7,21 +7,19 @@ import Notifications
import SwiftUI
import Timeline
@MainActor
struct NotificationsTab: View {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(\.scenePhase) private var scenePhase
@Environment(Theme.self) private var theme
@Environment(Client.self) private var client
@Environment(StreamWatcher.self) private var watcher
@Environment(AppAccountsManager.self) private var appAccount
@Environment(CurrentAccount.self) private var currentAccount
@Environment(UserPreferences.self) private var userPreferences
@Environment(PushNotificationsService.self) private var pushNotificationsService
@State private var routerPath = RouterPath()
@Binding var selectedTab: AppTab
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var appAccount: AppAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var userPreferences: UserPreferences
@EnvironmentObject private var pushNotificationsService: PushNotificationsService
@StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
let lockedType: Models.Notification.NotificationType?
@ -31,62 +29,70 @@ struct NotificationsTab: View {
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
routerPath.presentedSheet = .accountPushNotficationsSettings
} label: {
Image(systemName: "bell")
if !isSecondaryColumn {
statusEditorToolbarItem(routerPath: routerPath,
visibility: userPreferences.postVisibility)
if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
}
}
ToolbarTab(routerPath: $routerPath)
}
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
if UIDevice.current.userInterfaceIdiom == .pad {
if (!isSecondaryColumn && !userPreferences.showiPadSecondaryColumn) || isSecondaryColumn {
SecondaryColumnToolbarItem()
}
}
}
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(client.id)
}
.onAppear {
routerPath.client = client
if isSecondaryColumn {
clearNotifications()
}
}
.withSafariRouter()
.environment(routerPath)
.onChange(of: selectedTab) { _, _ in
clearNotifications()
.environmentObject(routerPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .notifications {
routerPath.path = []
}
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
if let newValue, let type = newValue.notification.supportedType {
}
.onChange(of: pushNotificationsService.handledNotification) { notification in
if let notification, let type = notification.notification.supportedType {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
switch type {
case .follow, .follow_request:
routerPath.navigate(to: .accountDetailWithAccount(account: newValue.notification.account))
routerPath.navigate(to: .accountDetailWithAccount(account: notification.notification.account))
default:
if let status = newValue.notification.status {
if let status = notification.notification.status {
routerPath.navigate(to: .statusDetailWithStatus(status: status))
}
}
}
}
}
.onChange(of: scenePhase) { _, newValue in
switch newValue {
.onChange(of: scenePhase, perform: { scenePhase in
switch scenePhase {
case .active:
clearNotifications()
default:
break
}
}
.onChange(of: client.id) {
})
.onChange(of: client.id) { _ in
routerPath.path = []
}
}
private func clearNotifications() {
if selectedTab == .notifications || isSecondaryColumn {
if let token = appAccount.currentAccount.oauthToken, userPreferences.notificationsCount[token] ?? 0 > 0 {
userPreferences.notificationsCount[token] = 0
if isSecondaryColumn {
if let token = appAccount.currentAccount.oauthToken {
userPreferences.setNotification(count: 0, token: token)
}
if watcher.unreadNotificationsCount > 0 {
watcher.unreadNotificationsCount = 0
}
}
}
}

View file

@ -5,15 +5,16 @@ import DesignSystem
import Env
import Models
import Network
import Shimmer
import SwiftUI
@MainActor
struct ProfileTab: View {
@Environment(AppAccountsManager.self) private var appAccount
@Environment(Theme.self) private var theme
@Environment(Client.self) private var client
@Environment(CurrentAccount.self) private var currentAccount
@State private var routerPath = RouterPath()
@EnvironmentObject private var appAccount: AppAccountsManager
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client
@EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routerPath.path) {
@ -21,21 +22,25 @@ struct ProfileTab: View {
AccountDetailView(account: account)
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(account.id)
} else {
AccountDetailView(account: .placeholder())
.redacted(reason: .placeholder)
.allowsHitTesting(false)
}
}
.onChange(of: client.id) {
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .profile {
routerPath.path = []
}
}
.onChange(of: client.id) { _ in
routerPath.path = []
}
.onAppear {
routerPath.client = client
}
.withSafariRouter()
.environment(routerPath)
.environmentObject(routerPath)
}
}

View file

@ -1,18 +1,10 @@
import Account
import DesignSystem
import Env
import Models
import Network
import SwiftUI
@MainActor
struct AboutView: View {
@Environment(RouterPath.self) private var routerPath
@Environment(Theme.self) private var theme
@Environment(Client.self) private var client
@State private var dimillianAccount: AccountsListRowViewModel?
@State private var iceCubesAccount: AccountsListRowViewModel?
@EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var theme: Theme
let versionNumber: String
@ -27,28 +19,27 @@ struct AboutView: View {
var body: some View {
List {
Section {
#if !targetEnvironment(macCatalyst) && !os(visionOS)
HStack {
Spacer()
Image(uiImage: .init(named: "AppIconAlternate0-image") ?? .init())
Image("icon0")
.resizable()
.frame(width: 50, height: 50)
.cornerRadius(4)
Image(uiImage: .init(named: "AppIconAlternate46-image") ?? .init())
Image("icon14")
.resizable()
.frame(width: 50, height: 50)
.cornerRadius(4)
Image(uiImage: .init(named: "AppIconAlternate17-image") ?? .init())
Image("icon17")
.resizable()
.frame(width: 50, height: 50)
.cornerRadius(4)
Image(uiImage: .init(named: "AppIconAlternate23-image") ?? .init())
Image("icon23")
.resizable()
.frame(width: 50, height: 50)
.cornerRadius(4)
Spacer()
}
#endif
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp/blob/main/PRIVACY.MD")!) {
Label("settings.support.privacy-policy", systemImage: "lock")
}
@ -57,25 +48,9 @@ struct AboutView: View {
Label("settings.support.terms-of-use", systemImage: "checkmark.shield")
}
} footer: {
Text("\(versionNumber)© 2024 Thomas Ricouard")
Text("\(versionNumber)©2023 Thomas Ricouard")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
followAccountsSection
Section("Telemetry") {
Link(destination: .init(string: "https://telemetrydeck.com")!) {
Label("Telemetry by TelemetryDeck", systemImage: "link")
}
Link(destination: .init(string: "https://telemetrydeck.com/privacy/")!) {
Label("Privacy Policy", systemImage: "checkmark.shield")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section {
Text("""
@ -105,77 +80,27 @@ struct AboutView: View {
""")
.multilineTextAlignment(.leading)
.font(.scaledSubheadline)
.foregroundStyle(.secondary)
.foregroundColor(.gray)
} header: {
Text("settings.about.built-with")
.textCase(nil)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.task {
await fetchAccounts()
}
.listStyle(.insetGrouped)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
.navigationTitle(Text("settings.about.title"))
.navigationBarTitleDisplayMode(.large)
.environment(\.openURL, OpenURLAction { url in
routerPath.handle(url: url)
})
}
@ViewBuilder
private var followAccountsSection: some View {
if let iceCubesAccount, let dimillianAccount {
Section {
AccountsListRow(viewModel: iceCubesAccount)
AccountsListRow(viewModel: dimillianAccount)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
} else {
Section {
ProgressView()
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
}
private func fetchAccounts() async {
await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
let viewModel = try await fetchAccountViewModel(client, account: "dimillian@mastodon.social")
await MainActor.run {
dimillianAccount = viewModel
}
}
group.addTask {
let viewModel = try await fetchAccountViewModel(client, account: "icecubesapp@mastodon.online")
await MainActor.run {
iceCubesAccount = viewModel
}
}
}
}
private func fetchAccountViewModel(_ client: Client, account: String) async throws -> AccountsListRowViewModel {
let dimillianAccount: Account = try await client.get(endpoint: Accounts.lookup(name: account))
let rel: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [dimillianAccount.id]))
return .init(account: dimillianAccount, relationShip: rel.first)
}
}
struct AboutView_Previews: PreviewProvider {
static var previews: some View {
AboutView()
.environment(Theme.shared)
.environmentObject(Theme.shared)
}
}

View file

@ -11,16 +11,16 @@ struct AccountSettingsView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@Environment(PushNotificationsService.self) private var pushNotifications
@Environment(CurrentAccount.self) private var currentAccount
@Environment(CurrentInstance.self) private var currentInstance
@Environment(Theme.self) private var theme
@Environment(AppAccountsManager.self) private var appAccountsManager
@Environment(Client.self) private var client
@Environment(RouterPath.self) private var routerPath
@EnvironmentObject private var pushNotifications: PushNotificationsService
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var client: Client
@State private var isEditingAccount: Bool = false
@State private var isEditingFilters: Bool = false
@State private var cachedPostsCount: Int = 0
@State private var timelineCache = TimelineCache()
let account: Account
let appAccount: AppAccount
@ -29,7 +29,7 @@ struct AccountSettingsView: View {
Form {
Section {
Button {
routerPath.presentedSheet = .accountEditInfo
isEditingAccount = true
} label: {
Label("account.action.edit-info", systemImage: "pencil")
.frame(maxWidth: .infinity, alignment: .leading)
@ -39,7 +39,7 @@ struct AccountSettingsView: View {
if currentInstance.isFiltersSupported {
Button {
routerPath.presentedSheet = .accountFiltersList
isEditingFilters = true
} label: {
Label("account.action.edit-filters", systemImage: "line.3.horizontal.decrease.circle")
.frame(maxWidth: .infinity, alignment: .leading)
@ -59,8 +59,8 @@ struct AccountSettingsView: View {
Label("settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
Button("settings.account.action.delete-cache", role: .destructive) {
Task {
await timelineCache.clearCache(for: appAccountsManager.currentClient.id)
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
await TimelineCache.shared.clearCache(for: appAccountsManager.currentClient.id)
cachedPostsCount = await TimelineCache.shared.cachedPostsCount(for: appAccountsManager.currentClient.id)
}
}
}
@ -80,38 +80,41 @@ struct AccountSettingsView: View {
if let token = appAccount.oauthToken {
Task {
let client = Client(server: appAccount.server, oauthToken: token)
await timelineCache.clearCache(for: client.id)
await TimelineCache.shared.clearCache(for: client.id)
if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
await sub.deleteSubscription()
}
appAccountsManager.delete(account: appAccount)
Telemetry.signal("account.removed")
dismiss()
}
}
} label: {
Label("account.action.logout", systemImage: "trash")
Text("account.action.logout")
.frame(maxWidth: .infinity)
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
.sheet(isPresented: $isEditingAccount, content: {
EditAccountView()
})
.sheet(isPresented: $isEditingFilters, content: {
FiltersListView()
})
.toolbar {
ToolbarItem(placement: .principal) {
HStack {
AvatarView(account.avatar, config: .embed)
AvatarView(url: account.avatar, size: .embed)
Text(account.safeDisplayName)
.font(.headline)
}
}
}
.task {
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
cachedPostsCount = await TimelineCache.shared.cachedPostsCount(for: appAccountsManager.currentClient.id)
}
.navigationTitle(account.safeDisplayName)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
}

View file

@ -1,5 +1,4 @@
import AppAccount
import AuthenticationServices
import Combine
import DesignSystem
import Env
@ -7,20 +6,18 @@ import Models
import Network
import NukeUI
import SafariServices
import Shimmer
import SwiftUI
@MainActor
struct AddAccountView: View {
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@Environment(\.openURL) private var openURL
@Environment(AppAccountsManager.self) private var appAccountsManager
@Environment(CurrentAccount.self) private var currentAccount
@Environment(CurrentInstance.self) private var currentInstance
@Environment(PushNotificationsService.self) private var pushNotifications
@Environment(Theme.self) private var theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var pushNotifications: PushNotificationsService
@EnvironmentObject private var theme: Theme
@State private var instanceName: String = ""
@State private var instance: Instance?
@ -28,9 +25,7 @@ struct AddAccountView: View {
@State private var signInClient: Client?
@State private var instances: [InstanceSocial] = []
@State private var instanceFetchError: LocalizedStringKey?
@State private var instanceSocialClient = InstanceSocialClient()
@State private var searchingTask = Task<Void, Never> {}
@State private var getInstanceDetailTask = Task<Void, Never> {}
@State private var oauthURL: URL?
private let instanceNamePublisher = PassthroughSubject<String, Never>()
@ -48,25 +43,16 @@ struct AddAccountView: View {
@FocusState private var isInstanceURLFieldFocused: Bool
private func cleanServerStr(_ server: String) -> String {
server.replacingOccurrences(of: " ", with: "")
}
var body: some View {
NavigationStack {
Form {
TextField("instance.url", text: $instanceName)
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
.keyboardType(.URL)
.textContentType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isInstanceURLFieldFocused)
.onChange(of: instanceName) { _, _ in
instanceName = cleanServerStr(instanceName)
}
if let instanceFetchError {
Text(instanceFetchError)
}
@ -82,52 +68,40 @@ struct AddAccountView: View {
.formStyle(.grouped)
.navigationTitle("account.add.navigation-title")
.navigationBarTitleDisplayMode(.inline)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
#endif
.toolbar {
CancelToolbarItem()
if !appAccountsManager.availableAccounts.isEmpty {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { dismiss() })
}
}
}
.onAppear {
isInstanceURLFieldFocused = true
let instanceName = instanceName
let client = InstanceSocialClient()
Task {
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
let instances = await client.fetchInstances()
withAnimation {
self.instances = instances
}
}
isSigninIn = false
}
.onChange(of: instanceName) {
searchingTask.cancel()
let instanceName = instanceName
let instanceSocialClient = instanceSocialClient
searchingTask = Task {
try? await Task.sleep(for: .seconds(0.1))
guard !Task.isCancelled else { return }
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
withAnimation {
self.instances = instances
.onChange(of: instanceName) { newValue in
instanceNamePublisher.send(newValue)
}
}
getInstanceDetailTask.cancel()
getInstanceDetailTask = Task {
try? await Task.sleep(for: .seconds(0.1))
guard !Task.isCancelled else { return }
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { _ in
// let newValue = newValue
// .replacingOccurrences(of: "http://", with: "")
// .replacingOccurrences(of: "https://", with: "")
let client = Client(server: sanitizedName)
Task {
do {
// bare bones preflight for domain validity
let instanceDetailClient = Client(server: sanitizedName)
if
instanceDetailClient.server.contains("."),
instanceDetailClient.server.last != "."
{
let instance: Instance = try await instanceDetailClient.get(endpoint: Instances.instance)
if client.server.contains(".") && client.server.last != "." {
let instance: Instance = try await client.get(endpoint: Instances.instance)
withAnimation {
self.instance = instance
self.instanceName = sanitizedName // clean up the text box, principally to chop off the username if present so it's clear that you might not wind up siging in as the thing in the box
@ -137,23 +111,35 @@ struct AddAccountView: View {
instance = nil
instanceFetchError = nil
}
} catch _ as ServerError {
} catch _ as DecodingError {
instance = nil
instanceFetchError = "account.add.error.instance-not-supported"
} catch {
instance = nil
instanceFetchError = nil
}
}
}
.onChange(of: scenePhase) { _, newValue in
switch newValue {
.onChange(of: scenePhase, perform: { scenePhase in
switch scenePhase {
case .active:
isSigninIn = false
default:
break
}
})
.onOpenURL(perform: { url in
Task {
await continueSignIn(url: url)
}
})
.onChange(of: oauthURL, perform: { newValue in
if newValue == nil {
isSigninIn = false
}
})
.sheet(item: $oauthURL, content: { url in
SafariView(url: url)
})
}
}
@ -182,9 +168,7 @@ struct AddAccountView: View {
}
.buttonStyle(.borderedProminent)
}
#if !os(visionOS)
.listRowBackground(theme.tintColor)
#endif
}
private var instancesListView: some View {
@ -192,65 +176,30 @@ struct AddAccountView: View {
if instances.isEmpty {
placeholderRow
} else {
ForEach(instances) { instance in
ForEach(sanitizedName.isEmpty ? instances : instances.filter { $0.name.contains(sanitizedName.lowercased()) }) { instance in
Button {
instanceName = instance.name
self.instanceName = instance.name
} label: {
VStack(alignment: .leading, spacing: 4) {
LazyImage(url: instance.thumbnail) { state in
if let image = state.image {
image
.resizable()
.scaledToFill()
} else {
Rectangle().fill(theme.tintColor.opacity(0.1))
}
}
.frame(height: 100)
.frame(maxWidth: .infinity)
.clipped()
VStack(alignment: .leading) {
HStack {
Text(instance.name)
.font(.scaledHeadline)
.foregroundColor(.primary)
Spacer()
(Text("instance.list.users-\(formatAsNumber(instance.users))")
Text(instance.info?.shortDescription ?? "")
.font(.scaledBody)
.foregroundColor(.gray)
(Text("instance.list.users-\(instance.users)")
+ Text("")
+ Text("instance.list.posts-\(formatAsNumber(instance.statuses))"))
.foregroundStyle(theme.tintColor)
}
.padding(.bottom, 5)
Text(instance.info?.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
.foregroundStyle(Color.secondary)
.lineLimit(10)
}
+ Text("instance.list.posts-\(instance.statuses)"))
.font(.scaledFootnote)
.padding(10)
.foregroundColor(.gray)
}
}
#if !os(visionOS)
.background(theme.primaryBackgroundColor)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0))
.listRowSeparator(.hidden)
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
.listRowBackground(theme.primaryBackgroundColor)
}
}
}
}
private func formatAsNumber(_ string: String) -> String {
(Int(string) ?? 0)
.formatted(
.number
.notation(.compactName)
.locale(.current)
)
}
private var placeholderRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("placeholder.loading.short")
@ -258,28 +207,27 @@ struct AddAccountView: View {
.foregroundColor(.primary)
Text("placeholder.loading.long")
.font(.scaledBody)
.foregroundStyle(.secondary)
.foregroundColor(.gray)
Text("placeholder.loading.short")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.foregroundColor(.gray)
}
.redacted(reason: .placeholder)
.allowsHitTesting(false)
#if !os(visionOS)
.shimmering()
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private func signIn() async {
do {
signInClient = .init(server: sanitizedName)
if let oauthURL = try? await signInClient?.oauthURL(),
let url = try? await webAuthenticationSession.authenticate(using: oauthURL,
callbackURLScheme: AppInfo.scheme.replacingOccurrences(of: "://", with: ""))
{
await continueSignIn(url: url)
if let oauthURL = try await signInClient?.oauthURL() {
self.oauthURL = oauthURL
} else {
isSigninIn = false
}
} catch {
isSigninIn = false
}
}
private func continueSignIn(url: URL) async {
@ -288,10 +236,10 @@ struct AddAccountView: View {
return
}
do {
oauthURL = nil
let oauthToken = try await client.continueOauthFlow(url: url)
let client = Client(server: client.server, oauthToken: oauthToken)
let account: Account = try await client.get(endpoint: Accounts.verifyCredentials)
Telemetry.signal("account.added")
appAccountsManager.add(account: AppAccount(server: client.server,
accountName: "\(account.acct)@\(client.server)",
oauthToken: oauthToken))
@ -302,7 +250,18 @@ struct AddAccountView: View {
isSigninIn = false
dismiss()
} catch {
oauthURL = nil
isSigninIn = false
}
}
}
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context _: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_: SFSafariViewController, context _: UIViewControllerRepresentableContext<SafariView>) {}
}

View file

@ -5,33 +5,28 @@ import Models
import Network
import NukeUI
import SwiftUI
import Timeline
import UserNotifications
@MainActor
struct ContentSettingsView: View {
@Environment(UserPreferences.self) private var userPreferences
@Environment(Theme.self) private var theme
@State private var contentFilter = TimelineContentFilter.shared
@EnvironmentObject private var userPreferences: UserPreferences
@EnvironmentObject private var theme: Theme
var body: some View {
@Bindable var userPreferences = userPreferences
Form {
Section("settings.content.boosts") {
Toggle(isOn: $userPreferences.suppressDupeReblogs) {
Text("settings.content.hide-repeated-boosts")
}
}.listRowBackground(theme.primaryBackgroundColor)
Section("settings.content.media") {
Toggle(isOn: $userPreferences.autoPlayVideo) {
Text("settings.other.autoplay-video")
}
Toggle(isOn: $userPreferences.muteVideo) {
Text("settings.other.mute-video")
}
Toggle(isOn: $userPreferences.showAltTextForMedia) {
Text("settings.content.media.show.alt")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}.listRowBackground(theme.primaryBackgroundColor)
Section("settings.content.sharing") {
Picker("settings.content.sharing.share-button-behavior", selection: $userPreferences.shareButtonBehavior) {
@ -41,25 +36,20 @@ struct ContentSettingsView: View {
}
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section("settings.content.instance-settings") {
Toggle(isOn: $userPreferences.useInstanceContentSettings) {
Text("settings.content.use-instance-settings")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
.onChange(of: userPreferences.useInstanceContentSettings) { _, newVal in
.onChange(of: userPreferences.useInstanceContentSettings) { newVal in
if newVal {
userPreferences.appAutoExpandSpoilers = userPreferences.autoExpandSpoilers
userPreferences.appAutoExpandMedia = userPreferences.autoExpandMedia
userPreferences.appDefaultPostsSensitive = userPreferences.postIsSensitive
userPreferences.appDefaultPostVisibility = userPreferences.postVisibility
userPreferences.appRequireAltText = userPreferences.appRequireAltText
}
}
@ -84,9 +74,7 @@ struct ContentSettingsView: View {
} footer: {
Text("settings.content.collapse-long-posts-hint")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section("settings.content.posting") {
Picker("settings.content.default-visibility", selection: $userPreferences.appDefaultPostVisibility) {
@ -99,13 +87,12 @@ struct ContentSettingsView: View {
Picker("settings.content.default-reply-visibility", selection: $userPreferences.appDefaultReplyVisibility) {
ForEach(Visibility.allCases, id: \.rawValue) { vis in
if UserPreferences.getIntOfVisibility(vis) <=
UserPreferences.getIntOfVisibility(userPreferences.postVisibility)
{
UserPreferences.getIntOfVisibility(userPreferences.postVisibility) {
Text(vis.title).tag(vis)
}
}
}
.onChange(of: userPreferences.postVisibility) {
.onChange(of: userPreferences.postVisibility) { newValue in
userPreferences.conformReplyVisibilityConstraints()
}
@ -113,37 +100,12 @@ struct ContentSettingsView: View {
Text("settings.content.default-sensitive")
}
.disabled(userPreferences.useInstanceContentSettings)
}
Toggle(isOn: $userPreferences.appRequireAltText) {
Text("settings.content.require-alt-text")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section("timeline.content-filter.title") {
Toggle(isOn: $contentFilter.showBoosts) {
Label("timeline.filter.show-boosts", image: "Rocket")
}
Toggle(isOn: $contentFilter.showReplies) {
Label("timeline.filter.show-replies", systemImage: "bubble.left.and.bubble.right")
}
Toggle(isOn: $contentFilter.showThreads) {
Label("timeline.filter.show-threads", systemImage: "bubble.left.and.text.bubble.right")
}
Toggle(isOn: $contentFilter.showQuotePosts) {
Label("timeline.filter.show-quote", systemImage: "quote.bubble")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.content.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
}

View file

@ -3,29 +3,57 @@ import DesignSystem
import Env
import Models
import Network
import Observation
import StatusKit
import Status
import SwiftUI
@MainActor
@Observable class DisplaySettingsLocalValues {
var tintColor = Theme.shared.tintColor
var primaryBackgroundColor = Theme.shared.primaryBackgroundColor
var secondaryBackgroundColor = Theme.shared.secondaryBackgroundColor
var labelColor = Theme.shared.labelColor
var lineSpacing = Theme.shared.lineSpacing
var fontSizeScale = Theme.shared.fontSizeScale
class DisplaySettingsLocalValues: ObservableObject {
@Published var tintColor = Theme.shared.tintColor
@Published var primaryBackgroundColor = Theme.shared.primaryBackgroundColor
@Published var secondaryBackgroundColor = Theme.shared.secondaryBackgroundColor
@Published var labelColor = Theme.shared.labelColor
@Published var lineSpacing = Theme.shared.lineSpacing
@Published var fontSizeScale = Theme.shared.fontSizeScale
private let debouncesDelay: DispatchQueue.SchedulerTimeType.Stride = .seconds(0.5)
private var subscriptions = Set<AnyCancellable>()
init() {
$tintColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.tintColor = newColor })
.store(in: &subscriptions)
$primaryBackgroundColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.primaryBackgroundColor = newColor })
.store(in: &subscriptions)
$secondaryBackgroundColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.secondaryBackgroundColor = newColor })
.store(in: &subscriptions)
$labelColor
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newColor in Theme.shared.labelColor = newColor })
.store(in: &subscriptions)
$lineSpacing
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newSpacing in Theme.shared.lineSpacing = newSpacing })
.store(in: &subscriptions)
$fontSizeScale
.debounce(for: debouncesDelay, scheduler: DispatchQueue.main)
.sink(receiveValue: { newScale in Theme.shared.fontSizeScale = newScale })
.store(in: &subscriptions)
}
}
@MainActor
struct DisplaySettingsView: View {
typealias FontState = Theme.FontState
@Environment(\.colorScheme) private var colorScheme
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var userPreferences
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var userPreferences: UserPreferences
@State private var localValues = DisplaySettingsLocalValues()
@StateObject private var localValues = DisplaySettingsLocalValues()
@State private var isFontSelectorPresented = false
@ -36,56 +64,26 @@ struct DisplaySettingsView: View {
var body: some View {
ZStack(alignment: .top) {
Form {
#if !os(visionOS)
StatusRowExternalView(viewModel: previewStatusViewModel)
StatusRowView(viewModel: { previewStatusViewModel })
.allowsHitTesting(false)
.opacity(0)
.hidden()
themeSection
#endif
fontSection
layoutSection
platformsSection
resetSection
}
.navigationTitle("settings.display.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
.task(id: localValues.tintColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.tintColor = localValues.tintColor
}
.task(id: localValues.primaryBackgroundColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.primaryBackgroundColor = localValues.primaryBackgroundColor
}
.task(id: localValues.secondaryBackgroundColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.secondaryBackgroundColor = localValues.secondaryBackgroundColor
}
.task(id: localValues.labelColor) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.labelColor = localValues.labelColor
}
.task(id: localValues.lineSpacing) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.lineSpacing = localValues.lineSpacing
}
.task(id: localValues.fontSizeScale) {
do { try await Task.sleep(for: .microseconds(500)) } catch {}
theme.fontSizeScale = localValues.fontSizeScale
}
#if !os(visionOS)
examplePost
#endif
}
}
private var examplePost: some View {
VStack(spacing: 0) {
StatusRowExternalView(viewModel: previewStatusViewModel)
StatusRowView(viewModel: { previewStatusViewModel })
.allowsHitTesting(false)
.padding(.layoutPadding)
.background(theme.primaryBackgroundColor)
@ -101,9 +99,7 @@ struct DisplaySettingsView: View {
}
}
@ViewBuilder
private var themeSection: some View {
@Bindable var theme = theme
Section {
Toggle("settings.display.theme.systemColor", isOn: $theme.followSystemColorScheme)
themeSelectorButton
@ -115,7 +111,7 @@ struct DisplaySettingsView: View {
}
.disabled(theme.followSystemColorScheme)
.opacity(theme.followSystemColorScheme ? 0.5 : 1.0)
.onChange(of: theme.selectedSet) {
.onChange(of: theme.selectedSet) { _ in
localValues.tintColor = theme.tintColor
localValues.primaryBackgroundColor = theme.primaryBackgroundColor
localValues.secondaryBackgroundColor = theme.secondaryBackgroundColor
@ -128,9 +124,7 @@ struct DisplaySettingsView: View {
Text("settings.display.section.theme.footer")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private var fontSection: some View {
@ -182,15 +176,10 @@ struct DisplaySettingsView: View {
d[.leading]
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var layoutSection: some View {
@Bindable var theme = theme
@Bindable var userPreferences = userPreferences
Section("settings.display.section.display") {
Picker("settings.display.avatar.position", selection: $theme.avatarPosition) {
ForEach(Theme.AvatarPosition.allCases, id: \.rawValue) { position in
@ -208,67 +197,53 @@ struct DisplaySettingsView: View {
Text(buttonStyle.description).tag(buttonStyle)
}
}
Picker("settings.display.status.action-secondary", selection: $theme.statusActionSecondary) {
ForEach(Theme.StatusActionSecondary.allCases, id: \.rawValue) { action in
Text(action.description).tag(action)
}
}
Picker("settings.display.status.media-style", selection: $theme.statusDisplayStyle) {
ForEach(Theme.StatusDisplayStyle.allCases, id: \.rawValue) { buttonStyle in
Text(buttonStyle.description).tag(buttonStyle)
}
}
Toggle("settings.display.translate-button", isOn: $userPreferences.showTranslateButton)
Toggle("settings.display.pending-at-bottom", isOn: $userPreferences.pendingShownAtBottom)
Toggle("settings.display.pending-left", isOn: $userPreferences.pendingShownLeft)
Toggle("settings.display.show-reply-indentation", isOn: $userPreferences.showReplyIndentation)
if userPreferences.showReplyIndentation {
VStack {
Slider(value: .init(get: {
Double(userPreferences.maxReplyIndentation)
}, set: { newVal in
userPreferences.maxReplyIndentation = UInt(newVal)
}), in: 1 ... 20, step: 1)
Text("settings.display.max-reply-indentation-\(String(userPreferences.maxReplyIndentation))")
.font(.scaledBody)
}
.alignmentGuide(.listRowSeparatorLeading) { d in
d[.leading]
}
}
Toggle("settings.display.show-account-popover", isOn: $userPreferences.showAccountPopover)
Toggle("Show Content Gradient", isOn: $theme.showContentGradient)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var platformsSection: some View {
@Bindable var userPreferences = userPreferences
if UIDevice.current.userInterfaceIdiom == .phone {
Section("iPhone") {
Toggle("settings.display.show-tab-label", isOn: $userPreferences.showiPhoneTabLabel)
}
.listRowBackground(theme.primaryBackgroundColor)
}
if UIDevice.current.userInterfaceIdiom == .pad {
Section("settings.display.section.platform") {
Section("iPad") {
Toggle("settings.display.show-ipad-column", isOn: $userPreferences.showiPadSecondaryColumn)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
}
private var resetSection: some View {
Section {
Button {
theme.restoreDefault()
theme.followSystemColorScheme = true
theme.selectedSet = colorScheme == .dark ? .iceCubeDark : .iceCubeLight
theme.avatarShape = .rounded
theme.avatarPosition = .top
theme.statusActionsDisplay = .full
localValues.tintColor = theme.tintColor
localValues.primaryBackgroundColor = theme.primaryBackgroundColor
localValues.secondaryBackgroundColor = theme.secondaryBackgroundColor
localValues.labelColor = theme.labelColor
} label: {
Text("settings.display.restore")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private var themeSelectorButton: some View {

View file

@ -1,30 +1,24 @@
import DesignSystem
import Env
import Models
import StatusKit
import Status
import SwiftUI
@MainActor
struct HapticSettingsView: View {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var userPreferences
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var userPreferences: UserPreferences
var body: some View {
@Bindable var userPreferences = userPreferences
Form {
Section {
Toggle("settings.haptic.timeline", isOn: $userPreferences.hapticTimelineEnabled)
Toggle("settings.haptic.tab-selection", isOn: $userPreferences.hapticTabSelectionEnabled)
Toggle("settings.haptic.buttons", isOn: $userPreferences.hapticButtonPressEnabled)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.haptic.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
}

View file

@ -1,7 +1,6 @@
import DesignSystem
import SwiftUI
@MainActor
struct IconSelectorView: View {
enum Icon: Int, CaseIterable, Identifiable {
var id: String {
@ -9,7 +8,7 @@ struct IconSelectorView: View {
}
init(string: String) {
if string == "AppIcon" {
if string == Icon.primary.appIconName {
self = .primary
} else {
self = .init(rawValue: Int(String(string.replacing("AppIconAlternate", with: "")))!)!
@ -17,24 +16,30 @@ struct IconSelectorView: View {
}
case primary = 0
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8, alt9, alt10, alt11, alt12, alt13, alt14, alt15
case alt16, alt17, alt18, alt19, alt20, alt21
case alt22, alt23, alt24, alt25, alt26
case alt27, alt28, alt29
case alt30, alt31, alt32, alt33, alt34, alt35, alt36
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8
case alt9, alt10, alt11, alt12, alt13, alt14
case alt15, alt16, alt17, alt18, alt19, alt20, alt21
case alt22, alt23, alt24, alt25
case alt26, alt27, alt28
case alt29, alt30, alt31, alt32
case alt33
case alt34, alt35
case alt36
case alt37
case alt38
case alt39, alt40, alt41, alt42, alt43
case alt44, alt45
case alt46, alt47, alt48
case alt49
case alt38, alt39
case alt40
var appIconName: String {
switch self {
case .primary:
return "AppIcon"
default:
return "AppIconAlternate\(rawValue)"
}
}
var previewImageName: String {
return "AppIconAlternate\(rawValue)-image"
var iconName: String {
"icon\(rawValue)"
}
}
@ -44,24 +49,20 @@ struct IconSelectorView: View {
let icons: [Icon]
static let items = [
IconSelector(title: "settings.app.icon.official".localized, icons: [
.primary, .alt46, .alt1, .alt2, .alt3, .alt4,
.alt5, .alt6, .alt7, .alt8,
.alt9, .alt10, .alt11, .alt12, .alt13, .alt14, .alt15,
.alt16, .alt17, .alt18, .alt19, .alt20, .alt21]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Albert Kinng", icons: [.alt22, .alt23, .alt24, .alt25, .alt26]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Dan van Moll", icons: [.alt27, .alt28, .alt29]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Chanhwi Joo (GitHub @te6-in)", icons: [.alt30, .alt31, .alt32, .alt33, .alt34, .alt35, .alt36]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) W. Kovács Ágnes (@wildgica)", icons: [.alt37]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Duncan Horne", icons: [.alt38]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) BeAware@social.beaware.live", icons: [.alt39, .alt40, .alt41, .alt42, .alt43]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Simone Margio", icons: [.alt44, .alt45]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Peter Broqvist (@PKB)", icons: [.alt47, .alt48]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Oz Tsori (@oztsori)", icons: [.alt49]),
IconSelector(title: "settings.app.icon.official".localized, icons: [.primary, .alt1, .alt2, .alt3, .alt4, .alt5, .alt6, .alt7, .alt8,
.alt9, .alt10, .alt11, .alt12, .alt13, .alt14,
.alt15, .alt16, .alt17, .alt18, .alt19, .alt25]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Albert Kinng", icons: [.alt20, .alt21, .alt22, .alt23, .alt24]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Dan van Moll", icons: [.alt26, .alt27, .alt28]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Chanhwi Joo (GitHub @te6-in)", icons: [.alt29, .alt34, .alt31, .alt35, .alt30, .alt32, .alt40]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) W. Kovács Ágnes (@wildgica)", icons: [.alt33]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Duncan Horne", icons: [.alt36]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) BeAware@social.beaware.live", icons: [.alt37]),
IconSelector(title: "\("settings.app.icon.designed-by".localized) Simone Margio", icons: [.alt38, .alt39]),
]
}
@Environment(Theme.self) private var theme
@EnvironmentObject private var theme: Theme
@State private var currentIcon = UIApplication.shared.alternateIconName ?? Icon.primary.appIconName
private let columns = [GridItem(.adaptive(minimum: 125, maximum: 1024))]
@ -81,9 +82,7 @@ struct IconSelectorView: View {
.padding(6)
.navigationTitle("settings.app.icon.navigation-title")
}
#if !os(visionOS)
.background(theme.primaryBackgroundColor)
#endif
}
private func makeIconGridView(icons: [Icon]) -> some View {
@ -101,7 +100,7 @@ struct IconSelectorView: View {
}
} label: {
ZStack(alignment: .bottomTrailing) {
Image(icon.previewImageName)
Image(uiImage: .init(named: icon.iconName) ?? .init())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(minHeight: 125, maxHeight: 1024)
@ -114,7 +113,6 @@ struct IconSelectorView: View {
}
}
}
.buttonStyle(.plain)
}
}
}
@ -122,6 +120,6 @@ struct IconSelectorView: View {
extension String {
var localized: String {
NSLocalizedString(self, comment: "")
return NSLocalizedString(self, comment: "")
}
}

View file

@ -4,7 +4,7 @@ import NukeUI
import SwiftUI
struct InstanceInfoView: View {
@Environment(Theme.self) private var theme
@EnvironmentObject private var theme: Theme
let instance: Instance
@ -13,15 +13,13 @@ struct InstanceInfoView: View {
InstanceInfoSection(instance: instance)
}
.navigationTitle("instance.info.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
}
public struct InstanceInfoSection: View {
@Environment(Theme.self) private var theme
@EnvironmentObject private var theme: Theme
let instance: Instance
@ -37,9 +35,7 @@ public struct InstanceInfoSection: View {
LabeledContent("instance.info.posts", value: format(instance.stats.statusCount))
LabeledContent("instance.info.domains", value: format(instance.stats.domainCount))
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
if let rules = instance.rules {
Section("instance.info.section.rules") {
@ -47,9 +43,7 @@ public struct InstanceInfoSection: View {
Text(rule.text.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
}

View file

@ -7,13 +7,12 @@ import NukeUI
import SwiftUI
import UserNotifications
@MainActor
struct PushNotificationsView: View {
@Environment(Theme.self) private var theme
@Environment(AppAccountsManager.self) private var appAccountsManager
@Environment(PushNotificationsService.self) private var pushNotifications
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var pushNotifications: PushNotificationsService
@State public var subscription: PushNotificationSubscriptionSettings
@StateObject public var subscription: PushNotificationSubscriptionSettings
var body: some View {
Form {
@ -33,9 +32,7 @@ struct PushNotificationsView: View {
} footer: {
Text("settings.push.main-toggle.description")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
if subscription.isEnabled {
Section {
@ -88,9 +85,7 @@ struct PushNotificationsView: View {
Label("settings.push.new-posts", systemImage: "bubble.right")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
Section {
@ -105,15 +100,11 @@ struct PushNotificationsView: View {
} footer: {
Text("settings.push.duplicate.footer")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.push.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
.task {
await subscription.fetchSubscription()
}

View file

@ -1,44 +0,0 @@
import DesignSystem
import Env
import Models
import SwiftData
import SwiftUI
struct RecenTagsSettingView: View {
@Environment(\.modelContext) private var context
@Environment(RouterPath.self) private var routerPath
@Environment(Theme.self) private var theme
@Query(sort: \RecentTag.lastUse, order: .reverse) var tags: [RecentTag]
var body: some View {
Form {
ForEach(tags) { tag in
VStack(alignment: .leading) {
Text("#\(tag.title)")
.font(.scaledBody)
.foregroundColor(.primary)
Text(tag.formattedDate)
.font(.scaledFootnote)
.foregroundStyle(.secondary)
}
}.onDelete { indexes in
if let index = indexes.first {
context.delete(tags[index])
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.general.recent-tags")
.scrollContentBackground(.hidden)
#if !os(visionOS)
.background(theme.secondaryBackgroundColor)
#endif
.toolbar {
EditButton()
}
}
}

View file

@ -1,45 +0,0 @@
import DesignSystem
import Env
import Models
import SwiftData
import SwiftUI
struct RemoteTimelinesSettingView: View {
@Environment(\.modelContext) private var context
@Environment(RouterPath.self) private var routerPath
@Environment(Theme.self) private var theme
@Query(sort: \LocalTimeline.creationDate, order: .reverse) var localTimelines: [LocalTimeline]
var body: some View {
Form {
ForEach(localTimelines) { timeline in
Text(timeline.instance)
}.onDelete { indexes in
if let index = indexes.first {
context.delete(localTimelines[index])
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("settings.timeline.add", systemImage: "badge.plus.radiowaves.right")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.general.remote-timelines")
.scrollContentBackground(.hidden)
#if !os(visionOS)
.background(theme.secondaryBackgroundColor)
#endif
.toolbar {
EditButton()
}
}
}

View file

@ -6,31 +6,26 @@ import Foundation
import Models
import Network
import Nuke
import SwiftData
import SwiftUI
import Timeline
@MainActor
struct SettingsTabs: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(PushNotificationsService.self) private var pushNotifications
@Environment(UserPreferences.self) private var preferences
@Environment(Client.self) private var client
@Environment(CurrentInstance.self) private var currentInstance
@Environment(AppAccountsManager.self) private var appAccountsManager
@Environment(Theme.self) private var theme
@EnvironmentObject private var pushNotifications: PushNotificationsService
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var client: Client
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var theme: Theme
@StateObject private var routerPath = RouterPath()
@State private var routerPath = RouterPath()
@State private var addAccountSheetPresented = false
@State private var isEditingAccount = false
@State private var cachedRemoved = false
@State private var timelineCache = TimelineCache()
let isModal: Bool
@State private var startingPoint: SettingsStartingPoint? = nil
@Binding var popToRootTab: Tab
var body: some View {
NavigationStack(path: $routerPath.path) {
@ -39,19 +34,15 @@ struct SettingsTabs: View {
accountsSection
generalSection
otherSections
postStreamingSection
AISection
cacheSection
}
.scrollContentBackground(.hidden)
#if !os(visionOS)
.background(theme.secondaryBackgroundColor)
#endif
.navigationTitle(Text("settings.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.toolbar {
if isModal {
if UIDevice.current.userInterfaceIdiom == .phone {
ToolbarItem {
Button {
dismiss()
@ -60,38 +51,12 @@ struct SettingsTabs: View {
}
}
}
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn, !isModal {
if UIDevice.current.userInterfaceIdiom == .pad && !preferences.showiPadSecondaryColumn {
SecondaryColumnToolbarItem()
}
}
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.onAppear {
startingPoint = RouterPath.settingsStartingPoint
RouterPath.settingsStartingPoint = nil
}
.navigationDestination(item: $startingPoint) { targetView in
switch targetView {
case .display:
DisplaySettingsView()
case .haptic:
HapticSettingsView()
case .remoteTimelines:
RemoteTimelinesSettingView()
case .tagGroups:
TagsGroupSettingView()
case .recentTags:
RecenTagsSettingView()
case .content:
ContentSettingsView()
case .swipeActions:
SwipeActionsSettingsView()
case .tabAndSidebarEntries:
EmptyView()
case .translation:
TranslationSettingsView()
}
}
}
.onAppear {
routerPath.client = client
@ -102,7 +67,12 @@ struct SettingsTabs: View {
}
}
.withSafariRouter()
.environment(routerPath)
.environmentObject(routerPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .notifications {
routerPath.path = []
}
}
}
private var accountsSection: some View {
@ -120,7 +90,7 @@ struct SettingsTabs: View {
.tint(.red)
}
}
AppAccountView(viewModel: .init(appAccount: account), isParentPresented: .constant(false))
AppAccountView(viewModel: .init(appAccount: account))
}
}
.onDelete { indexSet in
@ -131,14 +101,12 @@ struct SettingsTabs: View {
}
}
}
addAccountButton
if !appAccountsManager.availableAccounts.isEmpty {
editAccountButton
}
addAccountButton
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private func logoutAccount(account: AppAccount) async {
@ -146,10 +114,9 @@ struct SettingsTabs: View {
let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token })
{
let client = Client(server: account.server, oauthToken: token)
await timelineCache.clearCache(for: client.id)
await TimelineCache.shared.clearCache(for: client.id)
await sub.deleteSubscription()
appAccountsManager.delete(account: account)
Telemetry.signal("account.removed")
}
}
@ -169,50 +136,32 @@ struct SettingsTabs: View {
Label("settings.general.haptic", systemImage: "waveform.path")
}
}
NavigationLink(destination: RemoteTimelinesSettingView()) {
NavigationLink(destination: remoteLocalTimelinesView) {
Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right")
}
NavigationLink(destination: TagsGroupSettingView()) {
NavigationLink(destination: tagGroupsView) {
Label("timeline.filter.tag-groups", systemImage: "number")
}
NavigationLink(destination: RecenTagsSettingView()) {
Label("settings.general.recent-tags", systemImage: "clock")
}
NavigationLink(destination: ContentSettingsView()) {
Label("settings.general.content", systemImage: "rectangle.stack")
}
NavigationLink(destination: SwipeActionsSettingsView()) {
Label("settings.general.swipeactions", systemImage: "hand.draw")
}
if UIDevice.current.userInterfaceIdiom == .phone || horizontalSizeClass == .compact {
NavigationLink(destination: TabbarEntriesSettingsView()) {
Label("settings.general.tabbarEntries", systemImage: "platter.filled.bottom.iphone")
}
} else if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
NavigationLink(destination: SidebarEntriesSettingsView()) {
Label("settings.general.sidebarEntries", systemImage: "sidebar.squares.leading")
}
}
NavigationLink(destination: TranslationSettingsView()) {
Label("settings.general.translate", systemImage: "captions.bubble")
}
#if !targetEnvironment(macCatalyst)
Link(destination: URL(string: UIApplication.openSettingsURLString)!) {
Label("settings.system", systemImage: "gear")
}
.tint(theme.labelColor)
#endif
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var otherSections: some View {
@Bindable var preferences = preferences
Section {
#if !targetEnvironment(macCatalyst)
Section("settings.section.other") {
if !ProcessInfo.processInfo.isiOSAppOnMac {
Picker(selection: $preferences.preferredBrowser) {
ForEach(PreferredBrowser.allCases, id: \.rawValue) { browser in
switch browser {
@ -229,79 +178,35 @@ struct SettingsTabs: View {
Label("settings.general.browser.in-app.readerview", systemImage: "doc.plaintext")
}
.disabled(preferences.preferredBrowser != PreferredBrowser.inAppSafari)
#endif
}
Toggle(isOn: $preferences.isOpenAIEnabled) {
Label("settings.other.hide-openai", systemImage: "faxmachine")
}
Toggle(isOn: $preferences.isSocialKeyboardEnabled) {
Label("settings.other.social-keyboard", systemImage: "keyboard")
}
Toggle(isOn: $preferences.soundEffectEnabled) {
Label("settings.other.sound-effect", systemImage: "hifispeaker")
}
Toggle(isOn: $preferences.fastRefreshEnabled) {
Label("settings.other.fast-refresh", systemImage: "arrow.clockwise")
}
} header: {
Text("settings.section.other")
} footer: {
Text("settings.section.other.footer")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var postStreamingSection: some View {
@Bindable var preferences = preferences
Section {
Toggle(isOn: $preferences.isPostsStreamingEnabled) {
Label("Posts streaming", systemImage: "clock.badge")
}
} header: {
Text("Streaming")
} footer: {
Text("Enabling post streaming will automatically add new posts at the top of your home timeline. Disable if you get performance issues.")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var AISection: some View {
@Bindable var preferences = preferences
Section {
Toggle(isOn: $preferences.isOpenAIEnabled) {
Label("settings.other.hide-openai", systemImage: "faxmachine")
}
} header: {
Text("AI")
} footer: {
Text("Disable to hide AI assisted tool options such as copywritting and alt-image description generated using AI. Uses OpenAI API. See our Privacy Policy for more information.")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private var appSection: some View {
Section {
#if !targetEnvironment(macCatalyst) && !os(visionOS)
if !ProcessInfo.processInfo.isiOSAppOnMac {
NavigationLink(destination: IconSelectorView()) {
Label {
Text("settings.app.icon")
} icon: {
let icon = IconSelectorView.Icon(string: UIApplication.shared.alternateIconName ?? "AppIcon")
if let image: UIImage = .init(named: icon.previewImageName) {
Image(uiImage: image)
Image(uiImage: .init(named: icon.iconName)!)
.resizable()
.frame(width: 25, height: 25)
.cornerRadius(4)
} else {
EmptyView()
}
}
}
#endif
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp")!) {
Label("settings.app.source", systemImage: "link")
@ -321,18 +226,10 @@ struct SettingsTabs: View {
.tint(theme.labelColor)
}
NavigationLink {
AboutView()
} label: {
NavigationLink(destination: AboutView()) {
Label("settings.app.about", systemImage: "info.circle")
}
NavigationLink {
WishlistView()
} label: {
Label("Feature Requests", systemImage: "list.bullet.rectangle.portrait")
}
} header: {
Text("settings.section.app")
} footer: {
@ -340,16 +237,14 @@ struct SettingsTabs: View {
Text("settings.section.app.footer \(appVersion)").frame(maxWidth: .infinity, alignment: .center)
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private var addAccountButton: some View {
Button {
addAccountSheetPresented.toggle()
} label: {
Label("settings.account.add", systemImage: "person.badge.plus")
Text("settings.account.add")
}
.sheet(isPresented: $addAccountSheetPresented) {
AddAccountView()
@ -357,23 +252,84 @@ struct SettingsTabs: View {
}
private var editAccountButton: some View {
Button(role: .destructive) {
Button(role: isEditingAccount ? .none : .destructive) {
withAnimation {
isEditingAccount.toggle()
}
} label: {
if isEditingAccount {
Label("action.done", systemImage: "person.badge.minus")
.foregroundStyle(.red)
Text("action.done")
} else {
Label("account.action.logout", systemImage: "person.badge.minus")
.foregroundStyle(.red)
Text("account.action.logout")
}
}
}
private var tagGroupsView: some View {
Form {
ForEach(preferences.tagGroups, id: \.self) { group in
Label(group.title, systemImage: group.sfSymbolName)
.onTapGesture {
routerPath.presentedSheet = .editTagGroup(tagGroup: group, onSaved: nil)
}
}
.onDelete { indexes in
if let index = indexes.first {
_ = preferences.tagGroups.remove(at: index)
}
}
.onMove { source, destination in
preferences.tagGroups.move(fromOffsets: source, toOffset: destination)
}
.listRowBackground(theme.primaryBackgroundColor)
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("timeline.filter.tag-groups")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.toolbar {
EditButton()
}
}
private var remoteLocalTimelinesView: some View {
Form {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in
Text(server)
}.onDelete { indexes in
if let index = indexes.first {
_ = preferences.remoteLocalTimelines.remove(at: index)
}
}
.onMove(perform: moveTimelineItems)
.listRowBackground(theme.primaryBackgroundColor)
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("settings.timeline.add", systemImage: "badge.plus.radiowaves.right")
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("settings.general.remote-timelines")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.toolbar {
EditButton()
}
}
private func moveTimelineItems(from source: IndexSet, to destination: Int) {
preferences.remoteLocalTimelines.move(fromOffsets: source, toOffset: destination)
}
private var cacheSection: some View {
Section {
Section("settings.section.cache") {
if cachedRemoved {
Text("action.done")
.transition(.move(edge: .leading))
@ -385,13 +341,7 @@ struct SettingsTabs: View {
}
}
}
} header: {
Text("settings.section.cache")
} footer: {
Text("Remove all cached images and videos")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
}

View file

@ -1,40 +0,0 @@
import DesignSystem
import Env
import SwiftUI
@MainActor
struct SidebarEntriesSettingsView: View {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var userPreferences
@State private var sidebarTabs = SidebarTabs.shared
var body: some View {
@Bindable var userPreferences = userPreferences
Form {
Section {
ForEach($sidebarTabs.tabs, id: \.tab) { $tab in
if tab.tab != .profile && tab.tab != .settings {
Toggle(isOn: $tab.enabled) {
tab.tab.label
}
}
}
.onMove(perform: move)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.environment(\.editMode, .constant(.active))
.navigationTitle("settings.general.sidebarEntries")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
func move(from source: IndexSet, to destination: Int) {
sidebarTabs.tabs.move(fromOffsets: source, toOffset: destination)
}
}

View file

@ -1,9 +1,9 @@
import DesignSystem
import Env
import RevenueCat
import Shimmer
import SwiftUI
@MainActor
struct SupportAppView: View {
enum Tip: String, CaseIterable {
case one, two, three, four, supporter
@ -19,35 +19,35 @@ struct SupportAppView: View {
var title: LocalizedStringKey {
switch self {
case .one:
"settings.support.one.title"
return "settings.support.one.title"
case .two:
"settings.support.two.title"
return "settings.support.two.title"
case .three:
"settings.support.three.title"
return "settings.support.three.title"
case .four:
"settings.support.four.title"
return "settings.support.four.title"
case .supporter:
"settings.support.supporter.title"
return "settings.support.supporter.title"
}
}
var subtitle: LocalizedStringKey {
switch self {
case .one:
"settings.support.one.subtitle"
return "settings.support.one.subtitle"
case .two:
"settings.support.two.subtitle"
return "settings.support.two.subtitle"
case .three:
"settings.support.three.subtitle"
return "settings.support.three.subtitle"
case .four:
"settings.support.four.subtitle"
return "settings.support.four.subtitle"
case .supporter:
"settings.support.supporter.subtitle"
return "settings.support.supporter.subtitle"
}
}
}
@Environment(Theme.self) private var theme
@EnvironmentObject private var theme: Theme
@Environment(\.openURL) private var openURL
@ -68,10 +68,8 @@ struct SupportAppView: View {
linksSection
}
.navigationTitle("settings.support.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
.alert("settings.support.alert.title", isPresented: $purchaseSuccessDisplayed, actions: {
Button { purchaseSuccessDisplayed = false } label: { Text("alert.button.ok") }
}, message: {
@ -105,8 +103,8 @@ struct SupportAppView: View {
}
private func fetchStoreProducts() {
Purchases.shared.getProducts(Tip.allCases.map(\.productId)) { products in
subscription = products.first(where: { $0.productIdentifier == Tip.supporter.productId })
Purchases.shared.getProducts(Tip.allCases.map { $0.productId }) { products in
self.subscription = products.first(where: { $0.productIdentifier == Tip.supporter.productId })
self.products = products.filter { $0.productIdentifier != Tip.supporter.productId }.sorted(by: { $0.price < $1.price })
withAnimation {
loadingProducts = false
@ -116,7 +114,7 @@ struct SupportAppView: View {
private func refreshUserInfo() {
Purchases.shared.getCustomerInfo { info, _ in
customerInfo = info
self.customerInfo = info
}
}
@ -152,9 +150,7 @@ struct SupportAppView: View {
Text("settings.support.message-from-dev")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private var subscriptionSection: some View {
@ -178,7 +174,7 @@ struct SupportAppView: View {
.font(.scaledSubheadline)
Text(Tip.supporter.subtitle)
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.foregroundColor(.gray)
}
Spacer()
makePurchaseButton(product: subscription)
@ -191,9 +187,7 @@ struct SupportAppView: View {
Text("settings.support.supporter.subscription-info")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private var tipsSection: some View {
@ -209,7 +203,7 @@ struct SupportAppView: View {
.font(.scaledSubheadline)
Text(tip.subtitle)
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.foregroundColor(.gray)
}
Spacer()
makePurchaseButton(product: product)
@ -218,9 +212,7 @@ struct SupportAppView: View {
}
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
private var restorePurchase: some View {
@ -229,7 +221,7 @@ struct SupportAppView: View {
Spacer()
Button {
Purchases.shared.restorePurchases { info, _ in
customerInfo = info
self.customerInfo = info
}
} label: {
Text("settings.support.restore-purchase.button")
@ -239,9 +231,7 @@ struct SupportAppView: View {
} footer: {
Text("settings.support.restore-purchase.explanation")
}
#if !os(visionOS)
.listRowBackground(theme.secondaryBackgroundColor)
#endif
}
private var linksSection: some View {
@ -261,23 +251,21 @@ struct SupportAppView: View {
.buttonStyle(.borderless)
}
}
#if !os(visionOS)
.listRowBackground(theme.secondaryBackgroundColor)
#endif
}
private var loadingPlaceholder: some View {
HStack {
VStack(alignment: .leading) {
Text("placeholder.loading.short")
Text("placeholder.loading.short.")
.font(.scaledSubheadline)
Text("settings.support.placeholder.loading-subtitle")
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.foregroundColor(.gray)
}
.padding(.vertical, 8)
}
.redacted(reason: .placeholder)
.allowsHitTesting(false)
.shimmering()
}
}

View file

@ -2,13 +2,11 @@ import DesignSystem
import Env
import SwiftUI
@MainActor
struct SwipeActionsSettingsView: View {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var userPreferences
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var userPreferences: UserPreferences
var body: some View {
@Bindable var userPreferences = userPreferences
Form {
Section {
Label("settings.swipeactions.status.leading", systemImage: "arrow.right")
@ -16,7 +14,7 @@ struct SwipeActionsSettingsView: View {
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingLeft,
label: "settings.swipeactions.primary")
.onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { _, action in
.onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { action in
if action == .none {
userPreferences.swipeActionsStatusLeadingRight = .none
}
@ -31,7 +29,7 @@ struct SwipeActionsSettingsView: View {
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusTrailingRight,
label: "settings.swipeactions.primary")
.onChange(of: userPreferences.swipeActionsStatusTrailingRight) { _, action in
.onChange(of: userPreferences.swipeActionsStatusTrailingRight) { action in
if action == .none {
userPreferences.swipeActionsStatusTrailingLeft = .none
}
@ -46,9 +44,7 @@ struct SwipeActionsSettingsView: View {
} footer: {
Text("settings.swipeactions.status.explanation")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section {
Picker(selection: $userPreferences.swipeActionsIconStyle, label: Text("settings.swipeactions.icon-style")) {
@ -64,19 +60,15 @@ struct SwipeActionsSettingsView: View {
} footer: {
Text("settings.swipeactions.use-theme-colors-explanation")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.swipeactions.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
private func createStatusActionPicker(selection: Binding<StatusAction>, label: LocalizedStringKey) -> some View {
Picker(selection: selection, label: Text(label)) {
return Picker(selection: selection, label: Text(label)) {
Section {
Text(StatusAction.none.displayName()).tag(StatusAction.none)
}

View file

@ -1,69 +0,0 @@
import DesignSystem
import Env
import SwiftUI
@MainActor
struct TabbarEntriesSettingsView: View {
@Environment(Theme.self) private var theme
@Environment(UserPreferences.self) private var userPreferences
@State private var tabs = iOSTabs.shared
var body: some View {
@Bindable var userPreferences = userPreferences
Form {
Section {
Picker("settings.tabs.first-tab", selection: $tabs.firstTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.firstTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.second-tab", selection: $tabs.secondTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.secondTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.third-tab", selection: $tabs.thirdTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.thirdTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.fourth-tab", selection: $tabs.fourthTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.fourthTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
Picker("settings.tabs.fifth-tab", selection: $tabs.fifthTab) {
ForEach(AppTab.allCases) { tab in
if tab == tabs.fifthTab || !tabs.tabs.contains(tab) {
tab.label.tag(tab)
}
}
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section {
Toggle("settings.display.show-tab-label", isOn: $userPreferences.showiPhoneTabLabel)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.general.tabbarEntries")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
}
}

View file

@ -1,50 +0,0 @@
import DesignSystem
import Env
import Models
import SwiftData
import SwiftUI
struct TagsGroupSettingView: View {
@Environment(\.modelContext) private var context
@Environment(RouterPath.self) private var routerPath
@Environment(Theme.self) private var theme
@Query(sort: \TagGroup.creationDate, order: .reverse) var tagGroups: [TagGroup]
var body: some View {
Form {
ForEach(tagGroups) { group in
Label(group.title, systemImage: group.symbolName)
.onTapGesture {
routerPath.presentedSheet = .editTagGroup(tagGroup: group, onSaved: nil)
}
}
.onDelete { indexes in
if let index = indexes.first {
context.delete(tagGroups[index])
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("timeline.filter.tag-groups")
.scrollContentBackground(.hidden)
#if !os(visionOS)
.background(theme.secondaryBackgroundColor)
#endif
.toolbar {
EditButton()
}
}
}

View file

@ -2,25 +2,31 @@ import DesignSystem
import Env
import SwiftUI
@MainActor
struct TranslationSettingsView: View {
@Environment(UserPreferences.self) private var preferences
@Environment(Theme.self) private var theme
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme
@State private var apiKey: String = ""
var body: some View {
Form {
translationSelector
if preferences.preferredTranslationType == .useDeepl {
Toggle(isOn: preferences.$alwaysUseDeepl) {
Text("settings.translation.always-deepl")
}
.listRowBackground(theme.primaryBackgroundColor)
if preferences.alwaysUseDeepl {
Section("settings.translation.user-api-key") {
deepLPicker
Picker("settings.translation.api-key-type", selection: $preferences.userDeeplAPIFree) {
Text("DeepL API Free").tag(true)
Text("DeepL API Pro").tag(false)
}
SecureField("settings.translation.user-api-key", text: $apiKey)
.textContentType(.password)
}
#if !os(visionOS)
.onAppear(perform: readValue)
.listRowBackground(theme.primaryBackgroundColor)
#endif
if apiKey.isEmpty {
Section {
@ -29,103 +35,23 @@ struct TranslationSettingsView: View {
.foregroundColor(.red)
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
}
backgroundAPIKey
autoDetectSection
}
.navigationTitle("settings.translation.navigation-title")
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
#endif
.onChange(of: apiKey) {
writeNewValue()
}
.onAppear(perform: updatePrefs)
.onAppear(perform: readValue)
}
@ViewBuilder
private var translationSelector: some View {
@Bindable var preferences = preferences
Picker("Translation Service", selection: $preferences.preferredTranslationType) {
ForEach(allTTCases, id: \.self) { type in
Text(type.description).tag(type)
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
var allTTCases: [TranslationType] {
TranslationType.allCases.filter { type in
if type != .useApple {
return true
}
#if canImport(_Translation_SwiftUI)
if #available(iOS 17.4, *) {
return true
} else {
return false
}
#else
return false
#endif
}
}
@ViewBuilder
private var deepLPicker: some View {
@Bindable var preferences = preferences
Picker("settings.translation.api-key-type", selection: $preferences.userDeeplAPIFree) {
Text("DeepL API Free").tag(true)
Text("DeepL API Pro").tag(false)
}
}
@ViewBuilder
private var autoDetectSection: some View {
@Bindable var preferences = preferences
Section {
Toggle(isOn: $preferences.autoDetectPostLanguage) {
Toggle(isOn: preferences.$autoDetectPostLanguage) {
Text("settings.translation.auto-detect-post-language")
}
} footer: {
Text("settings.translation.auto-detect-post-language-footer")
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
@ViewBuilder
private var backgroundAPIKey: some View {
if preferences.preferredTranslationType != .useDeepl,
!apiKey.isEmpty
{
Section {
Text("The DeepL API Key is still stored!")
if preferences.preferredTranslationType == .useServerIfPossible {
Text("It can however still be used as a fallback for your instance's translation service.")
}
Button(role: .destructive) {
withAnimation {
writeNewValue(value: "")
readValue()
}
} label: {
Text("action.delete")
}
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.navigationTitle("settings.translation.navigation-title")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.onChange(of: apiKey, perform: writeNewValue)
.onAppear(perform: updatePrefs)
}
private func writeNewValue() {
@ -137,7 +63,11 @@ struct TranslationSettingsView: View {
}
private func readValue() {
apiKey = DeepLUserAPIHandler.readKey()
if let apiKey = DeepLUserAPIHandler.readIfAllowed() {
self.apiKey = apiKey
} else {
apiKey = ""
}
}
private func updatePrefs() {
@ -148,6 +78,6 @@ struct TranslationSettingsView: View {
struct TranslationSettingsView_Previews: PreviewProvider {
static var previews: some View {
TranslationSettingsView()
.environment(UserPreferences.shared)
.environmentObject(UserPreferences.shared)
}
}

View file

@ -1,8 +0,0 @@
import SwiftUI
import WishKit
struct WishlistView: View {
var body: some View {
WishKit.view
}
}

View file

@ -1,78 +1,54 @@
import Account
import AppIntents
import DesignSystem
import Explore
import Foundation
import StatusKit
import Status
import SwiftUI
@MainActor
enum AppTab: Int, Identifiable, Hashable, CaseIterable, Codable {
enum Tab: Int, Identifiable, Hashable {
case timeline, notifications, mentions, explore, messages, settings, other
case trending, federated, local
case profile
case bookmarks
case favorites
case post
case followedTags
case lists
case links
nonisolated var id: Int {
var id: Int {
rawValue
}
static func loggedOutTab() -> [AppTab] {
static func loggedOutTab() -> [Tab] {
[.timeline, .settings]
}
static func visionOSTab() -> [AppTab] {
[.profile, .timeline, .notifications, .mentions, .explore, .post, .settings]
static func loggedInTabs() -> [Tab] {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
return [.timeline, .trending, .federated, .local, .notifications, .mentions, .explore, .messages, .settings]
} else {
return [.timeline, .notifications, .explore, .messages, .profile]
}
}
@ViewBuilder
func makeContentView(selectedTab: Binding<AppTab>) -> some View {
func makeContentView(popToRootTab: Binding<Tab>) -> some View {
switch self {
case .timeline:
TimelineTab()
TimelineTab(popToRootTab: popToRootTab)
case .trending:
TimelineTab(timeline: .trending)
TimelineTab(popToRootTab: popToRootTab, timeline: .trending)
case .local:
TimelineTab(timeline: .local)
TimelineTab(popToRootTab: popToRootTab, timeline: .local)
case .federated:
TimelineTab(timeline: .federated)
TimelineTab(popToRootTab: popToRootTab, timeline: .federated)
case .notifications:
NotificationsTab(selectedTab: selectedTab, lockedType: nil)
NotificationsTab(popToRootTab: popToRootTab, lockedType: nil)
case .mentions:
NotificationsTab(selectedTab: selectedTab, lockedType: .mention)
NotificationsTab(popToRootTab: popToRootTab, lockedType: .mention)
case .explore:
ExploreTab()
ExploreTab(popToRootTab: popToRootTab)
case .messages:
MessagesTab()
MessagesTab(popToRootTab: popToRootTab)
case .settings:
SettingsTabs(isModal: false)
SettingsTabs(popToRootTab: popToRootTab)
case .profile:
ProfileTab()
case .bookmarks:
NavigationTab {
AccountStatusesListView(mode: .bookmarks)
}
case .favorites:
NavigationTab {
AccountStatusesListView(mode: .favorites)
}
case .followedTags:
NavigationTab {
FollowedTagsListView()
}
case .lists:
NavigationTab {
ListsListView()
}
case .links:
NavigationTab { TrendingLinksListView(cards: []) }
case .post:
VStack {}
ProfileTab(popToRootTab: popToRootTab)
case .other:
EmptyView()
}
@ -80,194 +56,56 @@ enum AppTab: Int, Identifiable, Hashable, CaseIterable, Codable {
@ViewBuilder
var label: some View {
if self != .other {
Label(title, systemImage: iconName)
}
}
var title: LocalizedStringKey {
switch self {
case .timeline:
"tab.timeline"
Label("tab.timeline", systemImage: iconName)
case .trending:
"tab.trending"
Label("tab.trending", systemImage: iconName)
case .local:
"tab.local"
Label("tab.local", systemImage: iconName)
case .federated:
"tab.federated"
Label("tab.federated", systemImage: iconName)
case .notifications:
"tab.notifications"
Label("tab.notifications", systemImage: iconName)
case .mentions:
"tab.mentions"
Label("tab.notifications", systemImage: iconName)
case .explore:
"tab.explore"
Label("tab.explore", systemImage: iconName)
case .messages:
"tab.messages"
Label("tab.messages", systemImage: iconName)
case .settings:
"tab.settings"
Label("tab.settings", systemImage: iconName)
case .profile:
"tab.profile"
case .bookmarks:
"accessibility.tabs.profile.picker.bookmarks"
case .favorites:
"accessibility.tabs.profile.picker.favorites"
case .post:
"menu.new-post"
case .followedTags:
"timeline.filter.tags"
case .lists:
"timeline.filter.lists"
case .links:
"explore.section.trending.links"
Label("tab.profile", systemImage: iconName)
case .other:
""
EmptyView()
}
}
var iconName: String {
switch self {
case .timeline:
"rectangle.stack"
return "rectangle.stack"
case .trending:
"chart.line.uptrend.xyaxis"
return "chart.line.uptrend.xyaxis"
case .local:
"person.2"
return "person.2"
case .federated:
"globe.americas"
return "globe.americas"
case .notifications:
"bell"
return "bell"
case .mentions:
"at"
return "at"
case .explore:
"magnifyingglass"
return "magnifyingglass"
case .messages:
"tray"
return "tray"
case .settings:
"gear"
return "gear"
case .profile:
"person.crop.circle"
case .bookmarks:
"bookmark"
case .favorites:
"star"
case .post:
"square.and.pencil"
case .followedTags:
"tag"
case .lists:
"list.bullet"
case .links:
"newspaper"
return "person.crop.circle"
case .other:
""
return ""
}
}
}
@MainActor
@Observable
class SidebarTabs {
struct SidedebarTab: Hashable, Codable {
let tab: AppTab
var enabled: Bool
}
class Storage {
@AppStorage("sidebar_tabs") var tabs: [SidedebarTab] = [
.init(tab: .timeline, enabled: true),
.init(tab: .trending, enabled: true),
.init(tab: .federated, enabled: true),
.init(tab: .local, enabled: true),
.init(tab: .notifications, enabled: true),
.init(tab: .mentions, enabled: true),
.init(tab: .messages, enabled: true),
.init(tab: .explore, enabled: true),
.init(tab: .bookmarks, enabled: true),
.init(tab: .favorites, enabled: true),
.init(tab: .followedTags, enabled: true),
.init(tab: .lists, enabled: true),
.init(tab: .links, enabled: true),
.init(tab: .settings, enabled: true),
.init(tab: .profile, enabled: true),
]
}
private let storage = Storage()
public static let shared = SidebarTabs()
var tabs: [SidedebarTab] {
didSet {
storage.tabs = tabs
}
}
func isEnabled(_ tab: AppTab) -> Bool {
tabs.first(where: { $0.tab.id == tab.id })?.enabled == true
}
private init() {
tabs = storage.tabs
}
}
@MainActor
@Observable
class iOSTabs {
enum TabEntries: String {
case first, second, third, fourth, fifth
}
class Storage {
@AppStorage(TabEntries.first.rawValue) var firstTab = AppTab.timeline
@AppStorage(TabEntries.second.rawValue) var secondTab = AppTab.notifications
@AppStorage(TabEntries.third.rawValue) var thirdTab = AppTab.explore
@AppStorage(TabEntries.fourth.rawValue) var fourthTab = AppTab.links
@AppStorage(TabEntries.fifth.rawValue) var fifthTab = AppTab.profile
}
private let storage = Storage()
public static let shared = iOSTabs()
var tabs: [AppTab] {
[firstTab, secondTab, thirdTab, fourthTab, fifthTab]
}
var firstTab: AppTab {
didSet {
storage.firstTab = firstTab
}
}
var secondTab: AppTab {
didSet {
storage.secondTab = secondTab
}
}
var thirdTab: AppTab {
didSet {
storage.thirdTab = thirdTab
}
}
var fourthTab: AppTab {
didSet {
storage.fourthTab = fourthTab
}
}
var fifthTab: AppTab {
didSet {
storage.fifthTab = fifthTab
}
}
private init() {
firstTab = storage.firstTab
secondTab = storage.secondTab
thirdTab = storage.thirdTab
fourthTab = storage.fourthTab
fifthTab = storage.fifthTab
}
}

View file

@ -1,448 +0,0 @@
import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import SFSafeSymbols
import SwiftData
import SwiftUI
@MainActor
struct EditTagGroupView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Environment(Theme.self) private var theme
@State private var tagGroup: TagGroup
private let onSaved: ((TagGroup) -> Void)?
private let isNewGroup: Bool
@FocusState private var focusedField: Focus?
var body: some View {
NavigationStack {
Form {
Section {
TitleInputView(
title: $tagGroup.title,
titleValidationStatus: tagGroup.titleValidationStatus,
focusedField: $focusedField,
isNewGroup: isNewGroup
)
SymbolInputView(
selectedSymbol: $tagGroup.symbolName,
selectedSymbolValidationStatus: tagGroup.symbolNameValidationStatus,
focusedField: $focusedField
)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
Section("add-tag-groups.edit.tags") {
TagsInputView(
tags: $tagGroup.tags,
tagsValidationStatus: tagGroup.tagsValidationStatus,
focusedField: $focusedField
)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.formStyle(.grouped)
.navigationTitle(
isNewGroup
? "timeline.filter.add-tag-groups"
: "timeline.filter.edit-tag-groups"
)
.navigationBarTitleDisplayMode(.inline)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.interactively)
#endif
.toolbar {
CancelToolbarItem()
ToolbarItem(placement: .navigationBarTrailing) {
Button("action.save", action: { save() })
.disabled(!tagGroup.isValid)
}
}
.onAppear {
focusedField = .title
}
}
}
init(tagGroup: TagGroup = .emptyGroup(), onSaved: ((TagGroup) -> Void)? = nil) {
_tagGroup = State(wrappedValue: tagGroup)
self.onSaved = onSaved
isNewGroup = tagGroup.title.isEmpty
}
private func save() {
tagGroup.format()
context.insert(tagGroup)
onSaved?(tagGroup)
dismiss()
}
enum Focus {
case title
case symbol
case new
}
}
struct AddTagGroupView_Previews: PreviewProvider {
static var previews: some View {
let container = try? ModelContainer(for: TagGroup.self, configurations: ModelConfiguration())
// need to use `sheet` to show `symbolsSuggestionView` in preview
return Text(verbatim: "parent view for EditTagGroupView")
.sheet(isPresented: .constant(true)) {
EditTagGroupView()
.withEnvironments()
.modelContainer(container!)
}
}
}
private struct TitleInputView: View {
@Binding var title: String
let titleValidationStatus: TagGroup.TitleValidationStatus
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
@Query var tagGroups: [TagGroup]
let isNewGroup: Bool
var body: some View {
VStack(alignment: .leading) {
TextField("add-tag-groups.edit.title.field", text: $title, axis: .horizontal)
.focused($focusedField, equals: .title)
.onSubmit {
focusedField = .symbol
}
if focusedField == .title, warningText != "" {
Text(warningText).warningLabel()
}
}
}
var warningText: LocalizedStringKey {
if case let .invalid(description) = titleValidationStatus {
return description
} else if
isNewGroup,
tagGroups.contains(where: { $0.title == title })
{
return "\(title) add-tag-groups.edit.title.field.warning.already-exists"
}
return ""
}
}
private struct SymbolInputView: View {
@State private var symbolQuery = ""
@Binding var selectedSymbol: String
let selectedSymbolValidationStatus: TagGroup.SymbolNameValidationStatus
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
var body: some View {
VStack(alignment: .leading) {
HStack {
TextField("add-tag-groups.edit.icon.field", text: $symbolQuery, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .symbol)
.onSubmit {
if TagGroup.allSymbols.contains(symbolQuery) {
selectedSymbol = symbolQuery
}
focusedField = .new
}
.onChange(of: focusedField) {
symbolQuery = selectedSymbol
}
Image(systemName: selectedSymbol)
.frame(height: 30)
}
if case let .invalid(description) = selectedSymbolValidationStatus,
focusedField == .symbol
{
Text(description).warningLabel()
}
if focusedField == .symbol {
SymbolSearchResultsView(
symbolQuery: $symbolQuery,
selectedSymbol: $selectedSymbol,
focusedField: $focusedField
)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 40)
}
}
}
}
private struct TagsInputView: View {
@State private var newTag: String = ""
@Binding var tags: [String]
let tagsValidationStatus: TagGroup.TagsValidationStatus
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
var body: some View {
ForEach(tags, id: \.self) { tag in
HStack {
Text(tag)
Spacer()
Button { deleteTag(tag) } label: {
Image(systemName: "trash")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
.onDelete { indexes in
if let index = indexes.first {
let tag = tags[index]
deleteTag(tag)
}
}
// this `VStack` need to be here to overcome a SwiftUI bug
// "add new tag" `TextField` is not focused after adding the first tag
VStack(alignment: .leading) {
HStack {
// this condition is using to overcome a SwiftUI bug
// "add new tag" `TextField` is not focused after adding the first tag
if tags.isEmpty {
addNewTagTextField()
} else {
addNewTagTextField()
.onAppear { focusedField = .new }
}
Spacer()
if !newTag.isEmpty, !tags.contains(newTag) {
Button { addNewTag() } label: {
Image(systemName: "checkmark.circle.fill").tint(.green)
}
}
}
if focusedField == .new, warningText != "" {
Text(warningText).warningLabel()
}
}
var warningText: LocalizedStringKey {
if tags.contains(newTag) {
return "add-tag-groups.edit.tags.field.warning.duplicated-tag"
} else if case let .invalid(description) = tagsValidationStatus {
return description
}
return ""
}
}
private func addNewTagTextField() -> some View {
VStack(alignment: .leading) {
TextField("add-tag-groups.edit.tags.add", text: $newTag, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit {
addNewTag()
}
.focused($focusedField, equals: .new)
}
}
private func addNewTag() {
addTag(newTag.trimmingCharacters(in: .whitespaces).lowercased())
newTag = ""
focusedField = .new
}
private func addTag(_ tag: String) {
guard !tag.isEmpty,
!tags.contains(tag)
else { return }
tags.append(tag)
tags.sort()
}
private func deleteTag(_ tag: String) {
tags.removeAll(where: { $0 == tag })
}
}
private struct SymbolSearchResultsView: View {
@Binding var symbolQuery: String
@Binding var selectedSymbol: String
@State private var results: [String] = []
@FocusState.Binding var focusedField: EditTagGroupView.Focus?
var body: some View {
Group {
switch validationStatus {
case .valid:
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(results, id: \.self) { name in
Button {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
selectedSymbol = name
symbolQuery = name
focusedField = .new
} label: {
Image(systemName: name)
}
.buttonStyle(.plain)
}
}
.animation(.spring(duration: 0.2), value: results)
}
.onAppear {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
}
case let .invalid(description):
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.onAppear {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
}
.onChange(of: symbolQuery) {
results = TagGroup.searchSymbol(for: symbolQuery, exclude: selectedSymbol)
}
}
// MARK: search results validation
enum ValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var validationStatus: ValidationStatus {
if results.isEmpty {
if symbolQuery == selectedSymbol,
!symbolQuery.isEmpty,
results.count == 0
{
.invalid(description: "\(symbolQuery) add-tag-groups.edit.tags.field.warning.search-results.already-selected")
} else {
.invalid(description: "add-tag-groups.edit.tags.field.warning.search-results.no-symbol-found")
}
} else {
.valid
}
}
}
extension TagGroup {
// MARK: title validation
enum TitleValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var titleValidationStatus: TitleValidationStatus {
title.isEmpty
? .invalid(description: "add-tag-groups.edit.title.field.warning.empty-title")
: .valid
}
// MARK: symbolName validation
enum SymbolNameValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var symbolNameValidationStatus: SymbolNameValidationStatus {
if symbolName.isEmpty {
return .invalid(description: "add-tag-groups.edit.title.field.warning.no-symbol-selected")
} else if !Self.allSymbols.contains(symbolName) {
return .invalid(description: "\(symbolName) add-tag-groups.edit.title.field.warning.invalid-sfsymbol-name")
}
return .valid
}
// MARK: tags validation
enum TagsValidationStatus: Equatable {
case valid
case invalid(description: LocalizedStringKey)
}
var tagsValidationStatus: TagsValidationStatus {
if tags.count < 2 {
return .invalid(description: "add-tag-groups.edit.tags.field.warning.number-of-tags")
}
return .valid
}
// MARK: TagGroup validation
var isValid: Bool {
titleValidationStatus == .valid
&& symbolNameValidationStatus == .valid
&& tagsValidationStatus == .valid
}
// MARK: format
func format() {
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
tags = tags.map { $0.lowercased() }
}
// MARK: static members
static func emptyGroup() -> TagGroup {
TagGroup(title: "", symbolName: "", tags: [])
}
static func searchSymbol(for query: String, exclude excludedSymbol: String) -> [String] {
guard !query.isEmpty else { return [] }
return allSymbols.filter {
$0.contains(query) &&
$0 != excludedSymbol
}
}
static let allSymbols: [String] = SFSymbol.allSymbols.map { symbol in
symbol.rawValue
}
}
extension Text {
func warningLabel() -> Text {
font(.caption)
.foregroundStyle(.red)
}
}

View file

@ -4,15 +4,14 @@ import Env
import Models
import Network
import NukeUI
import Shimmer
import SwiftUI
@MainActor
struct AddRemoteTimelineView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Environment(UserPreferences.self) private var preferences
@Environment(Theme.self) private var theme
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme
@State private var instanceName: String = ""
@State private var instance: Instance?
@ -37,35 +36,29 @@ struct AddRemoteTimelineView: View {
.foregroundColor(.green)
.listRowBackground(theme.primaryBackgroundColor)
}
if !instanceName.isEmpty && instance == nil {
Label("timeline.\(instanceName)-not-valid", systemImage: "xmark.seal.fill")
.foregroundColor(.red)
.listRowBackground(theme.primaryBackgroundColor)
}
Button {
guard instance != nil else { return }
context.insert(LocalTimeline(instance: instanceName))
preferences.remoteLocalTimelines.append(instanceName)
dismiss()
} label: {
Text("timeline.add.action.add")
}
.listRowBackground(theme.primaryBackgroundColor)
.disabled(instance == nil)
instancesListView
}
.formStyle(.grouped)
.navigationTitle("timeline.add-remote.title")
.navigationBarTitleDisplayMode(.inline)
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
#endif
.toolbar {
CancelToolbarItem()
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { dismiss() })
}
.onChange(of: instanceName) { _, newValue in
}
.onChange(of: instanceName) { newValue in
instanceNamePublisher.send(newValue)
}
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { newValue in
@ -77,9 +70,8 @@ struct AddRemoteTimelineView: View {
.onAppear {
isInstanceURLFieldFocused = true
let client = InstanceSocialClient()
let instanceName = instanceName
Task {
instances = await client.fetchInstances(keyword: instanceName)
self.instances = await client.fetchInstances()
}
}
}
@ -93,7 +85,7 @@ struct AddRemoteTimelineView: View {
} else {
ForEach(instanceName.isEmpty ? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }) { instance in
Button {
instanceName = instance.name
self.instanceName = instance.name
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(instance.name)
@ -101,13 +93,13 @@ struct AddRemoteTimelineView: View {
.foregroundColor(.primary)
Text(instance.info?.shortDescription ?? "")
.font(.scaledBody)
.foregroundStyle(Color.secondary)
.foregroundColor(.gray)
(Text("instance.list.users-\(instance.users)")
+ Text("")
+ Text("instance.list.posts-\(instance.statuses)"))
.font(.scaledFootnote)
.foregroundStyle(Color.secondary)
.foregroundColor(.gray)
}
}
.listRowBackground(theme.primaryBackgroundColor)

View file

@ -0,0 +1,216 @@
import Combine
import DesignSystem
import Env
import Models
import Network
import NukeUI
import Shimmer
import SwiftUI
struct EditTagGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var theme: Theme
@State private var title: String = ""
@State private var sfSymbolName: String = ""
@State private var tags: [String] = []
@State private var newTag: String = ""
@State private var popupTagsPresented = false
private var editingTagGroup: TagGroup?
private var onSaved: ((TagGroup) -> Void)?
private var canSave: Bool {
!title.isEmpty &&
// At least have 2 tags, one main and one additional.
tags.count >= 2
}
@FocusState private var focusedField: Focus?
enum Focus {
case title
case symbol
case new
}
init(editingTagGroup: TagGroup? = nil, onSaved: ((TagGroup) -> Void)? = nil) {
self.editingTagGroup = editingTagGroup
self.onSaved = onSaved
}
var body: some View {
NavigationStack {
ZStack(alignment: .bottom) {
Form {
metadataSection
keywordsSection
}
.formStyle(.grouped)
.navigationTitle(editingTagGroup != nil ? "timeline.filter.edit-tag-groups" : "timeline.filter.add-tag-groups")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("action.cancel", action: { dismiss() })
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("action.save", action: { save() })
.disabled(!canSave)
}
}
symbolsSuggestionView
}
.onAppear {
focusedField = .title
if let editingTagGroup {
title = editingTagGroup.title
sfSymbolName = editingTagGroup.sfSymbolName
tags = editingTagGroup.tags
}
}
}
}
@ViewBuilder
private var metadataSection: some View {
Section {
TextField("add-tag-groups.edit.title.field", text: $title, axis: .horizontal)
.focused($focusedField, equals: Focus.title)
.onSubmit {
focusedField = Focus.symbol
}
HStack {
TextField("add-tag-groups.edit.icon.field", text: $sfSymbolName, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: Focus.symbol)
.onSubmit {
focusedField = Focus.new
}
.onChange(of: sfSymbolName) { _ in
popupTagsPresented = true
}
Image(systemName: sfSymbolName)
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
private var keywordsSection: some View {
Section("add-tag-groups.edit.tags") {
ForEach(tags, id: \.self) { tag in
HStack {
Text(tag)
Spacer()
Button {
deleteTag(tag)
} label: {
Image(systemName: "trash")
.tint(.red)
}
}
}
.onDelete { indexes in
if let index = indexes.first {
let tag = tags[index]
deleteTag(tag)
}
}
HStack {
TextField("add-tag-groups.edit.tags.add", text: $newTag, axis: .horizontal)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit {
addNewTag()
}
.focused($focusedField, equals: Focus.new)
Spacer()
if !newTag.isEmpty {
Button {
addNewTag()
} label: {
Image(systemName: "checkmark.circle.fill")
.tint(.green)
}
}
}
}
.listRowBackground(theme.primaryBackgroundColor)
}
private func addNewTag() {
addTag(newTag.trimmingCharacters(in: .whitespaces))
newTag = ""
focusedField = Focus.new
}
private func addTag(_ tag: String) {
guard !tag.isEmpty else { return }
tags.append(tag)
}
private func deleteTag(_ tag: String) {
tags.removeAll(where: { $0 == tag })
}
private func save() {
var toSave = tags
let main = toSave.removeFirst()
let tagGroup: TagGroup = .init(
title: title.trimmingCharacters(in: .whitespaces),
sfSymbolName: sfSymbolName,
main: main,
additional: toSave
)
if let editingTagGroup,
let index = preferences.tagGroups.firstIndex(of: editingTagGroup) {
preferences.tagGroups[index] = tagGroup
} else {
preferences.tagGroups.append(tagGroup)
}
dismiss()
onSaved?(tagGroup)
}
@ViewBuilder
private var symbolsSuggestionView: some View {
if focusedField == .symbol && !sfSymbolName.isEmpty {
let filteredMatches = allSymbols
.filter { $0.contains(sfSymbolName) }
if !filteredMatches.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(filteredMatches, id: \.self) { symbolName in
Button {
sfSymbolName = symbolName
} label: {
Image(systemName: symbolName)
}
}
}
.padding(.horizontal, .layoutPadding)
}
.frame(height: 40)
.background(.ultraThinMaterial)
}
} else {
EmptyView()
}
}
}
struct AddTagGroupView_Previews: PreviewProvider {
static var previews: some View {
EditTagGroupView()
.withEnvironments()
}
}

View file

@ -0,0 +1,13 @@
//
// Symbols.swift
// IceCubesApp
//
// Created by Alejandro Martinez on 18/7/23.
//
import Foundation
import SFSafeSymbols
let allSymbols: [String] = SFSymbol.allSymbols.map { symbol in
symbol.rawValue
}

View file

@ -4,60 +4,51 @@ import DesignSystem
import Env
import Models
import Network
import SwiftData
import SwiftUI
import Timeline
@MainActor
struct TimelineTab: View {
@Environment(\.modelContext) private var context
@Environment(AppAccountsManager.self) private var appAccount
@Environment(Theme.self) private var theme
@Environment(CurrentAccount.self) private var currentAccount
@Environment(UserPreferences.self) private var preferences
@Environment(Client.self) private var client
@State private var routerPath = RouterPath()
@EnvironmentObject private var appAccount: AppAccountsManager
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var preferences: UserPreferences
@EnvironmentObject private var client: Client
@StateObject private var routerPath = RouterPath()
@Binding var popToRootTab: Tab
@State private var didAppear: Bool = false
@State private var timeline: TimelineFilter = .home
@State private var selectedTagGroup: TagGroup?
@State private var timeline: TimelineFilter
@State private var scrollToTopSignal: Int = 0
@Query(sort: \LocalTimeline.creationDate, order: .reverse) var localTimelines: [LocalTimeline]
@Query(sort: \TagGroup.creationDate, order: .reverse) var tagGroups: [TagGroup]
@AppStorage("last_timeline_filter") var lastTimelineFilter: TimelineFilter = .home
@AppStorage("timeline_pinned_filters") private var pinnedFilters: [TimelineFilter] = []
@AppStorage("last_timeline_filter") public var lastTimelineFilter: TimelineFilter = .home
private let canFilterTimeline: Bool
init(timeline: TimelineFilter? = nil) {
init(popToRootTab: Binding<Tab>, timeline: TimelineFilter? = nil) {
canFilterTimeline = timeline == nil
_timeline = .init(initialValue: timeline ?? .home)
self.timeline = timeline ?? .home
_popToRootTab = popToRootTab
}
var body: some View {
NavigationStack(path: $routerPath.path) {
TimelineView(timeline: $timeline,
pinnedFilters: $pinnedFilters,
selectedTagGroup: $selectedTagGroup,
canFilterTimeline: canFilterTimeline)
TimelineView(timeline: $timeline, scrollToTopSignal: $scrollToTopSignal, canFilterTimeline: canFilterTimeline)
.withAppRouter()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.toolbar {
toolbarView
}
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.50), for: .navigationBar)
.id(client.id)
}
.onAppear {
routerPath.client = client
if !didAppear, canFilterTimeline {
if !didAppear && canFilterTimeline {
didAppear = true
if client.isAuth {
timeline = lastTimelineFilter
} else {
timeline = .trending
timeline = .federated
}
}
Task {
@ -67,59 +58,118 @@ struct TimelineTab: View {
routerPath.presentedSheet = .addAccount
}
}
.onChange(of: client.isAuth) {
resetTimelineFilter()
}
.onChange(of: currentAccount.account?.id) {
resetTimelineFilter()
}
.onChange(of: client.id) {
routerPath.path = []
}
.onChange(of: timeline) { _, newValue in
if client.isAuth, canFilterTimeline {
lastTimelineFilter = newValue
}
switch newValue {
case let .tagGroup(title, _, _):
if let group = tagGroups.first(where: { $0.title == title }) {
selectedTagGroup = group
}
default:
selectedTagGroup = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: .refreshTimeline)) { _ in
timeline = .latest
}
.onReceive(NotificationCenter.default.publisher(for: .trendingTimeline)) { _ in
timeline = .trending
}
.onReceive(NotificationCenter.default.publisher(for: .localTimeline)) { _ in
timeline = .local
}
.onReceive(NotificationCenter.default.publisher(for: .federatedTimeline)) { _ in
.onChange(of: client.isAuth, perform: { _ in
if client.isAuth {
timeline = lastTimelineFilter
} else {
timeline = .federated
}
.onReceive(NotificationCenter.default.publisher(for: .homeTimeline)) { _ in
timeline = .home
})
.onChange(of: currentAccount.account?.id, perform: { _ in
if client.isAuth, canFilterTimeline {
timeline = lastTimelineFilter
} else {
timeline = .federated
}
})
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .timeline {
if routerPath.path.isEmpty {
scrollToTopSignal += 1
} else {
routerPath.path = []
}
}
}
.onChange(of: client.id) { _ in
routerPath.path = []
}
.onChange(of: timeline) { timeline in
if timeline == .home || timeline == .federated || timeline == .local {
lastTimelineFilter = timeline
}
}
.withSafariRouter()
.environment(routerPath)
.environmentObject(routerPath)
}
@ViewBuilder
private var timelineFilterButton: some View {
headerGroup
timelineFiltersButtons
if client.isAuth {
listsFiltersButons
tagsFiltersButtons
if timeline.supportNewestPagination {
Button {
self.timeline = .latest
} label: {
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "")
}
localTimelinesFiltersButtons
tagGroupsFiltersButtons
.keyboardShortcut("r", modifiers: .command)
Divider()
contentFilterButton
}
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
Button {
self.timeline = timeline
} label: {
Label(timeline.localizedTitle(), systemImage: timeline.iconName() ?? "")
}
}
if !currentAccount.lists.isEmpty {
Menu("timeline.filter.lists") {
ForEach(currentAccount.sortedLists) { list in
Button {
timeline = .list(list: list)
} label: {
Label(list.title, systemImage: "list.bullet")
}
}
}
}
if !currentAccount.tags.isEmpty {
Menu("timeline.filter.tags") {
ForEach(currentAccount.sortedTags) { tag in
Button {
timeline = .hashtag(tag: tag.name, accountId: nil)
} label: {
Label("#\(tag.name)", systemImage: "number")
}
}
}
}
Menu("timeline.filter.local") {
ForEach(preferences.remoteLocalTimelines, id: \.self) { server in
Button {
timeline = .remoteLocal(server: server, filter: .local)
} label: {
VStack {
Label(server, systemImage: "dot.radiowaves.right")
}
}
}
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right")
}
}
Menu("timeline.filter.tag-groups") {
ForEach(preferences.tagGroups, id: \.self) { group in
Button {
timeline = .tagGroup(group)
} label: {
VStack {
let icon = group.sfSymbolName.isEmpty ? "number" : group.sfSymbolName
Label(group.title, systemImage: icon)
}
}
}
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
}
}
private var addAccountButton: some View {
@ -139,7 +189,17 @@ struct TimelineTab: View {
}
}
if client.isAuth {
ToolbarTab(routerPath: $routerPath)
if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath)
.id(currentAccount.account?.id)
}
}
statusEditorToolbarItem(routerPath: routerPath,
visibility: preferences.postVisibility)
if UIDevice.current.userInterfaceIdiom == .pad && !preferences.showiPadSecondaryColumn {
SecondaryColumnToolbarItem()
}
} else {
ToolbarItem(placement: .navigationBarTrailing) {
addAccountButton
@ -174,149 +234,4 @@ struct TimelineTab: View {
}
}
}
@ViewBuilder
private var headerGroup: some View {
ControlGroup {
if timeline.supportNewestPagination {
Button {
timeline = .latest
} label: {
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName())
}
}
if timeline == .home {
Button {
timeline = .resume
} label: {
VStack {
Label(TimelineFilter.resume.localizedTitle(),
systemImage: TimelineFilter.resume.iconName())
}
}
}
pinButton
}
}
@ViewBuilder
private var pinButton: some View {
let index = pinnedFilters.firstIndex(where: { $0.id == timeline.id })
Button {
withAnimation {
if let index {
let timeline = pinnedFilters.remove(at: index)
Telemetry.signal("timeline.pin.removed", parameters: ["timeline" : timeline.rawValue])
} else {
pinnedFilters.append(timeline)
Telemetry.signal("timeline.pin.added", parameters: ["timeline" : timeline.rawValue])
}
}
} label: {
if index != nil {
Label("status.action.unpin", systemImage: "pin.slash")
} else {
Label("status.action.pin", systemImage: "pin")
}
}
}
private var timelineFiltersButtons: some View {
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in
Button {
self.timeline = timeline
} label: {
Label(timeline.localizedTitle(), systemImage: timeline.iconName())
}
}
}
@ViewBuilder
private var listsFiltersButons: some View {
Menu("timeline.filter.lists") {
Button {
routerPath.presentedSheet = .listCreate
} label: {
Label("account.list.create", systemImage: "plus")
}
ForEach(currentAccount.sortedLists) { list in
Button {
timeline = .list(list: list)
} label: {
Label(list.title, systemImage: "list.bullet")
}
}
}
}
@ViewBuilder
private var tagsFiltersButtons: some View {
if !currentAccount.tags.isEmpty {
Menu("timeline.filter.tags") {
ForEach(currentAccount.sortedTags) { tag in
Button {
timeline = .hashtag(tag: tag.name, accountId: nil)
} label: {
Label("#\(tag.name)", systemImage: "number")
}
}
}
}
}
private var localTimelinesFiltersButtons: some View {
Menu("timeline.filter.local") {
ForEach(localTimelines) { remoteLocal in
Button {
timeline = .remoteLocal(server: remoteLocal.instance, filter: .local)
} label: {
VStack {
Label(remoteLocal.instance, systemImage: "dot.radiowaves.right")
}
}
}
Button {
routerPath.presentedSheet = .addRemoteLocalTimeline
} label: {
Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right")
}
}
}
private var tagGroupsFiltersButtons: some View {
Menu("timeline.filter.tag-groups") {
ForEach(tagGroups) { group in
Button {
timeline = .tagGroup(title: group.title, tags: group.tags, symbolName: group.symbolName)
} label: {
VStack {
let icon = group.symbolName.isEmpty ? "number" : group.symbolName
Label(group.title, systemImage: icon)
}
}
}
Button {
routerPath.presentedSheet = .addTagGroup
} label: {
Label("timeline.filter.add-tag-groups", systemImage: "plus")
}
}
}
private var contentFilterButton: some View {
Button(action: {
routerPath.presentedSheet = .timelineContentFilter
}, label: {
Label("timeline.content-filter.title", systemSymbol: .line3HorizontalDecrease)
})
}
private func resetTimelineFilter() {
if client.isAuth, canFilterTimeline {
timeline = lastTimelineFilter
} else if !client.isAuth {
timeline = .trending
}
}
}

View file

@ -1,51 +0,0 @@
import AppAccount
import DesignSystem
import Env
import SwiftUI
@MainActor
struct ToolbarTab: ToolbarContent {
@Environment(\.isSecondaryColumn) private var isSecondaryColumn: Bool
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(UserPreferences.self) private var userPreferences
@Environment(Theme.self) private var theme
@Binding var routerPath: RouterPath
var body: some ToolbarContent {
if !isSecondaryColumn {
if horizontalSizeClass == .regular {
ToolbarItem(placement: .topBarLeading) {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Button {
withAnimation {
userPreferences.isSidebarExpanded.toggle()
}
} label: {
if userPreferences.isSidebarExpanded {
Image(systemName: "sidebar.squares.left")
} else {
Image(systemName: "sidebar.left")
}
}
}
}
}
statusEditorToolbarItem(routerPath: routerPath,
visibility: userPreferences.postVisibility)
if UIDevice.current.userInterfaceIdiom != .pad ||
(UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact)
{
ToolbarItem(placement: .navigationBarLeading) {
AppAccountsSelectorView(routerPath: routerPath, avatarConfig: theme.avatarShape == .circle ? .badge : .badgeRounded)
}
}
}
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .regular {
if (!isSecondaryColumn && !userPreferences.showiPadSecondaryColumn) || isSecondaryColumn {
SecondaryColumnToolbarItem()
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/128.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/16.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/256.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/32.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/512.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
IceCubesApp/Assets.xcassets/AppIcon.appiconset/64.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

View file

@ -1,98 +1,284 @@
{
"images" : [
"images": [
{
"filename" : "AppIcon-fs8.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"size": "60x60",
"expected-size": "180",
"filename": "180.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "3x"
},
{
"appearances" : [
"size": "40x40",
"expected-size": "80",
"filename": "80.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "2x"
},
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "32.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "32 1.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "64.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "256 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "512 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "Content.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"size": "40x40",
"expected-size": "120",
"filename": "120.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "3x"
},
{
"size": "60x60",
"expected-size": "120",
"filename": "120.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "2x"
},
{
"size": "57x57",
"expected-size": "57",
"filename": "57.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "1x"
},
{
"size": "29x29",
"expected-size": "58",
"filename": "58.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "2x"
},
{
"size": "29x29",
"expected-size": "29",
"filename": "29.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "1x"
},
{
"size": "29x29",
"expected-size": "87",
"filename": "87.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "3x"
},
{
"size": "57x57",
"expected-size": "114",
"filename": "114.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "2x"
},
{
"size": "20x20",
"expected-size": "40",
"filename": "40.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "2x"
},
{
"size": "20x20",
"expected-size": "60",
"filename": "60.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "iphone",
"scale": "3x"
},
{
"size": "1024x1024",
"filename": "1024.png",
"expected-size": "1024",
"idiom": "ios-marketing",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"scale": "1x"
},
{
"size": "40x40",
"expected-size": "80",
"filename": "80.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "72x72",
"expected-size": "72",
"filename": "72.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "76x76",
"expected-size": "152",
"filename": "152.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "50x50",
"expected-size": "100",
"filename": "100.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "29x29",
"expected-size": "58",
"filename": "58.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "76x76",
"expected-size": "76",
"filename": "76.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "29x29",
"expected-size": "29",
"filename": "29.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "50x50",
"expected-size": "50",
"filename": "50.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "72x72",
"expected-size": "144",
"filename": "144.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "40x40",
"expected-size": "40",
"filename": "40.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "83.5x83.5",
"expected-size": "167",
"filename": "167.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "20x20",
"expected-size": "20",
"filename": "20.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "1x"
},
{
"size": "20x20",
"expected-size": "40",
"filename": "40.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "ipad",
"scale": "2x"
},
{
"size": "128x128",
"expected-size": "128",
"filename": "128.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "256x256",
"expected-size": "256",
"filename": "256.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "128x128",
"expected-size": "256",
"filename": "256.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "256x256",
"expected-size": "512",
"filename": "512.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "32x32",
"expected-size": "32",
"filename": "32.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "512x512",
"expected-size": "512",
"filename": "512.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "16x16",
"expected-size": "16",
"filename": "16.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "1x"
},
{
"size": "16x16",
"expected-size": "32",
"filename": "32.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "32x32",
"expected-size": "64",
"filename": "64.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
},
{
"size": "512x512",
"expected-size": "1024",
"filename": "1024.png",
"folder": "Assets.xcassets/AppIcon.appiconset/",
"idiom": "mac",
"scale": "2x"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

View file

@ -1,13 +0,0 @@
{
"images" : [
{
"filename" : "Background.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,17 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.solidimagestacklayer"
},
{
"filename" : "Mid.solidimagestacklayer"
},
{
"filename" : "Back.solidimagestacklayer"
}
]
}

View file

@ -1,13 +0,0 @@
{
"images" : [
{
"filename" : "Layer 1.png",
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,12 +0,0 @@
{
"images" : [
{
"idiom" : "vision",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

View file

@ -1,14 +0,0 @@
{
"images" : [
{
"filename" : "AppIcon-fs8.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

View file

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "1024.png",
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

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