4 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 8db8e985a7 release: drop alpha framing, TestFlight badge -> Mac App Store, bump to 0.1.0
App Store link: https://apps.apple.com/us/app/outpost-for-outline/id6802736230

README's TestFlight badge replaced with Apple's official "Download on
the Mac App Store" badge (docs/assets/mac-app-store-badge.svg, black
lockup, from Apple's official marketing badge kit), linked to the
real App Store listing. "Early alpha" language dropped from README,
CONTRIBUTING.md, SECURITY.md, and both Gitea issue templates -
these are now "0.1.x"/"early" rather than "0.0.x"/"alpha", matching
the actual release.

OutpostVersion.releaseStage is now "" instead of "ALPHA" - About page
and the Settings sidebar footer both read through this single source
of truth, so this alone drops the "-ALPHA" suffix everywhere it was
shown without touching either call site.

MARKETING_VERSION bumped 0.0.4 -> 0.1.0 for the Outpost target
(Debug + Release) - left OutpostTests/OutpostUITests' MARKETING_VERSION
alone, that's just Xcode's unrelated template default for test
bundles, never shown to a user.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:58:39 +01:00
Puranjay Savar Mattas b8ce517b60 perf: stop recomputing derived state on every SwiftUI render
An audit for v0.1.0 turned up the same pattern in four places: a
computed property doing real work (filtering/sorting/scoring a
collection), read multiple times per render including from
unrelated state changes (selection, hover, scroll), so the work
reran far more often than the underlying data actually changed.
Converted each to a @State cache recomputed only via onChange of its
real inputs:

- DocumentSearchSheet: matchingLineIndices re-scanned the whole
  document per access, read once per visible row plus twice more in
  the header/step logic - O(n^2) case-insensitive scan per frame on
  a large document. Also split into an ordered array (for
  currentMatchIndex/stepping) plus a parallel Set for the per-row
  highlight check, which was an O(k) linear .contains before.
- CollectionDocumentsOutline: tree rebuilt the whole dictionary-
  grouped, recursively-sorted document tree on every body
  evaluation, not just when documents/sortOption actually changed.
- CommandPaletteView: results re-scored and re-sorted the entire
  index (up to the whole local workspace cache in Full Workspace
  mode) on every render, including ones from selectedIndex moving as
  arrow keys are pressed.
- CollectionOverviewView: sortedDocuments re-sorted on every render;
  same pattern, smaller blast radius (capped at 100 docs).

Also:
- HomeViewModel.fetchPinnedThrowing fetched each pinned document
  serially in a for loop (one round trip at a time) - switched to a
  TaskGroup so latency doesn't scale with pin count, results
  reordered back to pins.list's own order since task completion
  order isn't submission order.
- AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG
  compression synchronously on the main actor from the "Use Photo"
  button tap. ImageRenderer itself has to stay on the main actor (it
  captures live SwiftUI state), but JPEG compression on the already-
  rendered bitmap has no SwiftUI dependency left - hopped that part
  to a detached Task via tiffRepresentation (plain Data, unlike
  NSImage itself isn't Sendable) so it doesn't hitch the UI.

No crash risks or retain cycles found in the same audit (no try!/
as!, force-unwraps essentially absent outside a hardcoded URL
literal, weak self already used where it matters) - this is purely
the perf half of the findings.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:48:20 +01:00
Puranjay Savar Mattas 5de445daa5 feat(app): rewire account footer menu - App Store feedback, support link
Center-aligned TipJarView's "Support Outpost" heading and thank-you/
error text to match the rest of AboutInfoView (was VStack(alignment:
.leading), out of place among everything else there being centered).

AccountFooter's Documentation/API Documentation/Changelog links
pointed at Outpost's own repo or the signed-in Outline server's own
/developers page - not actually useful here, removed along with the
now-unused repositoryURL/issuesURL/apiDocumentationURL. Send Us
Feedback and Report a Bug (previously both just opening the Gitea
issues page) collapsed into one "Leave Us Feedback" wired to Apple's
native requestReview() prompt - there's no separate Apple-native
channel for "bug" vs "feedback", so one button covers both.

Added "Support Outpost" near the bottom of the same menu, alongside
Profile/Settings (same visual weight, not pinned to the top) - opens
Settings -> About, same navigation the app-menu's "About Outpost"
command already uses.

Also checking in the shared Xcode scheme (previously untracked/
nonexistent) now that it references Configuration.storekit, so the
StoreKit testing setup travels with the repo instead of being
machine-local.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:38:11 +01:00
Puranjay Savar Mattas 8ca5735019 feat(app): tip jar (StoreKit consumables) in Settings -> About
Four consumable IAP tiers - Small (0.99), Medium (2.99), Large (4.99),
Generous (9.99), product ids com.psmattas.OutpostApp.tip.{small,
medium,large,generous}. TipJarStore loads them via
Product.products(for:), purchases via product.purchase(), finishes
the transaction immediately on success - consumables have no
entitlement to persist or restore (a tip doesn't unlock anything), so
there's none of the Transaction.currentEntitlements restore-on-launch
logic a real purchase would need. TipJarView shows one button per
tier (price + name, StoreKit's own localized display strings) with a
"Thank you!" after success or a plain message on failure - no manual
retry button, tapping a tier again just re-attempts.

Wired into AboutInfoView between the source link and the copyright
line.

Added Configuration.storekit (4 products matching the IDs above) for
local testing in Xcode without needing real App Store Connect
products yet - enable it via Edit Scheme -> Run/Preview -> Options ->
StoreKit Configuration. Before actually shipping, the same 4 product
IDs need to exist for real in App Store Connect (Consumable type,
matching reference names) - the local file doesn't create anything
there.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:23:48 +01:00
20 changed files with 510 additions and 59 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
- type: input
id: summary
attributes:
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
Outpost is early alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
- type: input
id: summary
attributes:
+1 -1
View File
@@ -2,7 +2,7 @@
Thank you for contributing. Please read this guide before opening issues or PRs.
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it.
Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it.
---
+2 -2
View File
@@ -425,7 +425,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.4;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -477,7 +477,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.4;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C26302CE96200C9949F"
BuildableName = "OutpostTests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C30302CE96200C9949F"
BuildableName = "OutpostUITests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Outpost/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+74
View File
@@ -0,0 +1,74 @@
{
"identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A",
"nonRenewingSubscriptions" : [],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6",
"localizations" : [
{
"description" : "A small tip to support Outpost's development.",
"displayName" : "Small Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.small",
"referenceName" : "Small Tip",
"type" : "Consumable"
},
{
"displayPrice" : "2.99",
"familyShareable" : false,
"internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938",
"localizations" : [
{
"description" : "A medium tip to support Outpost's development.",
"displayName" : "Medium Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.medium",
"referenceName" : "Medium Tip",
"type" : "Consumable"
},
{
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D",
"localizations" : [
{
"description" : "A large tip to support Outpost's development.",
"displayName" : "Large Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.large",
"referenceName" : "Large Tip",
"type" : "Consumable"
},
{
"displayPrice" : "9.99",
"familyShareable" : false,
"internalID" : "E072C1AC-2719-483E-8A54-F4924808B724",
"localizations" : [
{
"description" : "A generous tip to support Outpost's development.",
"displayName" : "Generous Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.generous",
"referenceName" : "Generous Tip",
"type" : "Consumable"
}
],
"settings" : {
"_askToBuyEnabled" : false
},
"subscriptionGroups" : [],
"version" : {
"major" : 3,
"minor" : 0
}
}
+5
View File
@@ -48,6 +48,11 @@ struct AboutInfoView: View {
}
.font(.callout)
Divider()
.frame(maxWidth: 240)
TipJarView()
Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2)
.foregroundStyle(.tertiary)
+75
View File
@@ -0,0 +1,75 @@
#if os(macOS)
import SwiftUI
import StoreKit
/// One button per consumable tip tier no "restore purchases" (nothing to
/// restore, consumables aren't entitlements) and no manual retry: a failed
/// load just shows a message, tapping a tier again re-attempts naturally.
struct TipJarView: View {
@State private var store = TipJarStore()
var body: some View {
VStack(spacing: 8) {
Text("Support Outpost")
.font(.callout.weight(.semibold))
if store.isLoading && store.products.isEmpty {
ProgressView()
.controlSize(.small)
} else if !store.products.isEmpty {
HStack(spacing: 8) {
ForEach(store.products) { product in
tipButton(for: product)
}
}
}
switch store.purchaseState {
case .thankYou:
Label("Thank you!", systemImage: "heart.fill")
.font(.caption)
.foregroundStyle(.pink)
case .failed(let message):
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
case .idle, .purchasing:
EmptyView()
}
}
.task { await store.loadProductsIfNeeded() }
}
private func tipButton(for product: Product) -> some View {
Button {
Task { await store.purchase(product) }
} label: {
VStack(spacing: 2) {
if isPurchasing(product) {
ProgressView()
.controlSize(.small)
} else {
Text(product.displayPrice)
.font(.callout.weight(.semibold))
}
Text(product.displayName)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(minWidth: 64)
.padding(.vertical, 6)
}
.buttonStyle(.bordered)
.disabled(isAnyPurchaseInFlight)
}
private func isPurchasing(_ product: Product) -> Bool {
store.purchaseState == .purchasing(product.id)
}
private var isAnyPurchaseInFlight: Bool {
if case .purchasing = store.purchaseState { return true }
return false
}
}
#endif
+14 -18
View File
@@ -1,5 +1,6 @@
#if os(macOS)
import SwiftUI
import StoreKit
import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
@@ -9,20 +10,13 @@ import OutlineKit
struct AccountFooter: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL
@Environment(\.requestReview) private var requestReview
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
private var apiDocumentationURL: URL? {
session.serverURL?.appendingPathComponent("developers")
}
var body: some View {
Button {
isMenuPresented = true
@@ -62,16 +56,10 @@ struct AccountFooter: View {
private var menuContent: some View {
VStack(alignment: .leading, spacing: 2) {
menuItem("Documentation") { openURL(repositoryURL) }
if let apiDocumentationURL {
menuItem("API Documentation") { openURL(apiDocumentationURL) }
}
menuItem("Changelog") { openURL(repositoryURL) }
Divider()
menuItem("Send Us Feedback") { openURL(issuesURL) }
menuItem("Report a Bug") { openURL(issuesURL) }
// Apple's own review/feedback prompt there's no separate
// native channel for "bug" vs. "feedback", so one button covers
// both.
menuItem("Leave Us Feedback") { requestReview() }
Divider()
@@ -101,6 +89,14 @@ struct AccountFooter: View {
// as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
// Same deferred-Task reasoning as Settings above this also
// sets isShowingSettings synchronously.
menuItem("Support Outpost") {
Task { @MainActor in
navigation.selectedSettingsSection = .about
navigation.isShowingSettings = true
}
}
Divider()
@@ -74,10 +74,12 @@ struct AvatarCropperView: View {
Button("Cancel", role: .cancel, action: onCancel)
Spacer()
Button("Use Photo") {
if let data = renderFinalImage() {
Task {
if let data = await renderFinalImage() {
onConfirm(data)
}
}
}
.buttonStyle(.borderedProminent)
}
}
@@ -100,15 +102,26 @@ struct AvatarCropperView: View {
.clipped()
}
/// `ImageRenderer` itself has to run on the main actor (it captures live
/// SwiftUI view state), but JPEG compression on the bitmap it produces
/// is pure CPU work with no SwiftUI dependency left hopping off for
/// just that part avoids a visible hitch on tapping "Use Photo".
/// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what
/// actually crosses the actor boundary; mirrors `NSImage.jpegData(
/// compressionQuality:)`'s own logic rather than calling it directly, so
/// crossing doesn't require handing a non-Sendable `NSImage` to a
/// detached task.
@MainActor
private func renderFinalImage() -> Data? {
private func renderFinalImage() async -> Data? {
let content = avatarContent
.clipShape(Circle())
.frame(width: diameter, height: diameter)
let renderer = ImageRenderer(content: content)
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
guard let nsImage = renderer.nsImage else { return nil }
return nsImage.jpegData(compressionQuality: 0.9)
guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
return await Task.detached(priority: .userInitiated) {
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
}.value
}
}
#endif
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
/// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
/// Recomputed only when `viewModel.documents`/`sortOption` actually change
/// (below) instead of being a computed property this rebuilt the whole
/// dictionary-grouped, recursively-sorted tree on every `body` evaluation,
/// including renders triggered by unrelated state (selection, hover,
/// pins) that don't change the tree's shape at all.
@State private var tree: [DocumentNode] = []
private func rebuildTree() {
tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
}
init(
@@ -86,7 +93,10 @@ struct CollectionDocumentsOutline: View {
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load()
await loadPins()
rebuildTree()
}
.onChange(of: viewModel.documents) { rebuildTree() }
.onChange(of: sortOption) { rebuildTree() }
}
private func loadPins() async {
@@ -34,8 +34,15 @@ struct CollectionOverviewView: View {
self.onOpenDocument = onOpenDocument
}
private var sortedDocuments: [OutlineDocument] {
selectedTab.sorted(viewModel.documents)
/// Recomputed only when `viewModel.documents`/`selectedTab` actually
/// change (below) instead of being a computed property re-sorted on
/// every render capped at 100 documents per collection page, so lower
/// blast radius than the sidebar/command-palette versions of this same
/// pattern, but the same fix.
@State private var sortedDocuments: [OutlineDocument] = []
private func resortDocuments() {
sortedDocuments = selectedTab.sorted(viewModel.documents)
}
var body: some View {
@@ -94,6 +101,8 @@ struct CollectionOverviewView: View {
await viewModel.checkForRemoteChanges()
}
}
.onChange(of: viewModel.documents) { resortDocuments() }
.onChange(of: selectedTab) { resortDocuments() }
}
// Spans the full window width, centered, directly under the toolbar
@@ -41,22 +41,31 @@ struct CommandPaletteView: View {
}
}
private var results: [Result] {
/// Recomputed only when `query`/`collections`/`documents` actually change
/// (below) instead of being a computed property Full Workspace mode's
/// index can be large (every document in the local cache, sub-documents
/// included), and this was re-scanning + re-sorting the entire thing on
/// every render, including ones triggered by unrelated state like
/// `selectedIndex` changing as arrow keys move the selection.
@State private var results: [Result] = []
private func recomputeResults() {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
// No query yet: surface collections first, then the most
// recent/full-workspace documents as-is, capped so the panel
// doesn't dump the entire workspace with nothing typed.
return (collections.map(Result.collection) + documents.map(Result.document))
results = (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20)
.map { $0 }
return
}
let scored: [(Result, Int)] = collections.compactMap { collection in
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
} + documents.compactMap { document in
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
}
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
}
/// Lower is better exact match, then prefix match, then earliest
@@ -100,7 +109,10 @@ struct CommandPaletteView: View {
.textFieldStyle(.plain)
.font(.title3)
.focused($isSearchFieldFocused)
.onChange(of: query) { selectedIndex = 0 }
.onChange(of: query) {
selectedIndex = 0
recomputeResults()
}
.onSubmit { selectCurrent() }
// Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the
@@ -169,6 +181,8 @@ struct CommandPaletteView: View {
isSearchFieldFocused = true
await loadResults()
}
.onChange(of: collections) { recomputeResults() }
.onChange(of: documents) { recomputeResults() }
}
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
@State private var currentMatchIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private var matchingLineIndices: [Int] {
guard !query.isEmpty else { return [] }
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
/// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
/// instead of being a computed property this used to re-scan the whole
/// document on every access, and it's read multiple times per row
/// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a
/// large document turned into an O(n²) case-insensitive scan per frame.
@State private var matchingLineIndices: [Int] = []
/// O(1) membership for the per-row highlight check below `matchingLineIndices`
/// stays an ordered array (needed for `currentMatchIndex`/stepping), this is
/// just a parallel lookup so a common search term with many matches doesn't
/// make every row's highlight check an O(k) linear scan.
@State private var matchingLineIndexSet: Set<Int> = []
private func recomputeMatches() {
guard !query.isEmpty else {
matchingLineIndices = []
matchingLineIndexSet = []
return
}
matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
matchingLineIndexSet = Set(matchingLineIndices)
}
var body: some View {
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
.textFieldStyle(.plain)
.focused($isSearchFieldFocused)
.onChange(of: query) { currentMatchIndex = 0 }
.onChange(of: query) {
currentMatchIndex = 0
recomputeMatches()
}
if !matchingLineIndices.isEmpty {
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
.padding(.horizontal, 4)
.background(
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15)
: matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15)
: Color.clear
)
.id(index)
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
do {
let text = try await apiClient.documentInfo(id: document.id).text
lines = text.components(separatedBy: "\n")
recomputeMatches()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
}
+14 -5
View File
@@ -83,16 +83,25 @@ final class HomeViewModel {
/// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar.
/// where it wouldn't be in the sidebar but they're fetched concurrently
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
/// own cached-read/retry path either way.
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
var documents: [OutlineDocument] = []
let client = apiClient
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
documents.append(document)
group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
}
var result: [String: OutlineDocument] = [:]
for await (id, document) in group {
if let document { result[id] = document }
}
return documents
return result
}
// Preserve pins.list's own order rather than task-completion order.
return pins.compactMap { documentsByID[$0.documentId] }
}
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
+7 -5
View File
@@ -7,24 +7,26 @@ import Foundation
enum OutpostVersion {
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
static let releaseStage = "ALPHA"
/// expected to stay a plain dotted-numeric string, not `0.1.0-ALPHA`.
/// Empty since the App Store release (no more alpha/beta suffix)
/// `displayString`/`fullVersionString` just show the plain version now.
static let releaseStage = ""
static var shortVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
}
static var buildNumber: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
}
/// e.g. `"0.0.3-ALPHA"` for compact display (sidebar footer).
/// e.g. `"0.1.0"` for compact display (sidebar footer).
static var displayString: String {
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "\(shortVersion)\(stageSuffix)"
}
/// e.g. `"Version 0.0.3-ALPHA (1)"` for the About page.
/// e.g. `"Version 0.1.0 (1)"` for the About page.
static var fullVersionString: String {
"Version \(displayString) (\(buildNumber))"
}
+71
View File
@@ -0,0 +1,71 @@
import StoreKit
import Observation
/// Backs the tip jar in Settings About. Consumables only a tip doesn't
/// unlock anything, so there's no entitlement to persist or restore, and
/// finishing the transaction immediately (rather than checking
/// `Transaction.currentEntitlements` on launch, the way a real purchase
/// would need to) is correct here.
@MainActor
@Observable
final class TipJarStore {
enum PurchaseState: Equatable {
case idle
case purchasing(String)
case thankYou(String)
case failed(String)
}
/// Must match the consumable In-App Purchase products created in App
/// Store Connect for this app exactly, including the bundle id prefix.
static let productIDs = [
"com.psmattas.OutpostApp.tip.small",
"com.psmattas.OutpostApp.tip.medium",
"com.psmattas.OutpostApp.tip.large",
"com.psmattas.OutpostApp.tip.generous"
]
private(set) var products: [Product] = []
private(set) var isLoading = false
var purchaseState: PurchaseState = .idle
/// Tip options don't change during a session no reason to refetch
/// every time the About screen appears.
func loadProductsIfNeeded() async {
guard products.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let fetched = try await Product.products(for: Self.productIDs)
// Keep the order defined above (small -> generous), not
// whatever order the App Store happens to return them in.
products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } }
if products.isEmpty {
purchaseState = .failed("Tip options aren't available right now.")
}
} catch {
purchaseState = .failed("Couldn't load tip options. Check your connection and try again.")
}
}
func purchase(_ product: Product) async {
purchaseState = .purchasing(product.id)
do {
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
purchaseState = .failed("Couldn't verify this purchase.")
return
}
await transaction.finish()
purchaseState = .thankYou(product.id)
case .userCancelled, .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .failed("Something went wrong completing the purchase.")
}
}
}
+4 -4
View File
@@ -5,14 +5,14 @@
<h1 align="center">Outpost</h1>
<p align="center">
<a href="https://testflight.apple.com/join/y1mYcYAM">
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
<a href="https://apps.apple.com/us/app/outpost-for-outline/id6802736230">
<img src="docs/assets/mac-app-store-badge.svg" height="40" alt="Download on the Mac App Store">
</a>
</p>
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
> **macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
## Why
@@ -21,7 +21,7 @@ Outline's web app is great, but there's no native Apple client with full editing
## Requirements
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the note above) — same 27+ minimum will apply once it lands
- A self-hosted (or hosted) Outline instance with API access
## Setup
+3 -3
View File
@@ -18,9 +18,9 @@ within 14 days depending on severity.
## Supported Versions
Outpost is in early alpha (`0.0.x`) — there's no stable release line
yet. Only the most recent tagged release receives fixes; please make
sure you're on the latest alpha before reporting.
Outpost is early (`0.1.x`) — there's no stable release line yet. Only
the most recent tagged release receives fixes; please make sure
you're on the latest release before reporting.
| Version | Supported |
| :--- | :---: |
+51
View File
@@ -0,0 +1,51 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="156.10054" height="40" viewBox="0 0 156.10054 40">
<title>Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917</title>
<g>
<g>
<g>
<path d="M146.57123,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.4378,6.4378,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01514.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27446,6.27446,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H146.57123c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.60205-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72184,5.72184,0,0,1-.543-1.6572,12.41339,12.41339,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37032,12.37032,0,0,1,.16553-1.87207,5.75552,5.75552,0,0,1,.54346-1.6621A5.3735,5.3735,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875h139.205l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73127,12.73127,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<g id="_Group_4" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M46.14895,30.49609V21.35645H46.0884l-3.74316,9.04492H40.91652l-3.75293-9.04492H37.104v9.13965H35.34816v-12.418h2.22949l4.01855,9.80176h.06836l4.01074-9.80176h2.2373v12.418Z" style="fill: #fff"/>
<path d="M49.396,27.92285c0-1.583,1.21289-2.53906,3.36523-2.668l2.47852-.1377v-.68848c0-1.00684-.66309-1.5752-1.791-1.5752a1.73035,1.73035,0,0,0-1.90137,1.27441H49.8091c.05176-1.63574,1.5752-2.79687,3.69141-2.79687,2.16016,0,3.58887,1.17871,3.58887,2.96v6.20508H55.30813V29.00684h-.043a3.23683,3.23683,0,0,1-2.85742,1.64453A2.74447,2.74447,0,0,1,49.396,27.92285Zm5.84375-.81738V26.4082l-2.22949.1377c-1.11035.06934-1.73828.55078-1.73828,1.3252,0,.792.6543,1.30859,1.65234,1.30859A2.17046,2.17046,0,0,0,55.23977,27.10547Z" style="fill: #fff"/>
<path d="M64.89309,24.55762a1.99909,1.99909,0,0,0-2.13379-1.66895c-1.42871,0-2.375,1.19629-2.375,3.08105,0,1.92773.95508,3.08887,2.3916,3.08887a1.94829,1.94829,0,0,0,2.11719-1.626h1.79A3.61835,3.61835,0,0,1,62.7593,30.6084c-2.582,0-4.26855-1.76465-4.26855-4.63867,0-2.81445,1.68652-4.63867,4.251-4.63867a3.63931,3.63931,0,0,1,3.9248,3.22656Z" style="fill: #fff"/>
<path d="M78.7593,27.13965H74.0259l-1.13672,3.35645H70.8843l4.4834-12.418h2.083l4.4834,12.418H79.895Zm-4.24316-1.54883h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M91.61672,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438H83.0884V21.44238h1.79883v1.50586h.03418a3.21161,3.21161,0,0,1,2.88281-1.60059C90.10207,21.34766,91.61672,23.16406,91.61672,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C88.7593,29.01563,89.70656,27.81934,89.70656,25.96973Z" style="fill: #fff"/>
<path d="M101.58156,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C100.06691,21.34766,101.58156,23.16406,101.58156,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C98.72414,29.01563,99.67141,27.81934,99.67141,25.96973Z" style="fill: #fff"/>
<path d="M108.1675,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52149.75684-2.52149,1.8584c0,.87793.65431,1.39453,2.25489,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M119.80324,19.2998v2.14258h1.72168v1.47168h-1.72168v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406h-1.31641V21.44238h1.31641V19.2998Z" style="fill: #fff"/>
<path d="M122.521,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C124.18215,30.6084,122.521,28.82617,122.521,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40137,1.16211-2.40137,3.10742c0,1.96191.89551,3.10645,2.40137,3.10645S129.21633,27.93164,129.21633,25.96973Z" style="fill: #fff"/>
<path d="M132.64309,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.598,2.598,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93651,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M145.84035,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,139.55813,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
</g>
<g id="_Group_5" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57521,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.40041,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.40041,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29494,13.03955Z" style="fill: #fff"/>
<path d="M71.34768,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.07131,14.772,71.34768,13.87061,71.34768,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72072,10.91846,72.26564,11.49707,72.26564,12.44434Z" style="fill: #fff"/>
<path d="M79.22951,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.124,13.99463,82.56252,13.42432,82.56252,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB