diff --git a/Commands/SendToCommand.swift b/Commands/SendToCommand.swift new file mode 100644 index 000000000..ec56f3338 --- /dev/null +++ b/Commands/SendToCommand.swift @@ -0,0 +1,26 @@ +// +// SendToCommand.swift +// Evergreen +// +// Created by Brent Simmons on 1/8/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import Cocoa + +protocol SendToCommand { + + func canSendObject(_ object: Any?) -> Bool + func sendObject(_ object: Any?) +} + +extension SendToCommand { + + func appExistsOnDisk(_ bundleIdentifier: String) -> Bool { + + if let _ = NSWorkspace.shared.absolutePathForApplication(withBundleIdentifier: bundleIdentifier) { + return true + } + return false + } +} diff --git a/Commands/SendToMarsEditCommand.swift b/Commands/SendToMarsEditCommand.swift new file mode 100644 index 000000000..ca1e29d81 --- /dev/null +++ b/Commands/SendToMarsEditCommand.swift @@ -0,0 +1,21 @@ +// +// SendToMarsEditCommand.swift +// Evergreen +// +// Created by Brent Simmons on 1/8/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import Foundation + +final class SendToMarsEditCommand: SendToCommand { + + func canSendObject(_ object: Any?) -> Bool { + + return false + } + + func sendObject(_ object: Any?) { + + } +} diff --git a/Commands/SendToMicroBlogCommand.swift b/Commands/SendToMicroBlogCommand.swift new file mode 100644 index 000000000..8260799dc --- /dev/null +++ b/Commands/SendToMicroBlogCommand.swift @@ -0,0 +1,42 @@ +// +// SendToMicroBlogCommand.swift +// Evergreen +// +// Created by Brent Simmons on 1/8/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import Cocoa + +// Not undoable. + +final class SendToMicroBlogCommand: SendToCommand { + + private let bundleID = "blog.micro.mac" + private var appExists = false + + init() { + + self.appExists = appExistsOnDisk(bundleID) + NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive(_:)), name: NSApplication.didBecomeActiveNotification, object: nil) + } + + func canSendObject(_ object: Any?) -> Bool { + + if !appExists { + return false + } + return false + } + + func sendObject(_ object: Any?) { + + } + + @objc func appDidBecomeActive(_ note: Notification) { + + self.appExists = appExistsOnDisk(bundleID) + } +} + + diff --git a/Evergreen.xcodeproj/project.pbxproj b/Evergreen.xcodeproj/project.pbxproj index 5aeac4da6..166e84988 100644 --- a/Evergreen.xcodeproj/project.pbxproj +++ b/Evergreen.xcodeproj/project.pbxproj @@ -84,6 +84,9 @@ 849C64681ED37A5D003D8FC0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; }; 849C646B1ED37A5D003D8FC0 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 849C64691ED37A5D003D8FC0 /* Main.storyboard */; }; 849C64761ED37A5D003D8FC0 /* EvergreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849C64751ED37A5D003D8FC0 /* EvergreenTests.swift */; }; + 84A14FF320048CA70046AD9A /* SendToMicroBlogCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A14FF220048CA70046AD9A /* SendToMicroBlogCommand.swift */; }; + 84A1500320048D660046AD9A /* SendToCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A1500220048D660046AD9A /* SendToCommand.swift */; }; + 84A1500520048DDF0046AD9A /* SendToMarsEditCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A1500420048DDF0046AD9A /* SendToMarsEditCommand.swift */; }; 84B06FAE1ED37DBD00F0B54B /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84B06FA91ED37DAD00F0B54B /* RSCore.framework */; }; 84B06FAF1ED37DBD00F0B54B /* RSCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 84B06FA91ED37DAD00F0B54B /* RSCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 84B06FB21ED37DBD00F0B54B /* RSDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84B06F9D1ED37DA000F0B54B /* RSDatabase.framework */; }; @@ -512,6 +515,9 @@ 849C64711ED37A5D003D8FC0 /* EvergreenTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EvergreenTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 849C64751ED37A5D003D8FC0 /* EvergreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvergreenTests.swift; sourceTree = ""; }; 849C64771ED37A5D003D8FC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 84A14FF220048CA70046AD9A /* SendToMicroBlogCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMicroBlogCommand.swift; sourceTree = ""; }; + 84A1500220048D660046AD9A /* SendToCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToCommand.swift; sourceTree = ""; }; + 84A1500420048DDF0046AD9A /* SendToMarsEditCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendToMarsEditCommand.swift; sourceTree = ""; }; 84A6B6931FB8D43C006754AC /* DinosaursWindow.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = DinosaursWindow.xib; sourceTree = ""; }; 84A6B6951FB8DBD2006754AC /* DinosaursWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DinosaursWindowController.swift; sourceTree = ""; }; 84B06F961ED37DA000F0B54B /* RSDatabase.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSDatabase.xcodeproj; path = Frameworks/RSDatabase/RSDatabase.xcodeproj; sourceTree = ""; }; @@ -706,6 +712,9 @@ children = ( 84702AA31FA27AC0006B8943 /* MarkReadOrUnreadCommand.swift */, 84B99C9C1FAE83C600ECDEDB /* DeleteFromSidebarCommand.swift */, + 84A1500220048D660046AD9A /* SendToCommand.swift */, + 84A14FF220048CA70046AD9A /* SendToMicroBlogCommand.swift */, + 84A1500420048DDF0046AD9A /* SendToMarsEditCommand.swift */, ); path = Commands; sourceTree = ""; @@ -1511,9 +1520,11 @@ 849A975B1ED9EB0D007D329B /* ArticleUtilities.swift in Sources */, 84DAEE301F86CAFE0058304B /* OPMLImporter.swift in Sources */, 849A975C1ED9EB0D007D329B /* DefaultFeedsImporter.swift in Sources */, + 84A14FF320048CA70046AD9A /* SendToMicroBlogCommand.swift in Sources */, 849A97891ED9ECEF007D329B /* ArticleStyle.swift in Sources */, 84FF69B11FC3793300DC198E /* FaviconURLFinder.swift in Sources */, 842611A21FCB769D0086A189 /* RSHTMLMetadata+Extension.swift in Sources */, + 84A1500520048DDF0046AD9A /* SendToMarsEditCommand.swift in Sources */, 849A978A1ED9ECEF007D329B /* ArticleStylesManager.swift in Sources */, 849A97791ED9EC04007D329B /* TimelineStringUtilities.swift in Sources */, 84F204CE1FAACB660076E152 /* FeedListViewController.swift in Sources */, @@ -1549,6 +1560,7 @@ 84411E711FE5FBFA004B527F /* SmallIconProvider.swift in Sources */, 844B5B591FE9FE4F00C7C76A /* SidebarKeyboardDelegate.swift in Sources */, 849A97A31ED9F180007D329B /* FolderTreeControllerDelegate.swift in Sources */, + 84A1500320048D660046AD9A /* SendToCommand.swift in Sources */, 845A29091FC74B8E007B49E3 /* SingleFaviconDownloader.swift in Sources */, 849A97851ED9ECCD007D329B /* PreferencesWindowController.swift in Sources */, 84E850861FCB60CE0072EA88 /* AuthorAvatarDownloader.swift in Sources */, diff --git a/Evergreen/Base.lproj/MainWindow.storyboard b/Evergreen/Base.lproj/MainWindow.storyboard index 681e4fc9c..58236d0df 100644 --- a/Evergreen/Base.lproj/MainWindow.storyboard +++ b/Evergreen/Base.lproj/MainWindow.storyboard @@ -290,7 +290,7 @@ - + diff --git a/Evergreen/FeedList/FeedList.plist b/Evergreen/FeedList/FeedList.plist index b2a423c0a..ad2d739b6 100644 --- a/Evergreen/FeedList/FeedList.plist +++ b/Evergreen/FeedList/FeedList.plist @@ -28,14 +28,6 @@ url https://9to5mac.com/feed/ - - name - Macalope - homePageURL - http://www.macalope.com/ - url - http://www.macalope.com/feed/ - name Macdrifter diff --git a/Evergreen/Images/ImageDownloader.swift b/Evergreen/Images/ImageDownloader.swift index 7d819a539..e6d2ae87f 100644 --- a/Evergreen/Images/ImageDownloader.swift +++ b/Evergreen/Images/ImageDownloader.swift @@ -131,6 +131,8 @@ private extension ImageDownloader { func postImageDidBecomeAvailableNotification(_ url: String) { - NotificationCenter.default.post(name: .ImageDidBecomeAvailable, object: self, userInfo: [UserInfoKey.url: url]) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .ImageDidBecomeAvailable, object: self, userInfo: [UserInfoKey.url: url]) + } } } diff --git a/Evergreen/Info.plist b/Evergreen/Info.plist index 33ce922ac..6a7a1b995 100644 --- a/Evergreen/Info.plist +++ b/Evergreen/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0d30 + 1.0d31 CFBundleVersion 522 LSMinimumSystemVersion @@ -37,9 +37,9 @@ NSAllowsArbitraryLoads - NSAppleScriptEnabled - - OSAScriptingDefinition - Evergreen.sdef + NSAppleScriptEnabled + + OSAScriptingDefinition + Evergreen.sdef diff --git a/Evergreen/MainWindow/Timeline/TimelineViewController.swift b/Evergreen/MainWindow/Timeline/TimelineViewController.swift index 818c31e54..d822d7d98 100644 --- a/Evergreen/MainWindow/Timeline/TimelineViewController.swift +++ b/Evergreen/MainWindow/Timeline/TimelineViewController.swift @@ -51,6 +51,7 @@ class TimelineViewController: NSViewController, UndoableCommandRunner { private var didRegisterForNotifications = false private let timelineFontSizeKVOKey = "values.{AppDefaults.Key.timelineFontSize}" + private var reloadAvailableCellsTimer: Timer? private var articles = ArticleArray() { didSet { @@ -117,6 +118,7 @@ class TimelineViewController: NSViewController, UndoableCommandRunner { NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .FeedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(imageDidBecomeAvailable(_:)), name: .ImageDidBecomeAvailable, object: nil) NSUserDefaultsController.shared.addObserver(self, forKeyPath: timelineFontSizeKVOKey, options: NSKeyValueObservingOptions(rawValue: 0), context: nil) @@ -347,6 +349,11 @@ class TimelineViewController: NSViewController, UndoableCommandRunner { } } + @objc func imageDidBecomeAvailable(_ note: Notification) { + + queueReloadAvailableCells() + } + func fontSizeInDefaultsDidChange() { TimelineCellData.emptyCache() @@ -554,6 +561,32 @@ extension TimelineViewController: NSTableViewDelegate { private extension TimelineViewController { + func reloadAvailableCells() { + + if let indexesToReload = tableView.indexesOfAvailableRows() { + reloadCells(for: indexesToReload) + } + } + + func queueReloadAvailableCells() { + + invalidateReloadTimer() + reloadAvailableCellsTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { (timer) in + self.reloadAvailableCells() + self.invalidateReloadTimer() + } + } + + func invalidateReloadTimer() { + + if let timer = reloadAvailableCellsTimer { + if timer.isValid { + timer.invalidate() + } + reloadAvailableCellsTimer = nil + } + } + func updateShowAvatars() { if showFeedNames { diff --git a/Frameworks/RSCore/RSCore/NSTableView+Extensions.swift b/Frameworks/RSCore/RSCore/NSTableView+Extensions.swift index 5c7304626..dd79b419f 100755 --- a/Frameworks/RSCore/RSCore/NSTableView+Extensions.swift +++ b/Frameworks/RSCore/RSCore/NSTableView+Extensions.swift @@ -30,6 +30,13 @@ public extension NSTableView { return indexes.isEmpty ? nil : indexes } + func indexesOfAvailableRows() -> IndexSet? { + + var indexes = IndexSet() + enumerateAvailableRowViews { indexes.insert($1) } + return indexes.isEmpty ? nil : indexes + } + func scrollTo(row: Int) { guard let scrollView = self.enclosingScrollView else { diff --git a/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj b/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj index 1ea06cf4e..f97c93ce2 100644 --- a/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj +++ b/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj @@ -102,6 +102,7 @@ 84D81BE41EFA2D3D00652332 /* ParsedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D81BE31EFA2D3D00652332 /* ParsedItem.swift */; }; 84D81BE61EFA2DFB00652332 /* ParsedAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D81BE51EFA2DFB00652332 /* ParsedAttachment.swift */; }; 84D81BE81EFA2E6700652332 /* ParsedHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D81BE71EFA2E6700652332 /* ParsedHub.swift */; }; + 84DA2E21200415D500A4D03B /* curt.json in Resources */ = {isa = PBXBuildFile; fileRef = 84DA2E20200415D500A4D03B /* curt.json */; }; 84DCCC661FF80E0100D2DDF1 /* EntityDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DCCC651FF80E0100D2DDF1 /* EntityDecodingTests.swift */; }; 84E7E69F1F85780D0046719D /* ParserData.h in Headers */ = {isa = PBXBuildFile; fileRef = 84E7E69D1F85780D0046719D /* ParserData.h */; settings = {ATTRIBUTES = (Public, ); }; }; 84E7E6A01F85780D0046719D /* ParserData.m in Sources */ = {isa = PBXBuildFile; fileRef = 84E7E69E1F85780D0046719D /* ParserData.m */; }; @@ -216,6 +217,7 @@ 84D81BE31EFA2D3D00652332 /* ParsedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ParsedItem.swift; path = Feeds/ParsedItem.swift; sourceTree = ""; }; 84D81BE51EFA2DFB00652332 /* ParsedAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ParsedAttachment.swift; path = Feeds/ParsedAttachment.swift; sourceTree = ""; }; 84D81BE71EFA2E6700652332 /* ParsedHub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ParsedHub.swift; path = Feeds/ParsedHub.swift; sourceTree = ""; }; + 84DA2E20200415D500A4D03B /* curt.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = curt.json; sourceTree = ""; }; 84DCCC651FF80E0100D2DDF1 /* EntityDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityDecodingTests.swift; sourceTree = ""; }; 84E7E69D1F85780D0046719D /* ParserData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ParserData.h; sourceTree = ""; }; 84E7E69E1F85780D0046719D /* ParserData.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ParserData.m; sourceTree = ""; }; @@ -380,6 +382,7 @@ 849A03C41F0081EA00122600 /* Resources */ = { isa = PBXGroup; children = ( + 84DA2E20200415D500A4D03B /* curt.json */, 849A03C51F0081EA00122600 /* DaringFireball.html */, 840FDCB71F0218670041F61B /* DaringFireball.atom */, 840FDCB91F02186D0041F61B /* DaringFireball.json */, @@ -600,6 +603,7 @@ 849A03EA1F01F92B00122600 /* inessential.json in Resources */, 849A03D71F0081EA00122600 /* OneFootTsunami.atom in Resources */, 849A03D41F0081EA00122600 /* inessential.html in Resources */, + 84DA2E21200415D500A4D03B /* curt.json in Resources */, 849A03D31F0081EA00122600 /* furbo.html in Resources */, 849A03E81F01F88600122600 /* ScriptingNews.json in Resources */, 844B5B3E1FE9A13C00C7C76A /* 4fsodonline.atom in Resources */, diff --git a/Frameworks/RSParser/RSParserTests/FeedParserTypeTests.swift b/Frameworks/RSParser/RSParserTests/FeedParserTypeTests.swift index 8bc6627b5..09705ae3e 100644 --- a/Frameworks/RSParser/RSParserTests/FeedParserTypeTests.swift +++ b/Frameworks/RSParser/RSParserTests/FeedParserTypeTests.swift @@ -134,6 +134,13 @@ class FeedParserTypeTests: XCTestCase { XCTAssertTrue(type == .jsonFeed) } + func testCurtJSONFeedType() { + + let d = parserData("curt", "json", "http://curtclifton.net/") + let type = feedType(d) + XCTAssertTrue(type == .jsonFeed) + } + // MARK: Unknown func testPartialAllThisUnknownFeedType() { diff --git a/Frameworks/RSParser/RSParserTests/JSONFeedParserTests.swift b/Frameworks/RSParser/RSParserTests/JSONFeedParserTests.swift index 721cc5f3f..f7c5e61eb 100644 --- a/Frameworks/RSParser/RSParserTests/JSONFeedParserTests.swift +++ b/Frameworks/RSParser/RSParserTests/JSONFeedParserTests.swift @@ -66,4 +66,22 @@ class JSONFeedParserTests: XCTestCase { XCTAssertEqual(parsedFeed.items.count, 12) } + + func testCurt() { + + let d = parserData("curt", "json", "http://curtclifton.net/") + let parsedFeed = try! FeedParser.parse(d)! + + XCTAssertEqual(parsedFeed.items.count, 26) + + var didFindTwitterQuitterArticle = false + for article in parsedFeed.items { + if article.title == "Twitter Quitter" { + didFindTwitterQuitterArticle = true + XCTAssertTrue(article.contentHTML!.hasPrefix("

I’ve decided to close my Twitter account. William Van Hecke makes a convincing case")) + } + } + + XCTAssertTrue(didFindTwitterQuitterArticle) + } } diff --git a/Frameworks/RSParser/RSParserTests/Resources/curt.json b/Frameworks/RSParser/RSParserTests/Resources/curt.json new file mode 100644 index 000000000..44a0686a4 --- /dev/null +++ b/Frameworks/RSParser/RSParserTests/Resources/curt.json @@ -0,0 +1 @@ +{"icon":"http:\/\/curtclifton.net\/style\/feedicon.png","user_comment":"This feed allows you to read the posts from this site in any feed reader that supports the JSON Feed format. To add this feed to your reader, copy the following URL — http:\/\/curtclifton.net\/feed.json — and add it your reader.","favicon":"http:\/\/curtclifton.net\/style\/touch-icon.png","description":"Full-text posts, generally related to software development on Apple’s platforms. Programming language geekery.","version":"https:\/\/jsonfeed.org\/version\/1","title":"curtclifton.net","items":[{"date_published":"2018-01-06T08:00","title":"Twitter Quitter","id":"twitter-quitter","content_html":"

I’ve decided to close my Twitter account. William Van Hecke makes a convincing case<\/a> for its diminishing utility, and it’s clear that Jack<\/a> is more concerned with eyeballs than standards<\/a>.<\/p>\n\n

I stopped regularly reading my timeline months ago. The few times I have dipped in, I’ve ended up angry or depressed. Despite occasional bright spots, there is always someone sharing the angst of the day. I read the news. I don’t need Twitter to make me more anxious. As such, I’ve only been using Twitter for cross posting from my micro.blog account<\/a> and responding to mentions. Slack<\/a> meets my social chat needs without the screaming-into-the-void that Twitter has become.<\/p>\n\n

After some reflection, I’ve concluded that even posting to Twitter is just providing content to a platform for hate and anger. I can’t fix that problem, but I can stop contributing to the platform. And so I will.<\/p>\n\n

I’m taking my Twitter account private. I’ll stop reading and (after this) posting to it. If you want to get in touch, please email<\/a>, iMessage, or drop a mention <\/span>@<\/span>curt<\/span><\/code> on micro.blog<\/a>. I’d also be happy for an invite to your Slack group or a friend request on Facebook<\/a>.<\/em> (While Facebook is also an addiction-exploiting attention hole, it provides much more control to users. The positives there outweigh the negatives.)<\/p>\n\n

Be well. Find the good in the world. Peace.<\/p>\n","url":"http:\/\/www.curtclifton.net\/twitter-quitter"},{"date_published":"2017-12-15T08:00","title":"Next Actions","id":"next-actions","content_html":"

After six and a half wonderful years, today is my last with the Omni Group. It’s been the joy and privilege of a lifetime to work with the great people at Omni. Care for others permeates the culture at Omni, from interpersonal interactions, to software design, to our amazing Support Humans. It’s been especially rewarding to contribute to OmniFocus<\/a>, an app that’s been invaluable to me personally and that helps many others achieve their goals.<\/p>\n\n

While it’s difficult to say goodbye to all that, I have an opportunity to join a small fruit company in Cupertino working on iPad software for education. The role combines several of my passions: teaching, learning, mentoring, and building elegant software. I’m looking forward to joining my new team in January and making great things together.<\/p>\n\n

I’ll miss the amazing Xcoders<\/a> community, but hope to make it back occasionally. And, of course, I’ll see you all when you come to San Jose for WWDC and related festivities. <\/p>\n\n

It will be great to be able spend more time with friends in the Bay Area. The next few weeks will be a whirlwind with the holidays and moving, but if you’re in the area hit me up<\/a> in the new year and let’s get together.<\/p>\n","url":"http:\/\/www.curtclifton.net\/next-actions"},{"date_published":"2017-10-27T07:00","title":"These are a Few of My Stateful Machines","id":"these-are-a-few-of-my-stateful-machines","content_html":"

I’m excited to be presenting at the inaugural Swift by Northwest<\/a> conference today. My talk is on state machines and how easy they are to implement in Swift.<\/p>\n\n