<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://mackuba.eu</id>
  <title>MacKuba.eu</title>
  <subtitle>Kuba Suder's blog on Mac &amp; iOS development</subtitle>
  <link href="https://mackuba.eu" rel="alternate" type="text/html"/>
  <link href="https://mackuba.eu/feed.xml" rel="self" type="application/atom+xml"/>
  <updated>2024-07-20T14:10:52Z</updated>
  <author>
    <name>Kuba Suder</name>
    <email>jakub.suder@gmail.com</email>
  </author>
  <entry>
    <id>https://mackuba.eu/2024/03/27/march-projects-update/</id>
    <title>March 2024 projects update</title>
    <published>2024-03-27T01:14:35Z</published>
    <updated>2024-03-27T01:14:35Z</updated>
    <link href="https://mackuba.eu/2024/03/27/march-projects-update/"/>
    <content type="html">&lt;p&gt;I&amp;rsquo;ve been still pretty busy with various Bluesky- and social-related projects recently, so here&amp;rsquo;s a small update on what I&amp;rsquo;ve been working on since my &lt;a href="https://mackuba.eu/2023/11/09/year-of-social-media-coding/"&gt;November post&lt;/a&gt;, if you&amp;rsquo;re interested:&lt;/p&gt;

&lt;h3&gt;Skythread – quote &amp;amp; hashtag search&lt;/h3&gt;

&lt;p&gt;I&amp;nbsp;was missing one useful feature that&amp;rsquo;s still not available on Bluesky: being able to see the number of quote posts a post has received and looking up the list of those quote posts. The Bluesky AppView doesn&amp;rsquo;t currently collect and expose this info, so it&amp;rsquo;s not a simple matter of calling the API. But since everything&amp;rsquo;s open, anyone can build a service that does this, they just need to collect the data themselves.&lt;/p&gt;

&lt;p&gt;Since I&amp;rsquo;m already recording all recent posts in a database for the purposes of feeds and other tools, I&amp;nbsp;figured I&amp;nbsp;could just add an indexed &lt;code&gt;quote_id&lt;/code&gt; column and set it to reference the source post on all incoming posts that are quotes, and later look up the quotes using that field.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blue.mackuba.eu/skythread/"&gt;Skythread&lt;/a&gt;, my thread reading tool, seemed like a good place to add a UI&amp;nbsp;for this. When you look up a thread there, it now makes a call to a private endpoint on my server which returns the number of quotes of the root post, and if there are any, it shows an appropriate link below the post. The link leads you to another page that lists the quotes in a reverse-chronological order, &lt;a href="https://blue.mackuba.eu/skythread/?quotes=https://bsky.app/profile/bsky.app/post/3klzrudt4uk2z"&gt;like this&lt;/a&gt; (it doesn&amp;rsquo;t currently do pagination though). You can open that page directly by appending the &lt;code&gt;bsky.app&lt;/code&gt; URL of a post after the &lt;code&gt;quotes=&lt;/code&gt; parameter here: &lt;a href="https://blue.mackuba.eu/skythread/?quotes="&gt;https://blue.mackuba.eu/skythread/?quotes=&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the same way, I&amp;nbsp;also indexed &lt;a href="https://blue.mackuba.eu/skythread/?hash=wwdc"&gt;posts including hashtags&lt;/a&gt;, since hashtags were being written into post records since the autumn, but it wasn&amp;rsquo;t possible to search for them in the app. However, this has now been added to the Bluesky app and search service, so you don&amp;rsquo;t need to use Skythread for that. I&amp;nbsp;hope that the quote search also won&amp;rsquo;t be needed for much longer&amp;nbsp;:)&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/social-march24/skythread-quotes.jpg"&gt;&lt;img alt="Quotes link below a post" src="https://mackuba.eu/images/posts/social-march24/skythread-quotes.jpg?1721484643" width="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;Handles directory&lt;/h3&gt;

&lt;p&gt;One very cool feature of Bluesky is that you can verify the authenticity of your account by yourself, by proving that you own the domain name that you&amp;rsquo;ve used as your handle. So for official accounts like &lt;a href="https://bsky.app/profile/nytimes.com"&gt;The New York Times&lt;/a&gt;, &lt;a href="https://bsky.app/profile/washingtonpost.com"&gt;The Washington Post&lt;/a&gt;, or &lt;a href="https://bsky.app/profile/ocasio-cortez.house.gov"&gt;Alexandria Ocasio-Cortez&lt;/a&gt;, it&amp;rsquo;s enough if they just set their handle to their main website domain (or a subdomain of &lt;a href="https://www.house.gov"&gt;house.gov&lt;/a&gt; in AOC&amp;rsquo;s case) to prove they&amp;rsquo;re legit – they don&amp;rsquo;t need to apply anywhere to get a blue or gold tick on their profile.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;was thinking one day that it would be nice to see how many e.g. &lt;code&gt;.gov&lt;/code&gt; handles there are and notice easily when new ones show up. So I&amp;nbsp;grabbed a list of all custom handes from the &lt;a href="https://plc.directory"&gt;plc.directory&lt;/a&gt; and started recording new and updated ones from the firehose.&lt;/p&gt;

&lt;p&gt;In the end, I&amp;nbsp;decided to build a whole &lt;a href="https://blue.mackuba.eu/directory/"&gt;catalog of all custom handles&lt;/a&gt;, grouped by TLD, and show which TLDs are the most popular. At first I&amp;nbsp;only included the &amp;ldquo;traditional&amp;rdquo; main TLDs and country domains, but a lot of people liked it and I&amp;nbsp;got a lot of requests to also include domains like &lt;code&gt;.art&lt;/code&gt;, &lt;code&gt;.blue&lt;/code&gt;, &lt;code&gt;.xyz&lt;/code&gt; and so on, so in the next update I&amp;rsquo;ve added all other domains too. (I&amp;nbsp;gave it an old-school tables-based design as a homage to the old &amp;ldquo;web directory&amp;rdquo; websites like &lt;a href="https://web.archive.org/web/20141122194515/https://dir.yahoo.com/"&gt;Yahoo Directory&lt;/a&gt; 😉)&lt;/p&gt;

&lt;p&gt;Apart from handles, the website now also tracks &lt;a href="https://blue.mackuba.eu/directory/pdses"&gt;third party PDS servers&lt;/a&gt; that started to show up after the &lt;a href="https://bsky.social/about/blog/02-22-2024-open-social-web"&gt;federation launch in February&lt;/a&gt;.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/social-march24/directory.jpg"&gt;&lt;img alt="Bluesky handles directory screenshot" src="https://mackuba.eu/images/posts/social-march24/directory.jpg?1721484643" width="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;Bluesky activity charts&lt;/h3&gt;

&lt;p&gt;I&amp;rsquo;ve also made a page that shows some charts tracking &lt;a href="https://blue.mackuba.eu/stats/"&gt;Bluesky user activity&lt;/a&gt; – the number of daily posts and unique users that have posted in a given day. The activity has been gradually falling since October until February, then there was a huge spike when Bluesky &lt;a href="https://bsky.social/about/blog/02-06-2024-join-bluesky"&gt;opened up for registrations&lt;/a&gt; without an invite (when Japan &lt;a href="https://bsky.app/profile/mackuba.eu/post/3kkubfjxudp2d"&gt;suddenly took over&lt;/a&gt;), and then it&amp;rsquo;s been falling down again since then (currently around the level of the October top).&lt;/p&gt;

&lt;p&gt;You can also see some other interesting stats on &lt;a href="https://bsky.jazco.dev/stats"&gt;Jaz&amp;rsquo;s page&lt;/a&gt; and &lt;a href="https://bskycharts.edavis.dev/edavis.dev/bskycharts.edavis.dev/index.html"&gt;Eric Davis&amp;rsquo;s Munin charts&lt;/a&gt;, especially the one tracking &lt;a href="https://bskycharts.edavis.dev/edavis.dev/bskycharts.edavis.dev/bsky_users_total.html"&gt;daily/weekly/monthly active user count&lt;/a&gt;. (I&amp;nbsp;also have a few more ideas for what to add to my charts.)&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/social-march24/post-stats.jpg"&gt;&lt;img alt="Bluesky daily post stats chart" src="https://mackuba.eu/images/posts/social-march24/post-stats.jpg?1721484643" width="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;DIDKit&lt;/h3&gt;

&lt;p&gt;In the last few weeks, I&amp;rsquo;ve been updating the code that tracks custom handles again to adapt to some protocol changes. The &lt;code&gt;#handle&lt;/code&gt; event in the firehose, which included handle info on every handle change, is now deprecated and &lt;a href="https://github.com/bluesky-social/atproto/discussions/2220"&gt;being replaced&lt;/a&gt; with a new &lt;code&gt;#identity&lt;/code&gt; event, which only tells you to go fetch the account info from the source again (source being usually &lt;a href="https://plc.directory"&gt;plc.directory&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;At the same time, I&amp;nbsp;also implemented validation of custom handles – clients and services that display handles are supposed to verify the handle reference in both directions themselves, because some accounts may have handles in the PLC registry assigned to domains that they don&amp;rsquo;t actually own or which don&amp;rsquo;t exist (the Bluesky official app shows such accounts with an &amp;ldquo;⚠ Invalid Handle&amp;rdquo; label, which you&amp;rsquo;ve probably seen before). For example, the handles directory page initially listed an &lt;code&gt;amongus.gov&lt;/code&gt; account under &lt;code&gt;.gov&lt;/code&gt; TLD, which was loaded from plc.directory, but is not in fact a real domain.&lt;/p&gt;

&lt;p&gt; This should ideally be done by not relying on Bluesky servers, and instead checking the DNS TXT entry and the &lt;code&gt;.well-known&lt;/code&gt; URL of a given domain manually. There&amp;rsquo;s a bunch of pretty generic logic there that will be needed in most projects that need to convert between DIDs and handles, so I&amp;nbsp;extracted it to another Ruby gem named &lt;a href="https://github.com/mackuba/didkit"&gt;DIDKit&lt;/a&gt;, which lets you do things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;get the DID of an account with a given handle&lt;/li&gt;
&lt;li&gt;load the DID JSON document, which includes info like assigned handle(s) or hosting PDS server&lt;/li&gt;
&lt;li&gt;check if any of the assigned handles from the document resolve back to the same DID&lt;/li&gt;
&lt;li&gt;fetch all updates to all DIDs in batches from the PLC directory&lt;/li&gt;
&lt;/ul&gt;



        &lt;a class="github-card" href="https://github.com/mackuba/didkit" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;didkit&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;A library for handling DID identifiers used in Bluesky AT Protocol&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;h3&gt;Skyfall&lt;/h3&gt;

&lt;p&gt;I&amp;rsquo;ve also been making some minor updates to my &lt;a href="https://github.com/mackuba/skyfall"&gt;Skyfall&lt;/a&gt; library for streaming data from the Bluesky relay firehose.&lt;/p&gt;

&lt;p&gt;One thing I&amp;rsquo;ve been trying to fix is a rare but annoying issue with the websocket connection getting stuck. From time to time, it manages to get into a state where no data is coming, but the connection doesn&amp;rsquo;t time out and just waits for new packets for hours, until I&amp;nbsp;notice it and restart it. It isn&amp;rsquo;t only happening to me, others have mentioned it too (and not only in Ruby code); but it happens rarely enough that it&amp;rsquo;s really hard to debug.&lt;/p&gt;

&lt;p&gt;My proposed fix is adding a &lt;a href="https://github.com/mackuba/skyfall/commit/5d485ae61eccc16a138c509c3a4d643b7586e6b0"&gt;&amp;ldquo;heartbeat&amp;rdquo; timer&lt;/a&gt;, which runs with some interval like every 30 seconds, and checks if there have been any new packets in some period of time; if there haven&amp;rsquo;t been any in a while, then it will forcefully restart the connection. (This isn&amp;rsquo;t included in the latest release yet, I&amp;rsquo;m waiting for it to get triggered a few times first.)&lt;/p&gt;

&lt;p&gt;Another thing I&amp;rsquo;ve added is being able to connect to a new kind of firehose exposed by &amp;ldquo;labellers&amp;rdquo; a.k.a. moderation services. Bluesky has released this new important piece of the federated architecture &lt;a href="https://docs.bsky.app/blog/blueskys-moderation-architecture"&gt;earlier this month&lt;/a&gt; – third party developers and communities can now set up independent moderation services, which manually or automatically add various &amp;ldquo;labels&amp;rdquo; to accounts or specific posts, flagging them e.g. as &amp;ldquo;racism&amp;rdquo; or &amp;ldquo;disinformation&amp;rdquo;. Anyone can subscribe to any labellers they choose, and they&amp;rsquo;ll see the labels from those selected services shown in the app. The new firehose (the &lt;code&gt;subscribeLabels&lt;/code&gt; endpoint) allows you to connect to a specific labeller and stream all new labels that it&amp;rsquo;s adding.&lt;/p&gt;

&lt;p&gt;I&amp;rsquo;m also tracking all new registered labeller services and &lt;a href="https://blue.mackuba.eu/labellers/"&gt;keeping a list here&lt;/a&gt; (it&amp;rsquo;s not curated, just a dump from a database table, so it also includes various test servers etc.).&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/skyfall" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;skyfall&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;A Ruby gem for streaming data from the Bluesky/AtProto firehose&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;h3&gt;The &amp;ldquo;Bluesky guide&amp;rdquo;&lt;/h3&gt;

&lt;p&gt;Last month I&amp;nbsp;wrote a long blog post titled &lt;a href="https://mackuba.eu/2024/02/21/bluesky-guide/"&gt;&amp;ldquo;Complete guide to Bluesky&amp;rdquo;&lt;/a&gt;, where I&amp;nbsp;included various info and tips for beginners about Bluesky history, available apps, handles, custom feeds, privacy, or currently missing features. I&amp;rsquo;m now updating it every time Bluesky releases another big feature&amp;nbsp;:) Check it out if you&amp;rsquo;ve missed it.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;have ideas for a few more Bluesky introduction posts with a developer focus – a general intro to the protocol and architecture, and about working with the XRPC API&amp;nbsp;and the firehose. I&amp;nbsp;hope I&amp;rsquo;ll be able to find time for that in the next few months.&lt;/p&gt;

&lt;h3&gt;Tootify – cross-posting to Mastodon&lt;/h3&gt;

&lt;p&gt;I&amp;nbsp;still want to finish my &lt;a href="https://bsky.app/profile/mackuba.eu/post/3k3y7lxorqd24"&gt;Mac app for cross-posting&lt;/a&gt; to Twitter, Mastodon and Bluesky one day, but it&amp;rsquo;s a lot of work and I&amp;rsquo;ve got too many different things in progress at the same time, so it&amp;rsquo;s moving at a glacial pace… In the meantime, I&amp;nbsp;started thinking if I&amp;nbsp;could maybe quickly build something much simpler that also does the job. I&amp;rsquo;ve been mainly hanging out on Bluesky in recent months and posting on Mastodon only occasionally, because having to copy-paste things from one tab to another is annoying, especially if images and alt text are involved. But the folks I&amp;nbsp;know from Twitter still mostly follow me on Twitter and Mastodon only and aren&amp;rsquo;t coming to Bluesky.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;also didn&amp;rsquo;t want to simply copy every single post from here to there, because a lot of things I&amp;nbsp;post on Bluesky are specifically about Bluesky stuff, so it doesn&amp;rsquo;t always make sense to post them to Mastodon – I&amp;nbsp;only want some selected ones to be copied. But at the same time, I&amp;nbsp;wanted to minimize the amount of friction this would add.&lt;/p&gt;

&lt;p&gt;So the idea I&amp;nbsp;had one night was that I&amp;nbsp;could mark the Bluesky posts to be copied to Mastodon by simply &amp;ldquo;liking&amp;rdquo; my own posts that I&amp;nbsp;want copied; a service or a cron job would then periodically look at the list of my recent likes, and when it notices one made on my own post, it would copy that post to Mastodon (and remove the like).&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;managed to build it in about a day and a half, complete with image support with alt text and copying of quote-posts as posts with plain links. It&amp;rsquo;s now running happily on a &lt;a href="https://bsky.app/profile/mackuba.eu/post/3jx6qhmaa3t2e"&gt;Raspberry Pi&lt;/a&gt; on my local network 😎&lt;/p&gt;

&lt;p&gt;The code is &lt;a href="https://github.com/mackuba/tootify"&gt;published here&lt;/a&gt;, if you&amp;rsquo;re interested – but it&amp;rsquo;s a bit of a proof of concept at the moment, just enough to make it work for myself, so it&amp;rsquo;s probably not very user-friendly. But maybe I&amp;rsquo;ll build it up into something bigger if people find it useful. (Just to clarify, this is meant to be a one-way sync by design – syncing in the other direction would be harder for various reasons, e.g. because of the complex &amp;ldquo;facets&amp;rdquo; system that Bluesky uses for post record data, and because Mastodon&amp;rsquo;s post length limit is higher than on Bluesky.)&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/tootify" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;tootify&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;Toot toooooooot&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;h3&gt;And One More Thing&amp;nbsp;;)&lt;/h3&gt;

&lt;p&gt;Paul Frazee, Bluesky&amp;rsquo;s lead dev, has a lovely cat named Kit and often posts photos of her. I&amp;rsquo;m a big fan of Kit, so I&amp;nbsp;made a feed named the &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/kit"&gt;Kit Feed&lt;/a&gt;, which only includes posts with these photos 🙂 Like and subscribe! 🐱&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/social-march24/paul-kit.jpg"&gt;&lt;img alt="@pfrazee.com: the feisty / sleepy cycle (attached two photos of Kit lying on a couch)" src="https://mackuba.eu/images/posts/social-march24/paul-kit.jpg?1721484643" width="595"&gt;&lt;/a&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2024/02/21/bluesky-guide/</id>
    <title>A complete guide to Bluesky 🦋</title>
    <published>2024-02-21T18:05:12Z</published>
    <updated>2024-02-21T18:05:12Z</updated>
    <link href="https://mackuba.eu/2024/02/21/bluesky-guide/"/>
    <content type="html">

&lt;div class="hide-in-intro"&gt;
  &lt;p&gt;&lt;i&gt;(Last update: &lt;a href="#changelog"&gt;24 Jun 2024&lt;/a&gt;.)&lt;/i&gt;&lt;/p&gt;
&lt;/div&gt;


&lt;p&gt;For the past 10 months, I&amp;rsquo;ve been a pretty active user of Bluesky. I&amp;nbsp;enjoy it a lot, and I&amp;rsquo;ve managed to learn a lot about how it works, what works well and what doesn&amp;rsquo;t, and also what&amp;rsquo;s likely coming next.&lt;/p&gt;

&lt;p&gt;I&amp;rsquo;ve decided to write down some of the tips &amp;amp; tricks that I&amp;nbsp;often give to friends when I&amp;nbsp;send them an invite code, or the advice and answers that I&amp;nbsp;sometimes give to people that I&amp;nbsp;find in some feed asking about things.&lt;/p&gt;

&lt;p&gt;This of course got much longer than I&amp;nbsp;planned 😅 so if only have a moment, here’s a TLDR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;there are official &lt;a href="https://apps.apple.com/us/app/bluesky-social/id6444370199"&gt;iOS&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=xyz.blueskyweb.app"&gt;Android&lt;/a&gt; apps, but you can also use &lt;a href="https://bsky.app"&gt;bsky.app&lt;/a&gt; in the browser, or try e.g. &lt;a href="https://graysky.app"&gt;Graysky&lt;/a&gt;, &lt;a href="https://deck.blue"&gt;deck.blue&lt;/a&gt;, &lt;a href="https://apps.apple.com/us/app/skeets-for-bluesky/id6466340923"&gt;Skeets&lt;/a&gt; (iOS) or &lt;a href="https://play.google.com/store/apps/details?id=com.gmail.mfnboer.skywalker"&gt;Skywalker&lt;/a&gt; (Android)&lt;/li&gt;
&lt;li&gt;if your timeline feels empty, check out the default algorithmic feed called &amp;ldquo;Discover&amp;rdquo; – or even better, go to the “Feeds” tab, look for the &amp;ldquo;Discover New Feeds” section and look for some feeds on the topics that interest you (on the top list or in the search); follow these feeds, and then if you find some interesting people posting in those feeds, follow them too. You can also search for feeds on &lt;a href="https://goodfeeds.co"&gt;goodfeeds.co&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;don’t be afraid to interact with people, repost good posts, like good comments, comment in threads and so on &amp;ndash; that’s how you make friends! (but be nice&amp;nbsp;:)&lt;/li&gt;
&lt;li&gt;if you see too much NSFW stuff, look for “Content filters” settings in the Moderation tab&lt;/li&gt;
&lt;li&gt;everything you post here is very public, so don’t share anything too private 😏&lt;/li&gt;
&lt;li&gt;if you own some cool domain name like “&lt;a href="https://taylorswift.com"&gt;taylorswift.com&lt;/a&gt;”, you can set it as your handle&lt;/li&gt;
&lt;li&gt;hashtags, word muting, GIFs (from Tenor) and DMs (simple version) are now available, videos and more DM stuff are coming, post editing is planned; some kind of private profiles for sharing to limited audience &lt;em&gt;may&lt;/em&gt; be coming, but not in near future&lt;/li&gt;
&lt;li&gt;Jack Dorsey is not the CEO and does not run the company&amp;nbsp;;)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;And now the long version:&lt;/p&gt;
&lt;div class="toc"&gt;&lt;ol&gt;
&lt;li&gt;&lt;a href="#what-is-bluesky"&gt;What is Bluesky?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#company"&gt;The company&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#apps"&gt;Apps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#terms"&gt;How are things called here?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#feeds"&gt;Feeds &amp;amp; algorithms&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#safety"&gt;Safety &amp;amp; moderation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#privacy"&gt;Privacy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#security"&gt;Account security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#handles"&gt;Handles &amp;amp; IDs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#federation"&gt;What is this federation thing?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#search"&gt;Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#settings"&gt;Settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#missing"&gt;Missing features&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#tools"&gt;Other tools&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;


&lt;hr /&gt;

&lt;p id="what-is-bluesky"&gt;&lt;/p&gt;

&lt;h2&gt;What is Bluesky?&lt;/h2&gt;

&lt;p&gt;(A bit of history, skip if you’re not interested&amp;nbsp;:)&lt;/p&gt;

&lt;p&gt;Bluesky is a project started originally by Twitter (now an independent company), whose goal is to create a decentralized Twitter-like social network, or more generally, a platform for building various decentralized social networks.&lt;/p&gt;

&lt;p&gt;The project was &lt;a href="https://www.theverge.com/2019/12/11/21010856/twitter-jack-dorsey-bluesky-decentralized-social-network-research-moderation"&gt;started in Dec 2019&lt;/a&gt; by &lt;a href="https://twitter.com/jack/status/1204766078468911106"&gt;Jack Dorsey&lt;/a&gt;, former Twitter CEO. The basic idea was to design a protocol on which you could build something that would work like Twitter, but which would not be under the control of a single company that makes unilateral decisions about everything on it. It would be more like web and email, which are open standards that anyone can build on &amp;ndash; any company can set up an email service or write an email app, and anyone can sign up for an account and start sending emails. There’s no one central authority on the Internet that can ban you from email altogether.&lt;/p&gt;

&lt;p&gt;Such network would consist of many servers owned by different companies and people connecting together, and the idea was that eventually, Twitter itself could become a part of that network, as just one of its elements.&lt;/p&gt;

&lt;p&gt;(If this all sounds a lot like the Mastodon social network, or “the Fediverse”, then you’re right &amp;ndash; there are a lot of similarities between these two. However, Bluesky is built on a completely different system they’ve designed from scratch, called the AT Protocol or ATProto. They’re hoping that this will let them build some things better than they are done in Mastodon, making the network less confusing, more useful and more user-friendly. Bluesky does not directly connect with Mastodon servers and apps, although there are some “bridges” being worked on.)&lt;/p&gt;

&lt;p&gt;After a research phase, in 2021 a team was chosen to build the platform and the Bluesky company was formally created. A woman named Jay Graber, formerly a developer at Zcash, &lt;a href="https://www.theverge.com/2021/8/16/22627435/twitter-bluesky-lead-jay-graber-decentralized-social-web"&gt;was chosen as the CEO&lt;/a&gt;. Thankfully, Jay had the foresight at that point to insist that they&amp;rsquo;d set it up as an independent company, which was funded, but not controlled by Twitter. Had they not, it almost certainly would have been shut down last year after Elon&amp;rsquo;s Twitter takeover.&lt;/p&gt;

&lt;p&gt;The team has been working on designing and building the pieces of the system throughout 2022, and in February 2023 they&amp;rsquo;ve &lt;a href="https://techcrunch.com/2023/02/28/jack-dorsey-backed-twitter-alternative-bluesky-hits-the-app-store-as-an-invite-only-app/"&gt;launched&lt;/a&gt; a very early and rough beta and started slowly letting in some users who wanted to try it out and have signed up on a waitlist. However, the whole Elon thing happened in the meantime and that was a moment when everyone was looking for a Twitter alternative, so the interest has wildly exceeded expectations and they weren&amp;rsquo;t ready yet to take in everyone.&lt;/p&gt;

&lt;p&gt;Since then, the user base has been gradually growing, with people being let in from the waitlist and existing users inviting their friends using invite codes. Meanwhile, the team had to speed some things up to adapt to the new situation and has been working hard on adding the most important features, and building up the backend to allow for more and more traffic. Finally, almost a year later, in the first week of February &lt;a href="https://bsky.social/about/blog/02-06-2024-join-bluesky"&gt;Bluesky has opened up for registrations from everyone&lt;/a&gt;.&lt;/p&gt;

&lt;p id="company"&gt;&lt;/p&gt;

&lt;h2&gt;The company&lt;/h2&gt;

&lt;p&gt;Bluesky is still a fairly small team at the moment. The dev team is probably something like a dozen people altogether, and that’s for the frontend, backend, protocol, servers and so on. So they just can’t add new features as fast as they’d like to, but they’re doing what they can.&lt;/p&gt;

&lt;p&gt;The team members interact with people on the platform all the time, answering questions and just having fun in general. They’re also building almost everything in public &amp;ndash; &lt;a href="https://github.com/bluesky-social/"&gt;the source code&lt;/a&gt; of the app and servers is available on GitHub, so we can track in real time what they’re working on next, report bugs and sometimes submit code with some new features they can merge in.&lt;/p&gt;

&lt;p&gt;The company is set up as a “&lt;a href="https://en.wikipedia.org/wiki/Benefit_corporation"&gt;public benefit corporation&lt;/a&gt;”, which basically means (in my non-US layman understanding) that it is a business and it&amp;rsquo;s meant to make profit, but that profit is not it&amp;rsquo;s only and main goal. It can and should have other, more noble goals that benefit the public, as the term implies, in this case: creating a protocol for decentralized social apps that everyone can build on.&lt;/p&gt;

&lt;p&gt;At the moment, they don’t have a clear plan on how the company is going to make money on the platform &amp;ndash; the general idea is to &lt;a href="https://bsky.social/about/blog/7-05-2023-business-plan"&gt;build some extra paid services&lt;/a&gt; on top for users and developers. They said they don’t plan to ever add ads and they promise they &lt;a href="https://www.wired.com/story/bluesky-ceo-jay-graber-wont-enshittify-ads/"&gt;won’t “enshittify” the service&lt;/a&gt; in future. In any case, they’re explicitly building the network to be resilient even in the unlikely scenario that they themselves “turn evil” in the future &amp;ndash; the network is meant to be “billionaire-proof”, impossible to completely take over by one guy with too much money. To quote the &lt;a href="https://bsky.app/profile/pfrazee.com/post/3jypici6ihm2m"&gt;lead dev&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;&lt;p&gt;&amp;ldquo;Our culture doc includes the phrase “The company is a future adversary” to remind us that we won’t always be at the helm – or at our best – and that we should always give people a safe exit from our company. It’s weird at times to frame our priorities as protecting users from us, but that’s exactly what we’re trying to do.&lt;/p&gt;

&lt;p&gt;The Bluesky team is made up of users. None of us come from big tech companies. We all came together because we were frustrated by the experience of feeling helpless about how our online communities were being run. We don’t want to give that same feeling to other people now that we’re the builders.&lt;/p&gt;

&lt;p&gt;When we build an open protocol, we’re giving out the building blocks. We want to start from the premise that we’re not always right or best, that when we are right or best then it might not last, and that communities should be empowered to build away from us. Sometimes this can all feel very intangible and abstract, and for the average user the goal is to just feel like a good &amp;amp; usable network. But this is one big reason why we put all the Fancy Technology under the hood.&lt;/p&gt;&lt;/blockquote&gt;

&lt;p&gt;Also, contrary to what you might have heard, this isn’t “Jack Dorsey’s company”. Yes, he started it, but he isn’t running it, Jay Graber is. &lt;del&gt;Dorsey is still on the board with 1/3 of the vote, but&lt;/del&gt; He was very little involved in the project after they started building. He basically gathered a team, gave them a lot of money and let them do their thing.&lt;/p&gt;

&lt;p&gt;In fact, he deleted his account on the platform last summer, after he was booed off it by the users, and now he’s mostly hanging out on Nostr instead. In early May 2024, it was announced that &lt;a href="https://www.theverge.com/2024/5/5/24149543/jack-dorsey-gone-bluesky-board"&gt;Jack has left the board of directors&lt;/a&gt; too. He also &lt;a href="https://bsky.app/profile/jay.bsky.team/post/3krxdfy6koc22"&gt;doesn&amp;rsquo;t own any shares of Bluesky&lt;/a&gt;.&lt;/p&gt;

&lt;p id="apps"&gt;&lt;/p&gt;

&lt;h2&gt;Apps&lt;/h2&gt;

&lt;p&gt;Bluesky has an official mobile app for &lt;a href="https://apps.apple.com/us/app/bluesky-social/id6444370199"&gt;iOS&lt;/a&gt; and &lt;a href="https://play.google.com/store/apps/details?id=xyz.blueskyweb.app"&gt;Android&lt;/a&gt;. It’s written in React Native, so it doesn’t feel fully native in all places and some system integration features take a while to be added &amp;ndash; the reason is that they’ve started with a very small team at first (I&amp;nbsp;think initially just one guy did all the frontend), so the only way they could do it was to build for both platforms &amp;amp; the web from one codebase.&lt;/p&gt;

&lt;p&gt;At this point, after various improvements over the last months, the mobile app is mostly ok and gets better with every update, it’s also obviously the most feature-complete one. One issue is that it doesn’t support iPad yet.&lt;/p&gt;

&lt;p&gt;There is of course also a web interface, at &lt;a href="https://bsky.app"&gt;bsky.app&lt;/a&gt;, which is pretty good &amp;ndash; this is the main UI&amp;nbsp;that I&amp;nbsp;use Bluesky with. At the moment it’s mostly meant to be used while logged in, although some pages like posts/threads and profile views can now be viewed unauthenticated.&lt;/p&gt;

&lt;p&gt;But there is also a small but enthusiastic group of third party developers building various apps, tools and experimenting with the protocol. They’ve built several independent apps, libraries to access the API&amp;nbsp;in various languages, and they often manage to build various new features in their apps before the team gets around to doing that in official apps.&lt;/p&gt;

&lt;p&gt;Here&amp;rsquo;s a few of these apps (in various stages of development):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://graysky.app"&gt;Graysky&lt;/a&gt; &amp;ndash; a mobile app, probably the most advanced one (though its dev is working for Bluesky now)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://deck.blue"&gt;deck.blue&lt;/a&gt; &amp;ndash; a web-based “Tweetdeck” column UI, written in Flutter&lt;/li&gt;
&lt;li&gt;&lt;a href="https://skyfeed.app"&gt;SkyFeed&lt;/a&gt; &amp;ndash; a web app with a column UI&amp;nbsp;that also lets you build custom feeds&lt;/li&gt;
&lt;li&gt;&lt;a href="https://skeetdeck.pages.dev"&gt;Skeetdeck&lt;/a&gt; &amp;ndash; another web-based, more lightweight “Tweetdeck”&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apps.apple.com/us/app/skeets-for-bluesky/id6466340923"&gt;Skeets&lt;/a&gt; &amp;ndash; native iOS app with iPad support and some interesting features&lt;/li&gt;
&lt;li&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.gmail.mfnboer.skywalker"&gt;Skywalker&lt;/a&gt; &amp;ndash; a native app for Android&lt;/li&gt;
&lt;li&gt;&lt;a href="https://skychat.social"&gt;Skychat&lt;/a&gt; &amp;ndash; a webapp initially built to let people form a &amp;ldquo;chat&amp;rdquo; around a specific hashtag&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apps.apple.com/us/app/sora-for-mastodon-bluesky/id6450969760"&gt;Sora&lt;/a&gt; &amp;ndash; a new multi-network client for iOS&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Additionally, there is also a third party bridge service called &lt;a href="https://skybridge.fly.dev"&gt;SkyBridge&lt;/a&gt; that allows you to use Mastodon apps like Ivory to use Bluesky (although of course it only supports a subset of features).&lt;/p&gt;

&lt;p id="terms"&gt;&lt;/p&gt;

&lt;h2&gt;How are things called here?&lt;/h2&gt;

&lt;p&gt;The app generally uses “neutral” terms like “post”, “repost”, “feed”, “timeline” and so on. That said, pretty early on someone came up with with the word “skeet” for posts, for “sky + tweet”, and it stuck, despite (or likely because of) the &lt;a href="https://bsky.app/profile/jay.bsky.team/post/3juflvnb3d62u"&gt;team’s protests&lt;/a&gt;. So the term is used pretty commonly, though maybe a bit ironically, despite being a bit controversial because of its existing, other slang meaning (see Urban Dictionary)… By analogy, you can also “reskeet”, “subskeet” and so on.&lt;/p&gt;

&lt;p&gt;The timeline/home feed is also sometimes called a “skyline”.&lt;/p&gt;

&lt;p&gt;New users that have joined Bluesky recently are often called “newskies” &amp;ndash; there is even a &lt;a href="https://bsky.app/profile/did:plc:wzsilnxf24ehtmmc3gssy5bu/feed/newskies"&gt;Newskies feed&lt;/a&gt;, which includes every new user’s first post (and only their first post).&lt;/p&gt;

&lt;p&gt;On the opposite end, some folks sometimes jokingly call people with a long experience on the platform “Bluesky elders” (a reference to one post that was widely made fun of).&lt;/p&gt;

&lt;p&gt;A “&lt;a href="https://knowyourmeme.com/memes/events/hellthread-hellrope-bluesky"&gt;hellthread&lt;/a&gt;” is something that existed for some time in the spring of last year &amp;ndash; the initial implementation of notifications notified you of any reply somewhere below your post or comment, to an unlimited depth. People have started creating extremely long and nested threads, which notified everyone involved of any reply, and there was no way to opt out of it. A certain community has formed around these hellthreads, of people who hang out together and sometimes waged wars in them. Eventually, the notifications were fixed, but due to protests, the devs have kept an option to still create hellthreads in one place, hardcoded in the app code. This was finally removed a few months later.&lt;/p&gt;

&lt;p id="feeds"&gt;&lt;/p&gt;

&lt;h2&gt;Feeds &amp;amp; algorithms&lt;/h2&gt;

&lt;p&gt;Social networks generally include two kinds of feeds: a chronological one, showing all posts from the people you follow in order, and an algorithmic one, showing what the service thinks you will like (which often means though: what they think will bring them more profit). Centralized platforms generally push you towards an algorithmic feed and often make the chronological feed harder to reach (if at all). Mastodon on the other hand only includes chronological feeds and no algorithms.&lt;/p&gt;

&lt;p&gt;Bluesky has both &amp;ndash; and much, much more.&lt;/p&gt;

&lt;p&gt;The default “Following” feed is a classic chronological feed &lt;del&gt;although with a twist &amp;ndash; by default it shows you more replies from the people you follow than on Twitter or Mastodon&lt;/del&gt;. You can configure a lot of aspects of that feed in the settings (see the &lt;a href="#settings"&gt;Settings section&lt;/a&gt;), e.g. how many replies you want to see in it.&lt;/p&gt;

&lt;p&gt;The “Discover” feed is Bluesky’s main algorithmic feed &amp;ndash; it mixes some posts from the people you follow with some other posts that you might like. You can pick the option &amp;ldquo;Show more like this&amp;rdquo; or &amp;ldquo;Show less like this&amp;rdquo; from the menu on each post to give it some hints on what you like or don&amp;rsquo;t. The devs are constantly tweaking it and asking for feedback, so it should be getting better over time.&lt;/p&gt;

&lt;p&gt;But that’s just the tip of the iceberg. Bluesky has built a system where anyone with a server and some knowledge of coding can implement their own algorithmic feeds that they can share with everyone else. There are currently about 40 thousands of custom feeds (as of Feb 2024) made by the Bluesky community that you can add to your app. And more importantly, the &amp;ldquo;Following&amp;rdquo; and &amp;ldquo;Discover&amp;rdquo; feeds are just what you start with by default – you can set any of the thousands of other feeds as your default, and you can even remove the two built-in feeds if you don&amp;rsquo;t like them, and leave e.g. only the &amp;ldquo;&lt;a href="https://bsky.app/profile/did:plc:q6gjnaw2blty4crticxkmujt/feed/cv:cat"&gt;Cat Pics&lt;/a&gt;&amp;rdquo; custom feed as your only feed tab. Nothing here is forced on you.&lt;/p&gt;

&lt;p&gt;The way a feed works is that it basically reads all new posts on Bluesky from a giant stream and decides which of them to keep and how to arrange them (this can be a shared feed, same for everyone, or a personalized feed that looks different to each user). Most feeds match posts by keywords &amp;ndash; these are feeds on some specific topics like Linux, food, gardening, climate change, astronomy, and so on. They usually define some sets of words, phrases, hashtags, sometimes emojis, and include all posts that contain any of these, chronologically.&lt;/p&gt;

&lt;p&gt;There are also various “top posts of the week / all time” feeds, posts using AI&amp;nbsp;models to match some specific kinds of photos like pictures of cats, frogs, or moss, or personal algorithmic feeds that show you posts selected for you according to some specific idea: posts from your mutual follows, from people who follow you, posts with photos only, posts from your friends who post less than others, and so on. These are all feeds built by third party developers who just had an idea and implemented it, without having to register or apply anywhere or ask anyone for permission.&lt;/p&gt;

&lt;p&gt;There is also a very popular web tool called &lt;a href="https://skyfeed.app"&gt;SkyFeed&lt;/a&gt;, built by one German developer, which allows you to &lt;a href="https://docs.bsky.app/blog/feature-skyfeed"&gt;build some of the simpler feeds using a web form&lt;/a&gt;, without having to write code or host it yourself. This allowed a lot of people without programming knowledge to build their own feeds &amp;ndash; currently around 85% of all feeds are built in and hosted by SkyFeed.&lt;/p&gt;

&lt;p&gt;There is a feed search engine integrated in the official app, in the Feeds tab (the “Discover new feeds” section). You can also search for interesting feeds on these two external sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://goodfeeds.co"&gt;goodfeeds&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stats.skyfeed.me"&gt;SkyFeed Builder Feed Stats&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Goodfeeds also has a nice &lt;a href="https://goodfeeds.co/the-guide"&gt;guide to feeds&lt;/a&gt; that describes them in more detail, there is also &lt;a href="https://bsky.social/about/blog/7-27-2023-custom-feeds"&gt;a blog post on Bluesky&amp;rsquo;s blog&lt;/a&gt;.&lt;/p&gt;

&lt;p id="safety"&gt;&lt;/p&gt;

&lt;h2&gt;Safety &amp;amp; moderation&lt;/h2&gt;

&lt;p&gt;Like on most other networks, you can mute someone if you find them annoying, or you can block them if you find them &lt;em&gt;really&lt;/em&gt; annoying. &lt;del&gt;There is no way to mute words and phrases yet&lt;/del&gt; It&amp;rsquo;s also possible (added in February) to mute words, phrases or hashtags. There&amp;rsquo;s currently no way to mute something or someone for a specific period of time, but that will hopefully come at some point.&lt;/p&gt;

&lt;p&gt;(Note, the blocking mechanism here is pretty aggressive, in that it also hides all previous interactions between the two users *for everyone*; so don&amp;rsquo;t be surprised if someone blocks you and your reply or quote &amp;ldquo;disappears&amp;rdquo; &amp;ndash; it wasn&amp;rsquo;t deleted, just hidden.)&lt;/p&gt;

&lt;p&gt;You can also create whole “moderation lists” for muting or blocking some groups of people at once, which can be shared with others. This is meant to let various communities on Bluesky build their own “defences”, by collecting lists of people who are unpleasant or annoying in some way and letting others mute or block them all at once before they come across them.&lt;/p&gt;

&lt;p&gt;Bluesky itself seems to be pretty light on moderation so far, which some people criticise them for. That said, I&amp;nbsp;think they generally get rid of obvious trolls pretty quickly, and I’ve personally only ever come across a few of those. They claim to have around 20 people in the moderation team right now (as of February), which is about half the total company size.&lt;/p&gt;

&lt;p&gt;The general high-level plan for moderation at Bluesky and on the AT Protocol is something they call “&lt;em&gt;&lt;a href="https://bsky.social/about/blog/4-13-2023-moderation"&gt;composable moderation&lt;/a&gt;&lt;/em&gt;” (old blog post) or “&lt;em&gt;&lt;a href="https://bsky.social/about/blog/03-12-2024-stackable-moderation"&gt;stackable moderation&lt;/a&gt;&lt;/em&gt;” (recent post). It’s an idea that moderation will have many layers &amp;ndash; from server operators including the Bluesky company, through various tools and services provided by other companies, organizations, and communities, ending with some ways to privately personalize your experience according to your personal needs.&lt;/p&gt;

&lt;h3&gt;Labellers&lt;/h3&gt;

&lt;p&gt;A core part of this is a feature called “&lt;em&gt;labellers&lt;/em&gt;” that they&amp;rsquo;ve released in March, which are basically third-party moderation services. They work by assigning a set of labels/tags (manually or automatically) to accounts and posts – this can be because one of the labeller&amp;rsquo;s moderators has come across an offending post, or because it was detected automatically using some custom software, or because the post or account was reported to the service by a user (users can send moderation reports to any set of these services).&lt;/p&gt;

&lt;p&gt;All users on the platform can &amp;ldquo;subscribe&amp;rdquo; to one or more of these services and configure how they want these labels to affect their experience: for any label type, they can choose if the user/post marked with such label should be hidden from their view, just marked with a label, or if this kind of label should be ignored. (A labeller doesn&amp;rsquo;t have the full power of a built-in platform moderation in that it can&amp;rsquo;t just ban someone from the site and delete their account – but they can make someone &lt;em&gt;effectively&lt;/em&gt; disappear for those users who trust and agree with the given service.)&lt;/p&gt;

&lt;p&gt;Labellers are usually specialized in some area: they could be protecting their users from things such as racism, antisemitism, or homophobia; they could be automatically detecting some unwanted behaviors like following a huge number of people quickly; marking some specific types of accounts like new accounts without an avatar, or accounts from a different network; fighting disinformation or political extremism; or they could be serving a community using a specific language or from a specific country.&lt;/p&gt;

&lt;p&gt;A simple labeller can be run by one person, but the bigger ones are managed by a whole group of people that collaborate on processing the reports. This system allows different communities to handle moderation in their own way independently, to make their members feel safer and have a better experience in the aspects that are important for them. And most importantly, different communities could often have somewhat conflicting or even completely opposing views on some things – and Bluesky as a company doesn&amp;rsquo;t have to try to satisfy everyone (which is impossible) or always pick a side. They also don&amp;rsquo;t necessarily have to specialize in every country, language and culture on Earth. Of course they reserve the right to take down some accounts completely, because some things and some people have to removed from the platform for everyone (e.g. things that are just illegal), but in less serious or less clear cases, they can just use labels or defer to other labellers (the Bluesky built-in moderation is now &amp;ldquo;just&amp;rdquo; another labeller among many others, using the same API, and with only &lt;em&gt;some&lt;/em&gt; special powers).&lt;/p&gt;

&lt;p&gt;You can read more on this topic in the guide titled
“&lt;a href="https://web.archive.org/web/20240620103516/https://from-over-the-horizon.ghost.io/bluesky-crash-course-labelers/"&gt;Bluesky Crash Course: Labelers&lt;/a&gt;” written by Kairi, who used to run one of the most popular labellers called Aegis (now defunct).&lt;/p&gt;

&lt;p&gt;There&amp;rsquo;s no easy way to search for labellers in the app yet, but I&amp;rsquo;m keeping a rough list myself on &lt;a href="https://blue.mackuba.eu/labellers/"&gt;this page&lt;/a&gt;. I&amp;nbsp;also made a “&lt;a href="https://blue.mackuba.eu/scanner/"&gt;Label Scanner&lt;/a&gt;” tool where you can find all labels assigned to a given account from any labeller.&lt;/p&gt;

&lt;p id="privacy"&gt;&lt;/p&gt;

&lt;h2&gt;Privacy&lt;/h2&gt;

&lt;p&gt;One important thing from the privacy aspect that may not be obvious at first, which you need to be aware of: the underlying protocol on which Bluesky runs is &lt;em&gt;extremely&lt;/em&gt; open. Anyone who knows how to code can write an app or tool that can read practically any data about anyone, without having to ask anyone for permission (since there’s no central authority that can require registration and payment to get API&amp;nbsp;keys like on Twitter). This is by design, because all the different pieces of the network that make it work, apps, tools and services, need to be able to access the data to provide their functionality, and we want everyone to be able to build those to keep the network decentralized and not controlled by one corporation.&lt;/p&gt;

&lt;p&gt;This has both advantages and disadvantages. For a developer, it means that the only limit is your time and imagination (and maybe API&amp;nbsp;rate limits). You can build feeds that show &lt;a href="https://bsky.app/profile/did:plc:q6gjnaw2blty4crticxkmujt/feed/cv:cat"&gt;all posts with cat photos&lt;/a&gt;, a bot that responds to the text “/honk” with a &lt;a href="https://bsky.app/profile/intern.goose.art/post/3kdojng5j7q2i"&gt;random photo of a goose&lt;/a&gt;, implement &lt;a href="https://bsky.app/profile/skeetsapp.com/post/3kaihxsmrxq2c"&gt;some new features&lt;/a&gt; before the Bluesky team gets around to that, count the statistics of how the &lt;a href="https://bsky.app/profile/jaz.bsky.social/post/3kktq7i2noz26"&gt;percentage of languages used in posts&lt;/a&gt; has changed over time, and whatever else you can think of. You don’t have any monthly quotas or paid plans.&lt;/p&gt;

&lt;p&gt;For a user though, this means that everything you do is very public &amp;ndash; kind of like it was on Twitter, just more so, because there are fewer restrictions.&lt;/p&gt;

&lt;p&gt;Specifically, all of these are &lt;strong&gt;publicly accessible&lt;/strong&gt; (even if they aren&amp;rsquo;t all displayed in the official app):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your posts&lt;/li&gt;
&lt;li&gt;your likes&lt;/li&gt;
&lt;li&gt;the photos you’ve attached to posts&lt;/li&gt;
&lt;li&gt;all the handles you&amp;rsquo;ve previously used (you can&amp;rsquo;t delete those)&lt;/li&gt;
&lt;li&gt;the list of people you follow&lt;/li&gt;
&lt;li&gt;the list of people you block (!)&lt;/li&gt;
&lt;li&gt;the user lists and moderation lists (mute/block lists) that you’ve created&lt;/li&gt;
&lt;li&gt;which moderation lists (yours or others’) you are blocking people with&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;And these things are &lt;strong&gt;private&lt;/strong&gt; and known only to you (and the apps and tools that you’ve explicitly granted access to your account):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the people you’re muting (individually or through lists)&lt;/li&gt;
&lt;li&gt;the words, phrases, hashtags etc. that you&amp;rsquo;re muting&lt;/li&gt;
&lt;li&gt;your selected languages and other preferences&lt;/li&gt;
&lt;li&gt;your email address, birthday and phone number&lt;/li&gt;
&lt;li&gt;who invited you and who you have invited&lt;/li&gt;
&lt;li&gt;the moderation services you&amp;rsquo;re subscribed to&lt;/li&gt;
&lt;li&gt;the custom feeds that you’ve saved or pinned

&lt;ul&gt;
&lt;li&gt;one caveat though: the provider of the feed knows when you are opening it&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;DMs are private between you and the person/people you&amp;rsquo;re chatting with, but they&amp;rsquo;re &lt;strong&gt;not&lt;/strong&gt; currently end-to-end encrypted, so the Bluesky team can theoretically access them. A fully encrypted version will come later.&lt;/p&gt;

&lt;p&gt;The difference between muting and blocking is because muting is simply a filter applied only for you &amp;ndash; nobody else needs to know that you’ve asked your app to hide some of the posts. On the other hand, blocking is inherently a two-way thing &amp;ndash; that other person, their app/server and any other pieces of the network that process their posts need to be aware of the block, so that they can prevent them from interacting with you. So the fact that the block exists needs to be publicly known.&lt;/p&gt;

&lt;p&gt;Now, what all of this can mean in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;anyone can download anyone’s posts and do various targeted or global analysis on it, track your likes and contact graph and so on&lt;/li&gt;
&lt;li&gt;if you are posting personal photos, especially NSFW photos or photos that can be geolocated, anyone can be downloading all of them automatically (though the official apps strip metadata from photos)&lt;/li&gt;
&lt;li&gt;some (any) companies can potentially train some kind of AI&amp;nbsp;models on the data (speaking purely about technical possibility, not legality of course)&lt;/li&gt;
&lt;li&gt;blocking someone only adds friction, but it can’t completely prevent them from seeing your posts (same as it always was on Twitter until recent changes, since you could always open a post in an “incognito” window)&lt;/li&gt;
&lt;li&gt;there is no way to add a feature that would let you “lock” a profile for followers only, hiding your content from others, because all post data has to be public for the network to work&lt;/li&gt;
&lt;li&gt;copies of any posts you delete might still remain on some third party servers&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Some of this might sound scary, but most of this is or was always the case on other social networks too, those that have APIs at least &amp;ndash; if you’re posting something publicly anywhere, you need to realize that anyone can record it forever. The main difference is that it’s &lt;em&gt;easier&lt;/em&gt; to do it here, because the API&amp;nbsp;has fewer restrictions than on centralized platforms, and that it’s currently not possible to do any semi-private content that’s visible to some people but not to others.&lt;/p&gt;

&lt;p id="security"&gt;&lt;/p&gt;

&lt;h2&gt;Account security&lt;/h2&gt;

&lt;p&gt;For logging in to third party apps and tools, there is currently a temporary system of &amp;ldquo;app passwords&amp;rdquo; in place. It will be replaced with something more robust (OAuth) soon.&lt;/p&gt;

&lt;p&gt;At the moment, when you log in to an app other than the official one, you should use a special one-time password that you can generate in the official app (Settings / App Passwords). Those passwords look something like this: &lt;code&gt;abcd-ef56-vxyz-qq34&lt;/code&gt;. You can generate a separate password for every tool and app that you log into.&lt;/p&gt;

&lt;p&gt;An app password grants &lt;em&gt;almost&lt;/em&gt; the same privileges as the main password, but without a few critical ones, and you can revoke it at any time from the Settings screen if you&amp;rsquo;re not using that app anymore, which disables access to your account for that app. You can also specify if the app password should give the app access to your DMs or not.&lt;/p&gt;

&lt;p&gt;There is now a simple &lt;a href="https://bsky.app/profile/bsky.app/post/3kqxv5yeof32a"&gt;email-based Two-Factor Authentication (2FA)&lt;/a&gt; feature, added recently. A more complete 2FA system is coming soon.&lt;/p&gt;

&lt;p id="handles"&gt;&lt;/p&gt;

&lt;h2&gt;Handles &amp;amp; IDs&lt;/h2&gt;

&lt;p&gt;Bluesky has a really cool system of handles. They couldn’t have just used single-element handles like “&lt;a href="https://twitter.com/donaldtusk"&gt;@donaldtusk&lt;/a&gt;”, because that wouldn’t really make sense in a decentralized system. They also didn’t want to bind your account permanently to the name of the server you’re on like in Mastodon, where you’re e.g. “&lt;a href="https://tapbots.social/@ivory"&gt;ivory@tapbots.social&lt;/a&gt;” and you can’t change that unless you make a new account.&lt;/p&gt;

&lt;p&gt;So here’s what they’ve come up with: internally, your account is identified by a unique identifier called “DID” (Decentralized Identifier), which is an &lt;a href="https://pht.kpherox.dev/did/did:plc:oio4hkxaop4ao4wz2pp3f4cr"&gt;ugly string of random letters&lt;/a&gt;. To that DID you assign a handle, which you can switch at any moment to a different one, and any contacts, references and connections will (mostly) stay intact; and the handle is actually just any domain name, usually displayed with an “@“ at the beginning, but without any additional username before it.&lt;/p&gt;

&lt;p&gt;By default, when you first join Bluesky (the current official server) you’re given a handle which is a subdomain of bsky.social, e.g. &amp;ldquo;&lt;a href="https://dril.bsky.social"&gt;dril.bsky.social&lt;/a&gt;&amp;rdquo;. This is a real domain name, you can type it into the address bar of the browser and it will redirect you to your profile on Bluesky.&lt;/p&gt;

&lt;p&gt;But at any moment you can switch to a different handle by assigning any domain name that you own. It can a very short name like &lt;a href="https://bsky.app/profile/retr0.id"&gt;retr0.id&lt;/a&gt;, or something long with one of those quirky new TLDs like &lt;code&gt;.horse&lt;/code&gt; or &lt;code&gt;.computer&lt;/code&gt;, or it can even be a very official sounding domain like &lt;a href="https://bsky.app/profile/washingtonpost.com"&gt;washingtonpost.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There are two ways to assign a domain, either via HTTP by putting a file in a specific place on the website hosted on the domain, or via DNS by putting a new entry in your domain configuration &amp;ndash; &lt;a href="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"&gt;the complete instructions are here&lt;/a&gt;. (BTW, Bluesky also runs a service that resells and automatically configures domains through a &lt;a href="https://bsky.social/about/blog/7-05-2023-namecheap"&gt;partnership with Namecheap&lt;/a&gt;, which is the first of possibly many premium services that they want to actually make money on.)&lt;/p&gt;

&lt;p&gt;This also means that you don’t need to rush to “reserve your handle” at ***.bsky.social &amp;ndash; because custom handles are cooler anyway 😎 (although be aware that if you change your handle from ***.bsky.social to a custom domain, that old handle becomes available for others again).&lt;/p&gt;

&lt;p&gt;This system also makes it much easier to move your account to a different server &amp;ndash; you can take your content and connections with you and keep your existing identity, because your DID identifier never changes.&lt;/p&gt;

&lt;p id="federation"&gt;&lt;/p&gt;

&lt;h2&gt;What is this federation thing?&lt;/h2&gt;

&lt;p&gt;The goal of Bluesky is to be a network that consists of many, many servers run by a lot of different companies, organizations and people that connect with each other &amp;ndash; that’s roughly what federation means. Initially, all the critical pieces were controlled by Bluesky PBC; however, this is now starting to change. The team has been working hard preparing everything needed for the launch for the last few months (which is partly why some of the more user-facing features had to wait), and now they&amp;rsquo;ve taken a big step towards federation by &lt;a href="https://bsky.social/about/blog/02-22-2024-open-social-web"&gt;letting people migrate their accounts to self-hosted servers&lt;/a&gt;. This will be a gradual, controlled rollout, so that they have a chance to test how things are working, but the network is really starting to open up now.&lt;/p&gt;

&lt;p&gt;If you’re worried that things will get more complicated, maybe you have some bad experiences from Mastodon &amp;ndash; then don’t worry. They’ve specifically designed everything to be less confusing. The accounts on the platform have actually already been spread out on a number of separate servers for a while now (though all controlled by Bluesky) &amp;ndash; they’ve been migrated sometime in November, and few people have noticed, because everything just kept working. Now, you will have an option to move your account to a server controlled by someone else, if you want to &amp;ndash; but you’ll be able to just ignore the whole thing if you don’t care about it.&lt;/p&gt;

&lt;p&gt;Note, Bluesky opening to federation does not mean that it will connect with Mastodon servers, since they use different, incompatible protocols. There is however a &lt;a href="https://snarfed.org/2024-05-04_52915"&gt;third party &amp;ldquo;bridge&amp;rdquo; called Bridgy&lt;/a&gt; that has started operating recently. The way it works is that it &amp;ldquo;mirrors&amp;rdquo; Mastodon accounts and their posts to Bluesky or Bluesky accounts to Mastodon (for accounts that have enabled the bridge). It&amp;rsquo;s possible to create whole threads where some replies are made by Mastodon accounts and some by Bluesky accounts, all of them being visible in both places.&lt;/p&gt;

&lt;p id="search"&gt;&lt;/p&gt;

&lt;h2&gt;Search&lt;/h2&gt;

&lt;p&gt;It took a while, but search works pretty well now on Bluesky. You can search for words, phrases and hashtags, and sort by popular posts or by latest. This is a global search like on Twitter, so it finds posts from everyone everywhere, not like the limited full text search on Mastodon.&lt;/p&gt;

&lt;p&gt;You can also add “from:some.handle” to find only posts from a given user, or “from:me” to search within your own posts. There are several more filters available, like filtering by date or by language, although there&amp;rsquo;s no easy UI&amp;nbsp;for them yet – but the Bluesky team has posted a tutorial &amp;ldquo;&lt;a href="https://bsky.social/about/blog/05-31-2024-search"&gt;Tips and Tricks for Bluesky Search&lt;/a&gt;&amp;rdquo; on their blog recently.&lt;/p&gt;

&lt;h2&gt;Hashtags&lt;/h2&gt;

&lt;p&gt;Hashtags have been partially implemented since last autumn &amp;ndash; the protocol specifications were ready and the official app was actually encoding hashtag data into posts for a few months, but it wasn&amp;rsquo;t displaying them in the UI&amp;nbsp;(although that did work in some third party apps). Full support was finally added &lt;a href="https://bsky.app/profile/bsky.app/post/3kmjbfqrrbu2t"&gt;in February&lt;/a&gt;. When you click on a hashtag you have an option to search for all posts with this hashtag, only posts from the given person, or to mute that hashtag.&lt;/p&gt;

&lt;p&gt;One thing that&amp;rsquo;s defined in the protocol but not implemented yet is that hashtags will eventually have two forms: inline tags like on Twitter, and external tags which are shown below the post, which I&amp;nbsp;think is the way they work on Tumblr (Mastodon also recently started showing trailing hashtags at the end of a post as somewhat separated visually from the text). You will be able to use either or both in a post according to your preference, and they will be interchangeable, with both being returned in the same search.&lt;/p&gt;

&lt;p id="settings"&gt;&lt;/p&gt;

&lt;h2&gt;Settings&lt;/h2&gt;

&lt;p&gt;Some things that you may want to change in the settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;u&gt;Languages&lt;/u&gt; &amp;ndash; turn on all the languages that you understand or want to see; Bluesky generally hides posts in languages that you don’t have enabled from most places like your home timeline or feeds. For your own posts, you set a post&amp;rsquo;s language yourself in the compose post window &amp;ndash; you can switch between a few languages, and if you have the wrong one set when writing, a popup with a warning should appear.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Thread Preferences&lt;/u&gt; &amp;ndash; you can choose to have threads sorted by newest, oldest or most liked comments; there is also an experimental nested (tree-like) thread view that I&amp;nbsp;highly recommend enabling (although for longer threads you may want to use my tool &lt;a href="https://blue.mackuba.eu/skythread/"&gt;Skythread&lt;/a&gt; instead&amp;nbsp;:]&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Following Feed Preferences&lt;/u&gt; &amp;ndash; choose if you want to see replies, quotes, reposts etc. in the home feed.
&lt;div&gt;For replies, you can set a threshold of how many likes a reply needs to get to show up in the feed, and you can also choose to see all replies made by people you follow &lt;em&gt;to anyone&lt;/em&gt; (this used to be the default, although now it&amp;rsquo;s turned off initially for new accounts). This option is actually pretty nice when you don’t follow many people at the beginning, because it’s a great way to find new people to follow &amp;ndash; but it may get too noisy when you follow a lot of active people.&lt;/div&gt;
&lt;div&gt;So you can set this according to your preferences &amp;ndash; if you’re just starting, you can turn on replies to everyone even with 0 likes, and if you want to trim your timeline a bit, set it to “followers only” to make it work like on Twitter, and/or increase the likes threshold.&lt;/div&gt;&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Chat Settings&lt;/u&gt; &amp;ndash; who you want to be able to contact you via DMs: everyone, no one, or only people that you follow yourself (default is people you follow); conversations you&amp;rsquo;ve started before are accessible even if you change the setting&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Moderation &amp;raquo; Content Filters&lt;/u&gt; &amp;ndash; here you can set what kind of potentially objectionable content you may want to show or hide &amp;ndash; generally various NSFW things. I’m not sure what the defaults are currently, but it’s worth checking and tweaking to your preferences. (Watch out, there can be quite a lot of somewhat NSFW content there that you can randomly come across in some feeds, although it’s generally hidden behind a content warning.)
&lt;div&gt;In the &amp;ldquo;Advanced&amp;rdquo; section below, you can adjust which of the moderation labels from each specific labeller you want to apply in your feeds – this includes the built-in &amp;ldquo;Bluesky Moderation Service&amp;rdquo; labeller.&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Accessibility &amp;raquo; Require alt text before posting&lt;/u&gt; &amp;ndash; you can turn this on to always be reminded to set an alt text on photos (it&amp;rsquo;s generally considered nice to add the alt text to images whenever possible, for people who use tools like screen readers or VoiceOver)&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Accessibility &amp;raquo; Disable autoplay for GIFs&lt;/u&gt; &amp;ndash; prevents gifs from automatically playing in the feed, instead you get a play button on each that you have to press first to see it&lt;/li&gt;
&lt;/ul&gt;


&lt;p id="missing"&gt;&lt;/p&gt;

&lt;h2&gt;Missing features&lt;/h2&gt;

&lt;p&gt;There are a few things missing on Bluesky that are available on some other networks. Some of these are limitations of the protocol, and some are just a matter of too much work and too few hands to do it &amp;ndash; the team is still pretty small and they have a ton of things to build to catch up with more mature platforms (Mastodon) or those with more people and funds (Threads), and they need to prioritize and some things are always put off.&lt;/p&gt;

&lt;p&gt;That said, in recent months they’ve started accepting outside contributions and some recent features have actually been submitted as &lt;a href="https://github.com/bluesky-social/social-app/pull/2504"&gt;PRs from external developers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Some things that are still missing:&lt;/p&gt;

&lt;h4&gt;Private profiles / circles&lt;/h4&gt;

&lt;p&gt;Like I’ve mentioned in the &lt;a href="#privacy"&gt;Privacy section&lt;/a&gt;, the AT Protocol as built currently requires all data apart from your private settings to be completely public. There is no way to make something that you only share with &lt;em&gt;some&lt;/em&gt; people but not others. There may very well be something like this in the future, because &lt;em&gt;a lot&lt;/em&gt; of people are asking for this and the team wants to look into it &amp;ndash; but they will have to first invent some other, separate way to share content in the protocol, which will take a lot of time and thinking. So it’s likely to come at some point, but not anytime soon, because it’s much harder than it may look.&lt;/p&gt;

&lt;h4&gt;DMs on the protocol&lt;/h4&gt;

&lt;p&gt;For the same reason, sharing messages with only one or a few people using the AT Protocol is currently not possible. The team wants to eventually add DMs to the protocol, but this is something that will require a lot of research first.&lt;/p&gt;

&lt;p&gt;But since *a lot* of people wanted to have &lt;em&gt;some&lt;/em&gt; way of talking privately with friends, even if it&amp;rsquo;s an imperfect one, the team has recently added a simple implementation of DMs that isn&amp;rsquo;t currently a part of the protocol. The DMs are currently using a single centralized service hosted by Bluesky (although third-party apps can access this API), and are not end-to-end encrypted. The first version also doesn&amp;rsquo;t support group chats and images, only 1-to-1 text chat – but more features are coming soon.&lt;/p&gt;

&lt;p&gt;Eventually, the team wants to figure out and add a more full-featured, decentralized and private version of DMs (which may involve integrating with some existing private messages standard). This is however pretty far down the list at the moment, so something not likely to come this year.&lt;/p&gt;

&lt;h4&gt;GIFs &amp;amp; video&lt;/h4&gt;

&lt;p&gt;Another pretty obvious missing thing is a way to upload gifs (with a hard “g”) and video.&lt;/p&gt;

&lt;p&gt;The problem with video is a lot higher bandwidth and storage use than with text and images, but more importantly, it’s also a lot more moderation work. There are all kinds of ugly things that people could upload in video form, and someone has to check it all if it’s reported… Gifs should generally be easier to do at first glance, but in practice they’re just another form of video, just shorter and without audio, so the above problems also apply.&lt;/p&gt;

&lt;p&gt;For now, they’ve added two partial solutions to this. The first one is a way to embed e.g. YouTube videos and gifs from Giphy and Tenor in posts, which will play inline inside a post when clicked. For Giphy gifs, you need to paste a direct link to the image, and for Tenor gifs, a link to the page. This whole feature was actually &lt;a href="https://github.com/bluesky-social/social-app/pull/2217"&gt;implemented by a third party developer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A more recent addition is a built-in support for adding Tenor gifs from the post compose dialog, by pressing the gif button and picking something from the gif search dialog. Here, only Tenor is supported. In both cases, they&amp;rsquo;re kind of offloading both the bandwidth/storage problem and the moderation problem to other services.&lt;/p&gt;

&lt;p&gt;That said, they&amp;rsquo;ve recently started working on full support for (short) video and uploadable gifs. It will probably take some time, but this should be available in near future.&lt;/p&gt;

&lt;h4&gt;Post editing&lt;/h4&gt;

&lt;p&gt;This will definitely be added at some point, but it’s also a relatively complex feature, because of the need to store the previous versions of an edited post that you should be able to access somehow. They want to do it right and that will require some thinking on how to solve the problem in the most general and elegant way.&lt;/p&gt;

&lt;h4&gt;Soft-blocking / removing followers&lt;/h4&gt;

&lt;p&gt;There is an issue currently that there is no way to remove someone from your followers except by having them blocked. If you block-and-unblock them, what some people call “soft blocking”, they stay on the followers list. This is a current limitation of how follows are designed in the protocol &amp;ndash; when someone follows you, they do it by adding a “follow record” to their account, and only they can update or delete their own records, you can’t do that from your side.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;think this is likely to get fixed at some point with some kind of workaround, but it’s not trivial to add and not high priority at the moment.&lt;/p&gt;

&lt;h4&gt;Polls&lt;/h4&gt;

&lt;p&gt;This might be a bit tricky, because we probably don’t want to have everyone’s poll choices public to everyone, and right now everything is public… But this is also one of those things that people ask about regularly, so I&amp;nbsp;hope they’ll figure something out.&lt;/p&gt;

&lt;h4&gt;Showing post quotes&lt;/h4&gt;

&lt;p&gt;Bluesky has had support for quote-posting like on Twitter since the beginning and quotes are widely used on the platform. However, there&amp;rsquo;s currently no way to see a list of all quotes of a given post (although you do get notified when someone quotes you).&lt;/p&gt;

&lt;p&gt;I&amp;rsquo;ve added a quote search to my tool &lt;a href="https://blue.mackuba.eu/skythread/"&gt;Skythread&lt;/a&gt; &amp;ndash; paste the link to the post into the search bar and then look for a &amp;ldquo;quotes&amp;rdquo; link below the post when it loads, and click it to see the &lt;a href="https://blue.mackuba.eu/skythread/?quotes=https://bsky.app/profile/bsky.app/post/3kktuiwidw22d"&gt;list of all quotes&lt;/a&gt;. Alternatively, there is also &lt;a href="https://bsky.app/profile/did:plc:wzsilnxf24ehtmmc3gssy5bu/feed/quotes"&gt;a feed of all quotes of all your posts&lt;/a&gt; made by @flicknow.xyz (but this only works for your posts).&lt;/p&gt;

&lt;h4&gt;Trends&lt;/h4&gt;

&lt;p&gt;It would be nice to have some kind of “currently trending” screen like on Twitter. I&amp;nbsp;think it would make Bluesky a better place for learning quickly about current events and hot topics, which was something that Twitter was always good at, and make it easier to find interesting things to read.&lt;/p&gt;

&lt;p&gt;It should be pretty easy to implement technically &amp;ndash; there’s already a bot that someone made at &lt;a href="https://bsky.app/profile/did:plc:mcb6n67plnrlx4lg35natk2b"&gt;@nowbreezing.ntw.app&lt;/a&gt;, which posts “tag clouds” of trending words &amp;amp; phrases every 10 minutes, other developers have also implemented it e.g. in &lt;a href="https://graysky.app"&gt;Graysky&lt;/a&gt;, &lt;a href="https://apps.apple.com/us/app/skeets-for-bluesky/id6466340923"&gt;Skeets&lt;/a&gt; or &lt;a href="https://skyfeed.app"&gt;SkyFeed&lt;/a&gt;. The main problem is probably in deciding (automatically) which trending words to promote and which should be hidden… it will probably require a lot of manual control at first to keep it safe.&lt;/p&gt;

&lt;h4&gt;Verification&lt;/h4&gt;

&lt;p&gt;It’s hard to implement verification in a decentralized network, but the good news is &amp;ndash; there’s no need to! The system of domain names in handles serves as a way of verifying accounts.&lt;/p&gt;

&lt;p&gt;If someone has a handle that matches the domain of e.g. some newspaper, organization or government branch that you recognize, you can assume that the account is operated by someone who was authorized by that entity. So e.g. &lt;a href="https://bsky.app/profile/wyden.senate.gov"&gt;@wyden.senate.gov&lt;/a&gt; is definitely US Senator Wyden (or at least his staff), and &lt;a href="https://bsky.app/profile/nytimes.com"&gt;@nytimes.com&lt;/a&gt; is definitely The New York Times. No one has to manually verify their documents to vouch for them.&lt;/p&gt;

&lt;p&gt;If you&amp;rsquo;re setting up an account for some well known organization, it&amp;rsquo;s highly recommended to switch to your domain as the handle from the start to make it clear that it&amp;rsquo;s an official account.&lt;/p&gt;

&lt;h4&gt;OAuth and full 2FA&lt;/h4&gt;

&lt;p&gt;Coming soon, see the &amp;ldquo;&lt;a href="#security"&gt;Account security&lt;/a&gt;&amp;rdquo; section.&lt;/p&gt;

&lt;h4&gt;Longer posts&lt;/h4&gt;

&lt;p&gt;This seems to have been a conscious decision that the team wanted to create a medium that’s more like Twitter than like Mastodon when it comes to post length (although Jack Dorsey apparently disagreed). This isn’t a simple matter of a field length, because this affects the way people communicate, and allowing longer posts has advantages and disadvantages, it’s just a different text form. (&lt;a href="https://news.ycombinator.com/item?id=39551004"&gt;See a comment from lead dev about this.&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;So it looks like this won’t be changing &amp;ndash; although there might later be other “apps” implemented on the AT Protocol that aren’t a part of Bluesky that will allow longer posts, even complete articles, and they might be somehow integrated into Bluesky in the future (e.g. posts bridged from Mastodon by Bridgy, which can be up to 500 characters long, include the full original content in the record data and apps may choose to display the full text inline).&lt;/p&gt;

&lt;h4&gt;Bookmarks&lt;/h4&gt;

&lt;p&gt;It&amp;rsquo;s on the list, but no timeline at the moment.&lt;/p&gt;

&lt;h4&gt;Links on the profile, pinned posts, scheduling, disabling reposts per user&amp;hellip;&lt;/h4&gt;

&lt;p&gt;I&amp;nbsp;haven’t heard much about those, but I’m assuming it’s all coming at some point once they get through the hard stuff they&amp;rsquo;re busy with now.&lt;/p&gt;

&lt;p id="tools"&gt;&lt;/p&gt;

&lt;h2&gt;Other tools&lt;/h2&gt;

&lt;p&gt;Finally, here are a few tools written by third party devs that you might find useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://firesky.tv"&gt;Firesky&lt;/a&gt; &amp;ndash; a site that shows you a live feed of every single new post made on Bluesky &amp;ndash; feels like watching the Matrix screen&lt;/li&gt;
&lt;li&gt;&lt;a href="https://clearsky.app/"&gt;Clearsky&lt;/a&gt; &amp;ndash; lets you look up the list of all people that are blocking you (or someone else), or the mute/block lists that you’ve been added to&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wolfgang.raios.xyz"&gt;wolfgang.raios.xyz&lt;/a&gt; &amp;ndash; also shows your blockers, block statistics and can generate “interaction circle” images that show who you mostly keep in touch with&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.jazco.dev/cleanup"&gt;Jaz&amp;rsquo;s Profile Cleaner&lt;/a&gt; &amp;ndash; lets you delete old data (old posts etc.) from your account&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pht.kpherox.dev"&gt;PLC handle tracker&lt;/a&gt; or &lt;a href="https://internect.info"&gt;internect.info&lt;/a&gt; &amp;ndash; these let you look up the DID of an account and how the assigned handle has changed in the past&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.jazco.dev/stats"&gt;Jaz’s post stats&lt;/a&gt; (and &lt;a href="https://blue.mackuba.eu/stats/"&gt;mine&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.jazco.dev/atlas"&gt;Atlas&lt;/a&gt;, a &amp;ldquo;bird&amp;rsquo;s eye view&amp;rdquo; map of Bluesky (warning, takes some time to load)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blue.mackuba.eu/skythread/"&gt;Skythread&lt;/a&gt; &amp;ndash; a thread reader by yours truly, mentioned earlier&amp;nbsp;:]&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blue.mackuba.eu/scanner/"&gt;Label Scanner&lt;/a&gt;, for checking if there are any labels assigned to an account or post&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;--&lt;/p&gt;

&lt;p&gt;And some other guides (some mentioned earlier in the blog post):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://web.archive.org/web/20240620103427/https://from-over-the-horizon.ghost.io/bluesky-social-new-user-guide/"&gt;Bluesky Social New User Guide&lt;/a&gt; (Kairi, Nov 2023 – archived)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://emilydoesastro.com/posts/230824-bluesky-signup/"&gt;How to get started on Bluesky&lt;/a&gt; (Emily Hunt, Nov 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/5-19-2023-user-faq"&gt;Bluesky user FAQ&lt;/a&gt; (Bluesky, May 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://write.as/y5kzn9moj6ohs30l.md"&gt;The Newskies' Guide to Safety and Privacy on Bluesky&lt;/a&gt; (eepy, Oct 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://web.archive.org/web/20240620103516/https://from-over-the-horizon.ghost.io/bluesky-crash-course-labelers/"&gt;Bluesky Crash Course: Labelers&lt;/a&gt; (Kairi, Apr 2024 – archived)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://goodfeeds.co/the-guide"&gt;The Guide to Feeds&lt;/a&gt; (Jerry Chen)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/7-27-2023-custom-feeds"&gt;Algorithmic Choice with Custom Feeds&lt;/a&gt; (Bluesky, Jul 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"&gt;How to set your domain as your handle&lt;/a&gt; (Bluesky, Apr 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/05-31-2024-search"&gt;Tips and Tricks for Bluesky Search&lt;/a&gt; (Bluesky, May 2024)&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

&lt;p&gt;Thanks to Mozzius, Shreyan and Marshal for the feedback on the first draft&amp;nbsp;:)&lt;/p&gt;

&lt;p id="changelog"&gt;&lt;/p&gt;

&lt;h3&gt;Changelog:&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;24 Jun 2024&lt;/strong&gt;: removed link to Aegis (rip)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;19 Jun 2024&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;added info about built-in Tenor GIFs and DMs&lt;/li&gt;
&lt;li&gt;added new section about labellers&lt;/li&gt;
&lt;li&gt;Jack Dorsey is no longer on the board&lt;/li&gt;
&lt;li&gt;updated some mentions about the Mastodon bridge, which is now live&lt;/li&gt;
&lt;li&gt;some changes to feeds section - defaults for Following have changed, Discover is now much better, and you can remove the default feeds&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;strong&gt;26 Mar 2024&lt;/strong&gt;: added mention about handle history in the Privacy section&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2 Mar 2024&lt;/strong&gt;: hashtags and word muting are now available, updated the part about longer posts&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;23 Feb 2024&lt;/strong&gt;: federation is live!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;21 Feb 2024&lt;/strong&gt;: search now returns results when you search for a hashtag.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2023/11/09/year-of-social-media-coding/</id>
    <title>2023: Year of social media coding</title>
    <published>2023-11-09T14:46:07Z</published>
    <updated>2023-11-09T14:46:07Z</updated>
    <link href="https://mackuba.eu/2023/11/09/year-of-social-media-coding/"/>
    <content type="html">&lt;p&gt;I&amp;nbsp;had different plans for this year… then, Elon Musk happened.&lt;/p&gt;

&lt;p&gt;Elon took over Twitter in October last year, which set many different processes in motion. A lot of people I&amp;nbsp;liked and followed started leaving the platform. Mastodon and the broader Fediverse, which has been slowly growing for many years but never got anything close to being mainstream, suddenly blew up with activity. A lot of those people I&amp;nbsp;was following ended up there.&lt;/p&gt;

&lt;p&gt;Then, Twitter started getting progressively worse under the new management. Elon&amp;rsquo;s antics, the whole blue checks / verification clusterfuck, killing off third party apps and effectively shutting down the API, locking the site behind a login wall, finally renaming the app and changing the logo – each step made some of the users lose interest in the platform, making it gradually less interesting and harder to use.&lt;/p&gt;

&lt;p&gt;Changes, so many changes… and things changing meant that I&amp;nbsp;had to change my workflows, change some plans, build a whole bunch of new tools, change plans a few times again, and so on. My GitHub looks like this right now, which is way above the average of previous years:&lt;/p&gt;
&lt;p class="image"&gt;&lt;img alt="GitHub green squares activity chart, pretty green" src="https://mackuba.eu/images/posts/social2023/github.png?1721484643" width="640"&gt;&lt;/p&gt;

&lt;p&gt;As usual, I&amp;nbsp;ended up writing way more Ruby and JavaScript than Swift, which goes a bit against my general career plans – but I’ve built so much stuff this year and I&amp;nbsp;had a ton of fun doing it. So in this blog post, I&amp;nbsp;wanted to share some of the things I’ve been working on lately.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;The Dead Bird Site 🦤&lt;/h2&gt;

&lt;p&gt;I&amp;nbsp;had a bunch of private tools written for the Twitter API. For example, I&amp;nbsp;had a script that downloaded all tweets from my timeline and some lists to a local database. I&amp;nbsp;was also running various statistics on tweets, e.g. which people contribute how much to the timeline and list feeds, and automatically extracted links from tweets from some selected lists.&lt;/p&gt;

&lt;p&gt;And then Elon shut off access to the API&amp;nbsp;(unless you can afford $100 per month for a &amp;ldquo;hobbyist&amp;rdquo; plan), which meant I&amp;nbsp;had to try to find other ways to get that data.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;quickly got the idea that I&amp;nbsp;could somehow intercept the JSON responses that the Twitter webapp (I&amp;nbsp;refuse to call it the new name, sue me) is loading from the JavaScript code. The JSON responses are very complicated with a lot of extra content, but they do contain everything I&amp;nbsp;need. The problem is how to get them; I&amp;nbsp;wanted to get data from my personal timelines, so I&amp;nbsp;couldn&amp;rsquo;t do anonymous requests, and I&amp;nbsp;didn&amp;rsquo;t want to make authenticated requests for my account from some hacked-together scripts, for fear of triggering some bot detection tripwire that would lock my account.&lt;/p&gt;

&lt;p&gt;So the approach I&amp;nbsp;settled on was to passively collect the requests in the browser, using Safari&amp;rsquo;s Web Inspector, and export them to a HAR file that can be parsed and processed like the data from the public API. (It would be even better to have a browser extension that intercepts XHR calls on twitter.com automatically, but as far I&amp;nbsp;can tell, there is no way for request monitoring extensions to look at the &lt;em&gt;content&lt;/em&gt; of responses, unless you inject scripts to the site.)&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/social2023/safari_graphql.png"&gt;&lt;img alt="Network tab in Safari Web Inspector, showing requests to HomeTimeline endpoint" src="https://mackuba.eu/images/posts/social2023/safari_graphql.png?1721484643" width="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;initially tried to implement it as a &lt;a href="https://github.com/mackuba/BirdLog"&gt;Mac app&lt;/a&gt;, which gave me a chance to start experimenting with Core Data a&amp;nbsp;bit. But in the end, I&amp;nbsp;rewrote it in Ruby and released it as gem I&amp;nbsp;called “BadPigeon” – named after the friends who visit my balcony every day 🐦&lt;/p&gt;

&lt;p&gt;The gem is designed to output extracted data in the same form as the Twitter API, in a way that can be plugged into the popular &lt;a href="https://github.com/sferik/twitter-ruby"&gt;twitter gem&lt;/a&gt;, so I&amp;nbsp;could use all existing tools I&amp;nbsp;had written with very little changes. The obvious downside is that it needs some manual help with the recording first, but I&amp;nbsp;can live with that. I’ve been using this setup since June and it works pretty well for me so far.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;had one more Twitter-related project that I&amp;nbsp;sadly had to shut down though – the &lt;a href="https://twitter.com/rails_bot/"&gt;Rails Bot&lt;/a&gt; which has been running non-stop since 2013, mostly unattended, picking and retweeting tweets from some developers in the Ruby community. It requires access to the API&amp;nbsp;to fetch its home timeline periodically from crontab, so I&amp;nbsp;couldn’t make it work this way.&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/bad_pigeon" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;bad_pigeon&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;A tool for extracting tweet data from GraphQL requests made by the Twitter website 🐦&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;hr /&gt;

&lt;h2&gt;Mastodon’t 🦣&lt;/h2&gt;

&lt;p&gt;As the migration of developer communities out of Twitter started, I&amp;nbsp;was initially &lt;a href="/2022/12/22/social-media-update/"&gt;skeptical&lt;/a&gt;; looking back, I&amp;nbsp;guess I&amp;nbsp;just had to go through the &amp;ldquo;five stages of grief&amp;rdquo; at my own pace… I&amp;nbsp;also didn’t initially see the change as &lt;em&gt;that&lt;/em&gt; bad as some others did, and to be honest I&amp;nbsp;still don’t – to me, Twitter still isn’t literal hell on Earth, it’s just that month after month, it got progressively less useful, less interesting and more annoying.&lt;/p&gt;

&lt;p&gt;So I&amp;nbsp;finally started looking at Mastodon with interest. The idea of the “Fediverse”, a distributed system of many independent servers with a completely open API, where I&amp;nbsp;don’t need to pay absurd prices for an access key, don&amp;rsquo;t have monthly download limits and which can’t be taken over and locked down, was appealing to me.&lt;/p&gt;

&lt;p&gt;You see, I’m a bit crazy about data hoarding and processing information – for many years I’ve been having various ideas about tools I&amp;nbsp;could write to somehow automate finding more relevant content in the noise of social media, to let me waste less time on it while still finding what’s important (the Rails Bot was a very early example of that). So I&amp;nbsp;thought that maybe in this new open world, where the only limit is my imagination, I&amp;nbsp;could build any tools I&amp;nbsp;ever wanted and share them with others.&lt;/p&gt;

&lt;p&gt;Well, turns out it&amp;rsquo;s not that simple…&lt;/p&gt;

&lt;p&gt;It’s true that the Mastodon APIs are completely open and generally permissionless; for example, you can easily download any account’s &lt;a href="https://martianbase.net/api/v1/accounts/109927540589302167/statuses"&gt;complete history&lt;/a&gt; of “toots”, going as far as a few years back, anonymously. The problem is that there is a certain culture of the existing community of the Fediverse that was there way before the great migration, which is extremely against any kind of data collection, archiving and indexing. Making information searchable – information which is broadcasted in the open to the world – is seen as a threat to safety, and anyone who attempts that is labelled a “tech bro”, derided and attacked.&lt;/p&gt;

&lt;p&gt;Sometime in winter I&amp;nbsp;went down the rabbit hole of many, many threads discussing several of such tools, with the authors being attacked and told that they shouldn’t have built them. Just by mentioning in one of the threads that I’m thinking about building a Mac app that allows you to search the history of your home timeline, I&amp;nbsp;got called out on &amp;ldquo;#fediblock&amp;rdquo; (Fediverse&amp;rsquo;s popular channel for warning about bad actors) as someone worthy of blocking.&lt;/p&gt;

&lt;p&gt;All of this has very quickly cured me of any ideas to build pretty much any public tool for the Mastodon API. I&amp;nbsp;just don&amp;rsquo;t have the energy and mental strength to deal with people attacking me this way for simply building tools on an open API&amp;nbsp;that they don&amp;rsquo;t like.&lt;/p&gt;

&lt;p&gt;What I&amp;nbsp;ended up doing though was setting up my own personal Mastodon instance, &lt;a href="https://martianbase.net"&gt;martianbase.net&lt;/a&gt;. I&amp;nbsp;joked that I&amp;rsquo;m probably the only person who hates Mastodon and also has their own Mastodon instance&amp;hellip; But the first instance I&amp;nbsp;signed up on last year was shut down unexpectedly, giving me no chance to migrate the account. That’s another thing I&amp;nbsp;dislike about the ActivityPub system – your account identity and data is bound to the domain of your instance, and there is no easy way out if your admin misbehaves or disappears, or just has a different view on which other servers you should be able to talk to. So at that point I&amp;nbsp;decided not to trust another instance admin, but to set up my own place, so that I&amp;nbsp;can have full control over it.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Blue skies ahead 🌤&lt;/h2&gt;

&lt;p&gt;And then, just as Twitter was slowly going down and Mastodon has disappointed me – I&amp;nbsp;started hearing about &lt;a href="https://blueskyweb.xyz"&gt;Bluesky&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Started as an idea of Jack Dorsey from Twitter &lt;a href="https://www.cnbc.com/2019/12/11/twitter-ceo-jack-dorsey-announces-bluesky-social-media-standards-push.html"&gt;back in 2019&lt;/a&gt;, with a goal of building a “decentralized Twitter” that Twitter itself could possibly one day be a part of, the project has been going on for a few years, and just as the whole Twitter chaos started, Bluesky got to the point where it could be presented to the world.&lt;/p&gt;

&lt;p&gt;(Important note here, since media has widely promoted Bluesky as “Jack’s social network” and his name puts a lot of people off: it’s not in fact Jack’s social network. He’s not the CEO (a woman named &lt;a href="https://www.forbes.com/sites/digital-assets/2023/04/25/twitter-hatchling-bluesky-emerges-from-its-shell/"&gt;Jay Graber&lt;/a&gt; is), he does not manage or control the company, and AFAIK he’s actually been very little involved in it recently, having mostly switched his interest to &lt;a href="https://nostr.com"&gt;Nostr&lt;/a&gt; – to the point that he has even deleted his Bluesky profile.)&lt;/p&gt;

&lt;p&gt;The attention and interest that Bluesky has received after lauching an invite-only beta has widely exceeded the team’s expectations, but this was both a blessing and a curse. They weren’t really prepared to run a real Twitter competitor that could accept the “refugees” escaping Elon’s playground. The thing is, they were mostly focused on the underlying protocol before, and the site itself has been launched as a bit of a demo. A lot of things that people consider pretty essential in a social networking site weren’t ready. But the team – which at that point was less than 10 developers in total, AFAIK – started adapting to the new reality, working as hard as they could to make the site usable for much larger crowds that they had been planning to.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;got access to Bluesky in late April. I&amp;nbsp;don’t want to get into too much detail here about what it’s like, how it’s evolved since then and so on – I’m going to write a few more blog posts about Bluesky specifically. But long story short, I&amp;nbsp;was completely hooked from day one.&lt;/p&gt;

&lt;p&gt;Yes, it’s invite-only, has a much smaller userbase than the Fediverse, it’s an early beta, it doesn’t have videos, gifs or even hashtags. The iOS/Swift developers there are as rare as a unicorn. But it has a really nice community of users, third party developers who hack on various tools and help each other, and team members who interact with us on the site all the time. It looks and feels more like Twitter than Mastodon does, and it somehow just feels more fun to be on.&lt;/p&gt;

&lt;p&gt;But the thing that excites me the most is the &lt;a href="https://atproto.com"&gt;AT Protocol&lt;/a&gt; it’s built on and its potential. It’s a completely open federated protocol, just like ActivityPub that Mastodon uses, and it’s intended to eventually create another “fediverse” of distributed social apps (though the federation part is not live yet, but coming soon). It’s designed to take some lessons from what doesn’t work well in ActivityPub (and would be hard to change) and design the architecture better. For example, it uses &amp;ldquo;Decentralized IDs&amp;rdquo; (DID) independent of the hosting server to identify accounts, which makes it easy to migrate accounts between servers (and your handle can be any domain name you own, like &lt;a href="https://bsky.app/profile/mackuba.eu"&gt;@mackuba.eu&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The code it’s running on is &lt;a href="https://github.com/bluesky-social/"&gt;open source&lt;/a&gt;, the APIs are completely open, and it all just invites you to write some tools and libraries for it – to be the first person to write a Ruby library, a Swift SDK, a command line client, a website with statistics. To be the first to plant a flag where others will come later.&lt;/p&gt;

&lt;p&gt;I’ve been spending most of the time since April working on one Bluesky-related project after another, sometimes switching between a few in parallel. In the rest of this blog post, I&amp;nbsp;wanted to show you some of the things I’ve been busy building:&lt;/p&gt;

&lt;h3&gt;Minisky&lt;/h3&gt;

&lt;p&gt;On the first day after I&amp;nbsp;got in, I&amp;nbsp;already started digging in the API&amp;nbsp;and I&amp;nbsp;wrote a small &lt;a href="https://gist.github.com/mackuba/ddcb225ae4e6cf08e0e0396b3f6a2f6d"&gt;Ruby script&lt;/a&gt; for archiving my timeline and likes (of course I&amp;nbsp;did…). This eventually evolved into a Ruby gem I&amp;nbsp;called Minisky, which provides a minimalistic API&amp;nbsp;client that handles logging in, refreshing access tokens, making GET and POST requests to the API&amp;nbsp;and returning parsed JSON responses.&lt;/p&gt;

&lt;p&gt;It doesn’t include any higher-level features like “get posts”, you have to know the name of the endpoint, what params to pass and what fields it returns, but it handles all the basic boilerplate for you. I&amp;nbsp;use it as a base for some internal scripts, and for manually getting or sending some data to the API&amp;nbsp;in the Ruby console. If you want to start playing with the Bluesky API&amp;nbsp;or build some more specific tool that uses it, you can give this library a try (see the &lt;a href="https://github.com/mackuba/minisky/tree/master/example"&gt;example&lt;/a&gt; folder for some ideas). It has no dependencies apart from Ruby stdlib.&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/minisky" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;minisky&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;A minimal client of Bluesky/AtProto API&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;h3&gt;Custom feeds on Bluesky&lt;/h3&gt;

&lt;p&gt;Bluesky has a really cool feature that I&amp;nbsp;think is pretty unique among all the social networks. On social sites, you normally have either a reverse-chronological timeline of posts from the people you follow, or some kind of algorithmic “home” feed that mixes them up with other suggested posts, in a way that you usually don’t fully understand and may not like (or both of these feeds).&lt;/p&gt;

&lt;p&gt;Bluesky has both of these, but it also lets &lt;em&gt;anyone&lt;/em&gt; build a custom feed that selects and orders posts however you like, and most importantly, lets you make this feed available to everyone else. &lt;a href="https://blueskyweb.xyz/blog/7-27-2023-custom-feeds"&gt;Custom feeds&lt;/a&gt; are a core feature of the app; it lets you browse popular feeds from other people, feeds are listed in a separate tab on the feed author’s profile, and you can “pin” the feeds you use often, which puts them in the top bar in the mobile app, as if it was another built-in timeline. People build all kinds of feeds – thematic feeds like various scientific or art or NSFW feeds, feeds for specific communities like &amp;ldquo;Blacksky&amp;rdquo;, general “top posts this week” feeds, or different variations of an algorithmic “home feed” using various approaches.&lt;/p&gt;

&lt;p&gt;The way the feeds work is that you need to provide an HTTP service on your server which implements a couple of endpoints. The Bluesky server then makes a request to your service on user’s behalf when they want to view the feed, and your service should respond with a JSON that includes a list of post URIs. Bluesky then takes these URIs and turns them into full post JSONs that it returns to the client.&lt;/p&gt;

&lt;p&gt;When the team launched this feature back in May, they included a sample &lt;a href="https://github.com/bluesky-social/feed-generator/"&gt;feed service project&lt;/a&gt; implemented in TypeScript. But I’m not a big fan of JS/TS and Node, so of course I&amp;nbsp;had to reimplement it all in Ruby&amp;nbsp;:]&lt;/p&gt;

&lt;p&gt;I’ve spent quite a lot of time working on the feeds and related code this summer, and the result of this is three separate Ruby projects that I’ve open sourced on GitHub (in addition to my main project which is private).&lt;/p&gt;

&lt;h3&gt;BlueFactory&lt;/h3&gt;

&lt;p&gt;The first part is an implementation of the feed service itself. I&amp;nbsp;based it on Sinatra, and it implements the three API&amp;nbsp;endpoints required from a feed service. You need to provide some configuration (hostname, DID of the owner etc.) and your custom class to call back to in order to get the list of post URIs and the feed metadata. If you want, you can further customize the server using the Sinatra API, e.g. adding some custom routes with HTML content.&lt;/p&gt;

&lt;p&gt;Feeds can generally be divided into two categories: general and thematic feeds that return the same content for everyone, and personalized feeds that show the feed from a specific user’s perspective. The latter are usually much more complicated to build, since you will often need much more data of different kinds to generate the response, depending on your algorithm. If you want to build a personalized feed, the request includes a &lt;a href="https://jwt.io"&gt;JWT token&lt;/a&gt; that you can use to get the requesting user’s DID, and the gem can pass that as a param to your class (although note that at the moment it does not verify the token, so it can be easily faked).&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/blue_factory" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;blue_factory&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;A simple Ruby server using Sinatra that serves Bluesky custom feeds&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;h3&gt;Skyfall&lt;/h3&gt;

&lt;p&gt;To return the post URIs from the feed service, first you need to get the posts from somewhere. You could possibly get them from the API, but realistically, a much better option is to connect to a so-called “firehose” web service and stream and save them as they are created, keeping a copy in a local database.&lt;/p&gt;

&lt;p&gt;The firehose streams every single thing happening on the network, live – every new and deleted post, follow, like, block, and so on. Depending on your specific feed idea, you will usually only need to keep a small fraction of this data, e.g. only posts and only those that match some regexps – but you need to parse it all first to know what to keep. What further complicates things is that the firehose data does not come in a JSON form, but instead uses a bunch of binary protocols originated from &lt;a href="https://ipld.io"&gt;IPLD&lt;/a&gt;/&lt;a href="https://ipfs.tech"&gt;IPFS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The second Ruby gem is meant to simplify this for you. It uses an existing &lt;a href="https://github.com/cabo/cbor-ruby/"&gt;CBOR&lt;/a&gt; library to do some of the binary protocol parsing and &lt;a href="https://github.com/faye/faye-websocket-ruby/"&gt;faye-websocket&lt;/a&gt; for the websocket connection. It connects to the firehose websocket on a given hostname and returns parsed message objects with the info about specific add/remove operations and relevant JSON records.&lt;/p&gt;

&lt;p&gt;The firehose (and the Skyfall gem) isn’t only useful for creating feed services – you could possibly use it for any other project that needs to track some kind of records from the network in real time, whether it’s follows (to create a &lt;a href="https://bsky.jazco.dev"&gt;connection graph&lt;/a&gt; of the whole network, or to track when a follower unfollows you), or blocks (to find out &lt;a href="https://bsky.thieflord.dev"&gt;who is blocking you&lt;/a&gt;), or to monitor when you or your company or project are mentioned by anyone anywhere. I’ve also included an &lt;a href="https://github.com/mackuba/skyfall/tree/master/example"&gt;examples&lt;/a&gt; folder with some sample scripts in the repo.&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/skyfall" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;skyfall&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;A Ruby gem for streaming data from the Bluesky/AtProto firehose&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;h3&gt;Bluesky feeds template&lt;/h3&gt;

&lt;p&gt;This project puts the previous two together and combines them into an example of a complete Bluesky feed service, which reads posts from the firehose, saves them to an SQLite database and serves them on a required endpoint – basically a reimplementation of the official TypeScript example in Ruby.&lt;/p&gt;

&lt;p&gt;This is a “template” repo, which means it’s not meant to be used as-is, but instead forked and modified in your own copy. The reason is there are simply too many things that you may want to do differently – deployment method, chosen database, specific data to keep etc., and making this all configurable would be an impossible task. Instead, I’ve extracted the “input” and “output” parts as separate gems that can be used directly, and you build the parts in the middle – but you can use this template project as a good starting point.&lt;/p&gt;

&lt;p&gt;My own feed service project is a private repo, but I’m keeping it in a similar structure to this template and I’m manually backporting some fixes and new features from time to time.&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/bluesky-feeds-rb" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;bluesky-feeds-rb&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;Template of a custom feed generator service for the Bluesky network in Ruby&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;h3&gt;My feeds&lt;/h3&gt;

&lt;p&gt;And now we get to the part that all of this was for – building my own custom feeds.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;mentioned earlier that I&amp;nbsp;often think about and experiment with various ways to find most relevant content to me on social media. So when I&amp;nbsp;heard about the custom feeds feature, I&amp;nbsp;immediately had an idea to build a feed for Mac/iOS developers that filters only posts on this topic, using a long list of keywords and regexps (I’ve actually reused a lot of work I’ve done a while ago for an unfinished thing I&amp;nbsp;played with on Twitter).&lt;/p&gt;

&lt;p&gt;It took me a couple of months to build all the pieces of the “feed generator”, but I’ve launched the Apple Dev feed in July. It isn’t very busy so far, to put it mildly, because there still aren’t that many iOS devs on Bluesky 😅 But as of today, it has 35 likes – only 50 likes less than the &lt;em&gt;other&lt;/em&gt; &lt;a href="https://bsky.app/profile/did:plc:kcevumnk4gjxyegqwbubpajo/feed/taylor-swift"&gt;Swift feed&lt;/a&gt;&amp;nbsp;:]&lt;/p&gt;

&lt;p&gt;Apart from the iOS dev feed, I’ve also made a more general macOS users feed and a couple of other feeds that were mostly a proof of concept / playground while building the service, but a lot of people seem to find them useful anyway, so I&amp;rsquo;ve left them running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/apple"&gt;iOS &amp;amp; Mac Developers feed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/mac"&gt;macOS users feed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/linux"&gt;Linux feed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/starwars"&gt;Star Wars feed&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/build"&gt;#buildinpublic feed&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;h3&gt;Skythread&lt;/h3&gt;

&lt;p&gt;The last one of my Bluesky-related projects, also with a “sky” in the name (most of the &lt;a href="https://atproto.com/community/projects"&gt;third party projects&lt;/a&gt; so far have either the word “blue” or “sky” as part of the name 😄). And this one is written in JavaScript for a change.&lt;/p&gt;

&lt;p&gt;If you use Twitter and/or Mastodon a lot, you probably have the experience of reading some complicated thread and getting lost, not knowing who replies to whom or if you haven’t missed a whole part of the discussion. These two display branching out threads a bit differently – Twitter hides some of the branches, while Mastodon shows all direct and indirect replies in one flat list. In both cases, it’s not a perfect solution for reading some heated “&lt;a href="https://knowyourmeme.com/memes/events/hellthread-hellrope-bluesky"&gt;hellthreads&lt;/a&gt;” that branch out endlessly. For me, a UI&amp;nbsp;more like the one on Reddit would be ideal. (Bluesky has recently a thread view with limited nesting, as an experimental feature.)&lt;/p&gt;

&lt;p&gt;So that’s what I’ve built, as a web tool. You enter a URL of the root of the thread on bsky.app, and it renders the whole thread as a tree. You can use the +/– buttons to collapse and expand parts of the tree, just like on Reddit, and if you log in, you can also click the heart icons below a comment to like it:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/social2023/skythread.png"&gt;&lt;img alt="View of a thread with 3 posts, nested under one another" src="https://mackuba.eu/images/posts/social2023/skythread.png?1721484643" width="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The initial version of Skythread required logging in first to see anything, but I’ve recently switched it to a different API&amp;nbsp;that allows me to load whole threads without an access token. Note that the official Bluesky web app currently does not allow viewing any content unauthenticated – just like Twitter after the recent changes – so tools like Skythread, and other similar ones (e.g. &lt;a href="https://skyview.social"&gt;Skyview&lt;/a&gt;) are the only way right now to share links to posts and threads with people who don’t have an account; but this is a temporary situation and Bluesky should be open to the world (for reading at least) in near future.&lt;/p&gt;


        &lt;a class="github-card" href="https://github.com/mackuba/skythread" target="_blank"&gt;
          &lt;h2&gt;&lt;span class="author"&gt;mackuba&lt;/span&gt; ∕ &lt;span class="repo"&gt;skythread&lt;/span&gt;&lt;/h2&gt;
          &lt;p class="description"&gt;Thread viewer for Bluesky&lt;/p&gt;
          &lt;img src="https://mackuba.eu/images/github-mark.png?1721484643" class="gh-logo"&gt;
        &lt;/a&gt;
      

&lt;hr /&gt;

&lt;h2&gt;One app to rule them all&lt;/h2&gt;

&lt;p&gt;So where can you find me now on social media? As you might have guessed from the earlier sections, I’m spending most of the time on Bluesky now; which may be a bit strange, because that’s not where most of my friends and follows from Twitter ended up. A large part of the iOS/Mac/Swift programming community has moved to Mastodon and stayed there, with some stubbornly sticking to Twitter or posting to both. Possibly also to Threads, which I&amp;nbsp;don’t even have access to.&lt;/p&gt;

&lt;p&gt;But there’s something about Bluesky and the AT Protocol that really draws me to it… I&amp;nbsp;think it&amp;rsquo;s some combination of a nicer UI/UX, tech/architecture that I&amp;nbsp;like more, a new community that is only just forming, and having this feeling like I&amp;rsquo;m blazing the trail, being able to build all the tooling that doesn&amp;rsquo;t exist yet. I&amp;nbsp;like being part of something that&amp;rsquo;s being created around me, flying on that plane that&amp;rsquo;s &lt;a href="https://www.youtube.com/watch?v=UZq4sZz56qM"&gt;being built in the air&lt;/a&gt;, watching the devs build it live and feeling like I&amp;rsquo;m part of it all. I&amp;nbsp;enjoy being there, I&amp;nbsp;really want it to succeed, and I&amp;nbsp;want to help with that as much as I&amp;nbsp;can.&lt;/p&gt;

&lt;p&gt;So I&amp;nbsp;have friends on all three platforms, and even though I&amp;nbsp;spend most time on Bluesky, I&amp;nbsp;check all three everyday, for slightly different content – Twitter for news, Mastodon for Swift programming, Bluesky for… dopamine? And since some people only follow me here and some only there, I&amp;nbsp;end up manually cross-posting a lot of things to 2 or 3 websites.&lt;/p&gt;

&lt;p&gt;Wouldn’t it be nice to have a tool, kind of like &lt;a href="https://buffer.com"&gt;Buffer&lt;/a&gt;, that can let you post to Twitter, Mastodon and/or Bluesky in parallel? There doesn’t seem to be, so I’ve decided to build one myself&amp;nbsp;:] This one isn’t available yet and it still needs a lot of work before I&amp;nbsp;can call it an “MVP”, but it’s going to look something like this:&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;img alt="A small New Post window with an avatar and 3 network icons on the left, Post button in the bottom right, and the text &amp;lsquo;Hack the planet!&amp;rsquo; in the main text area" src="https://mackuba.eu/images/posts/social2023/new_post_window.png?1721484643" width="520"&gt;&lt;/p&gt;

&lt;p&gt;In the meantime, you can follow me here on any of these platforms – listed in the order of preference&amp;nbsp;:)&lt;/p&gt;

&lt;ul class="social-media"&gt;
  &lt;li class="bluesky"&gt;&lt;span&gt;🦋&lt;/span&gt; Bluesky: &lt;a href="https://bsky.app/profile/mackuba.eu"&gt;@mackuba.eu&lt;/a&gt;&lt;/li&gt;
  &lt;li class="mastodon"&gt;&lt;span class="mamutek"&gt;🦣&lt;/span&gt; Mastodon: &lt;a href="https://martianbase.net/@mackuba"&gt;mackuba@martianbase.net&lt;/a&gt;&lt;/li&gt;
  &lt;li class="twitter"&gt;&lt;img alt="" src="https://mackuba.eu/images/twitter-logo.png?1721484643" width="20"&gt; Twitter: &lt;a href="https://twitter.com/kuba_suder"&gt;@kuba_suder&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2022/12/22/social-media-update/</id>
    <title>Social media update - Elon's Twitter and Mastodon</title>
    <published>2022-12-22T16:21:01Z</published>
    <updated>2022-12-22T16:21:01Z</updated>
    <link href="https://mackuba.eu/2022/12/22/social-media-update/"/>
    <content type="html">&lt;p&gt;&lt;strong&gt;Update 01.03.2023&lt;/strong&gt;: Updated Mastodon address - my previous instance has been unexpectedly shut down and I&amp;nbsp;had to make a new account. I&amp;rsquo;ve decided to set up my own server to make sure it won&amp;rsquo;t happen again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update 09.11.2023&lt;/strong&gt;: I&amp;nbsp;made a &lt;a href="http://localhost:3000/2023/11/09/year-of-social-media-coding/"&gt;follow-up post&lt;/a&gt; which talks about the social media related projects I&amp;rsquo;ve been working on this year, and about Bluesky, where I&amp;rsquo;m spending most of the time now.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;This is just a small update about Twitter and Mastodon, since things have been… very unstable and chaotic in the last few weeks, as you&amp;rsquo;ve surely noticed if you log in to these even occassionally.&lt;/p&gt;

&lt;p&gt;Twitter has been my internet home for over 13 years now. I&amp;nbsp;started using it when my colleagues from &lt;a href="https://lunarlogic.io"&gt;Lunar Logic&lt;/a&gt; showed it to me, and especially in the recent years it&amp;rsquo;s been my main source of information and news. It&amp;rsquo;s where I&amp;nbsp;went to keep track of what was happening in the Apple/Swift world, find useful tips about UIKit, SwiftUI&amp;nbsp;or Xcode, follow the news, rumors and dramas on the Crypto Twitter, and find out every day what important thing was happening in the world, including following the &lt;a href="/2020/04/03/coronavirus-charts/"&gt;Covid pandemic&lt;/a&gt; and the Russian invasion of Ukraine this year.&lt;/p&gt;
&lt;p&gt;Like most of the people I&amp;rsquo;m following, I&amp;rsquo;m not very happy about Elon&amp;rsquo;s takeover and his actions, how he randomly makes changes to the rules based on his current mood, blocks journalists who write about him and how he fired or scared away most of the people who kept the site working. I&amp;rsquo;m worried about how the future looks for the platform, if Twitter will even exist in this form a year or two from now. I&amp;nbsp;wish this all hadn&amp;rsquo;t happened, and I&amp;rsquo;m angry at the people who made it happen for their own gain.&lt;/p&gt;

&lt;p&gt;But so far, Twitter is still working and is still a great place to get the news, tips and information about so many things. I&amp;rsquo;m not ready to give up on this site as long as the feed is loading and there are some tweets left to read.&lt;/p&gt;

&lt;p&gt;I&amp;rsquo;ve been trying out Mastodon like everyone else and I&amp;nbsp;slowly get more comfortable there, but it still feels a bit alien to me. It feels like when you move in to a new apartment and everything is different there than you&amp;rsquo;re used to, some things are missing, some things are better, some things are worse than in your old place, but a lot of your subconscious habits and muscle memory stop working. I&amp;nbsp;had some ways of using Twitter that worked for me and a bunch of private tools I&amp;nbsp;wrote for myself to help me automate some things - I&amp;nbsp;will have to figure this all out again now.&lt;/p&gt;

&lt;p&gt;So I&amp;nbsp;am on Mastodon, if only because I&amp;nbsp;don&amp;rsquo;t want to miss out on things, but I&amp;nbsp;am still on Twitter and I&amp;rsquo;m planning to stay and keep posting there, as long as it stays usable. I&amp;nbsp;will probably be posting more on Twitter than Mastodon, because I&amp;nbsp;feel more comfortable there. I&amp;nbsp;hope some of my friends and people I&amp;nbsp;follow stay on the platform, or at least check in from time to time.&lt;/p&gt;

&lt;p&gt;So here&amp;rsquo;s where you can find and follow me (&lt;strong&gt;updated 09.11.2023&lt;/strong&gt;):&lt;/p&gt;

&lt;ul class="social-media"&gt;
  &lt;li class="bluesky"&gt;&lt;span&gt;🦋&lt;/span&gt; Bluesky: &lt;a href="https://bsky.app/profile/mackuba.eu"&gt;@mackuba.eu&lt;/a&gt;&lt;/li&gt;
  &lt;li class="mastodon"&gt;&lt;span class="mamutek"&gt;🦣&lt;/span&gt; Mastodon: &lt;a href="https://martianbase.net/@mackuba"&gt;mackuba@martianbase.net&lt;/a&gt;&lt;/a&gt;&lt;/li&gt;
  &lt;li class="twitter"&gt;&lt;img alt="" src="https://mackuba.eu/images/twitter-logo.png?1721484643" width="20"&gt; Twitter: &lt;a href="https://twitter.com/kuba_suder"&gt;@kuba_suder&lt;/a&gt;&lt;/li&gt;
  &lt;li class="rss"&gt;&lt;img alt="" src="https://mackuba.eu/images/rss.png?1721484643" width="16"&gt; if you like to use RSS, check out the &lt;a href="/feeds"&gt;feeds&lt;/a&gt; for my blog&lt;/li&gt;
&lt;/ul&gt;



</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2021/12/30/new-nsbutton-post/</id>
    <title>New edition of the "Guide to NSButton styles"</title>
    <published>2021-12-30T14:28:59Z</published>
    <updated>2021-12-30T14:28:59Z</updated>
    <link href="https://mackuba.eu/2021/12/30/new-nsbutton-post/"/>
    <content type="html">&lt;p&gt;&lt;strong&gt;Note (Oct 2023):&lt;/strong&gt; The names of the buttons have been changed again in the SDK in macOS Sonoma - I&amp;nbsp;will update the blog post again once I&amp;nbsp;have Sonoma on one of my Macs&amp;nbsp;:)&lt;/p&gt;

&lt;p&gt;Back in October 2014 I&amp;nbsp;wrote a post about &lt;a href="/2014/10/06/a-guide-to-nsbutton-styles/"&gt;different styles of NSButtons&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That was in the era of OS X Yosemite and Xcode 6. I&amp;nbsp;started researching what each kind of button available in Interface Builder was for, because I&amp;nbsp;couldn&amp;rsquo;t figure that out from Xcode and the built-in documentation - I&amp;nbsp;dug a bit into the Human Interface Guidelines, some older documentation archives and into Apple apps themselves. I&amp;nbsp;collected everything into a long post that went through all the button styles and described what I&amp;nbsp;could find about each one.&lt;/p&gt;

&lt;p&gt;It seems that a lot of people also had the same problem, because the post turned out to be extremely popular. It&amp;rsquo;s around #3 in total page views on this blog, and 7 years and 7 major macOS versions later it still usually comes out #2 in monthly or yearly stats and still gets a couple hundred visits a month. Even with greatly improved documentation in Xcode and much expanded content in the modern HIG, there&amp;rsquo;s clearly demand for this kind of information collected in one place.&lt;/p&gt;
&lt;p&gt;However, the post was kind of asking to be updated for a long time now… The original screenshots were made in 1x quality, since I&amp;nbsp;didn&amp;rsquo;t get a Mac with a &lt;a href="/2015/04/02/testing-retina-images-older-mac/"&gt;Retina screen&lt;/a&gt; until &lt;a href="/2017/01/18/macbook-pro-2016-an-ios-developers-review/"&gt;the end of 2016&lt;/a&gt;. Big Sur was released in the summer of 2020, significantly changing the design of the OS, and making Catalina suddenly look outdated (to the point that I&amp;rsquo;ve seen some people already call the Yosemite-Catalina era design &amp;ldquo;classic macOS&amp;rdquo;!). Some new button variants were added, some older buttons were no longer used in system apps the way I&amp;nbsp;presented them, and the button styles available in Xcode were no longer shown and described as shown on screenshots from Xcode&amp;nbsp;6.&lt;/p&gt;

&lt;p&gt;The Big Sur launch seemed like a great moment to give that post a refresh, and I&amp;nbsp;started working on it at the end of last year, but then 2021 came and this year turned out to be kind of rough - surprisingly more so than 2020… as it was for a lot of people, I&amp;nbsp;suppose. I&amp;nbsp;only managed to get back to this project this month.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;thought it would take maybe a week or two… it took around three in total 😬 That included setting up new Mavericks and Yosemite installations in &lt;a href="/images/posts/nsbuttons/macos-mavericks-vm.jpg"&gt;VirtualBox&lt;/a&gt; to get updated Retina screenshots from there, building a number of versions of a &lt;a href="https://github.com/mackuba/NSButtonGallery"&gt;sample app&lt;/a&gt; full of all kinds of buttons that I&amp;nbsp;took a ton of screenshots of on several macOS versions, cutting every screenshot pixel-perfect to size a few times, merging different versions of the same information from a few different sources, including versions of HIG going as far back as &lt;a href="https://web.archive.org/web/20060612095317/http://developer.apple.com/documentation/UserExperience/Conceptual/OSXHIGuidelines/OSXHIGuidelines.pdf"&gt;2006&lt;/a&gt;, looking through Apple apps searching for buttons, and &lt;a href="https://twitter.com/kuba_suder/status/1470466284227895309"&gt;view-debugging some of them&lt;/a&gt; with SIP turned off to check what controls were used there… whew 😅&lt;/p&gt;

&lt;p&gt;I&amp;rsquo;m really happy with the result though. This is now by far the longest post on the blog, with around 11k words total (although around 1/3 of that is just quotes) and around a hundred images. I&amp;nbsp;expanded a lot of the content, adding some things I&amp;nbsp;hadn&amp;rsquo;t thought about last time and clarifying some that I&amp;nbsp;hadn&amp;rsquo;t fully understood after I&amp;nbsp;found some new information - and each button now has three different screenshots, sometimes even four:&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/nsbuttons/push-gallery.png?1721484643" width="752"&gt;&lt;/p&gt;

&lt;p&gt;Of course I&amp;rsquo;ve also learned a few new things myself and organized the information better in my head again, which is always my primary motivation when writing this kind of blog posts&amp;nbsp;:) After Big Sur I&amp;nbsp;don&amp;rsquo;t expect a next massive redesign for another few years, so I&amp;nbsp;probably won&amp;rsquo;t need to repeat this anytime soon - but if macOS 13 or 14 changes some minor things here and there, I&amp;rsquo;ll try to keep the post up to date.&lt;/p&gt;

&lt;p&gt;Read the &lt;a href="/2014/10/06/a-guide-to-nsbutton-styles/"&gt;updated blog post here&lt;/a&gt; - if you want to see the old version for some reason, you can find it in the &lt;a href="https://web.archive.org/web/20210803214559/https://mackuba.eu/2014/10/06/a-guide-to-nsbutton-styles/"&gt;Web Archive&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2020/10/15/typescript-on-corona-charts/</id>
    <title>TypeScript on Corona Charts</title>
    <published>2020-10-15T15:43:06Z</published>
    <updated>2020-10-15T15:43:06Z</updated>
    <link href="https://mackuba.eu/2020/10/15/typescript-on-corona-charts/"/>
    <content type="html">&lt;p&gt;Back in spring &lt;a href="/2020/04/03/coronavirus-charts/"&gt;I&amp;nbsp;built a website&lt;/a&gt; that lets you browse charts of coronavirus cases for each country separately, or to compare any chosen countries or regions together on one chart. I&amp;nbsp;spent about a month of time working on it, but I&amp;nbsp;mostly stopped around early May, since I&amp;nbsp;ran out of feature ideas and the pandemic situation was getting better (at least in Europe). The traffic that was huge at the beginning (over 10k visits daily at first) gradually fell to something around 1-1.5k over a few months, and I&amp;nbsp;was only checking the page myself now and then. So it seemed like it wouldn&amp;rsquo;t be needed for much longer…&lt;/p&gt;

&lt;p&gt;&amp;ldquo;Oh, my sweet summer child&amp;rdquo;, I&amp;nbsp;kinda want to tell the June me 😬&lt;/p&gt;

&lt;p&gt;So now that autumn is here and winter is coming, I&amp;nbsp;suddenly found new motivation to work on the charts site again. But instead of adding a bunch of new features right away, I&amp;nbsp;figured that maybe some refactoring would make sense first. I&amp;nbsp;initially built this page as a sort of hackathon-style prototype (&amp;ldquo;let&amp;rsquo;s see if I&amp;nbsp;can build this in a day&amp;rdquo;), but it grew much more complex since then, to reach around 2k lines of plain JavaScript &amp;ndash; all in one file and on one level.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;started thinking about how I&amp;nbsp;can make this easier to manage, and somehow I&amp;nbsp;got the idea to try TypeScript.&lt;/p&gt;
&lt;h2&gt;Why add static typing?&lt;/h2&gt;

&lt;p&gt;I&amp;nbsp;used to believe that static typing was just unnecessary complication.&lt;/p&gt;

&lt;p&gt;My first programming experiments back in school were in Pascal and very bad C++. At the university, pretty much everything was either plain C or Java (or C#, depending on which group you picked). It was only near the end of the studies that I&amp;nbsp;suddenly discovered Python and later Ruby, and it was like a breath of fresh air. I&amp;nbsp;also read Paul Graham&amp;rsquo;s book &amp;ldquo;&lt;a href="https://www.amazon.com/Hackers-Painters-Big-Ideas-Computer/dp/1449389554"&gt;Hackers and Painters&lt;/a&gt;&amp;rdquo;, which made a big impression on me, steered me away from big corpos towards the startup world (for which I&amp;rsquo;m forever grateful), and also showed me how much better dynamically typed languages (specifically Lisp) were than statically typed ones.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;spent the next few years writing mostly Ruby and some JavaScript for the frontend, and I&amp;nbsp;loved it. I&amp;nbsp;still love Ruby and to this day I&amp;nbsp;use it for all my scripting and server-side code.&lt;/p&gt;

&lt;p&gt;However, at some point I&amp;nbsp;also started building Mac and iOS apps, first in ObjC, and then in Swift. ObjC just felt like so much unnecessary boilerplate, and it really was. Then Swift came and simplified everything, but it exchanged the boilerplate for a very strict type system, much stricter than anything I&amp;rsquo;ve seen before. It was annoying to have to explain the compiler which property can be nil and when and what to do with it, or what to do if this JSON array does not contain what I&amp;nbsp;think it does.&lt;/p&gt;

&lt;p&gt;But after using Swift for a few years, I&amp;nbsp;really appreciate the feeling of safety it gives you. You have to put in more work up front, but once you do, and once it compiles, you can be sure that whole categories of possible errors have already been eliminated before you even run the app. You have to do fewer build &amp;ndash; run &amp;ndash; find an error &amp;ndash; fix the error cycles while building new features, and it&amp;rsquo;s also great while refactoring. And most importantly, it makes it harder to break one part of the code while changing another &amp;ndash; we don&amp;rsquo;t always test every part and every single path in the app after every change, so such accidentally introduced errors can make their way to production and to users before they&amp;rsquo;re discovered.&lt;/p&gt;

&lt;p&gt;Long story short, I&amp;nbsp;miss this feeling a bit when working with Ruby and JavaScript now. It would be nice to have someone or something look over my code as I&amp;rsquo;m working on it, and not only the parts that are currently executed.&lt;/p&gt;

&lt;h2&gt;Learning TypeScript&lt;/h2&gt;

&lt;p&gt;TypeScript is not a difficult language, it&amp;rsquo;s not even a completely new language &amp;ndash; it&amp;rsquo;s like JavaScript with some extra features and a compiler. So if you know JavaScript, you just need to learn the syntax for adding types, and the type declarations is the only thing you need to change in your code.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.typescriptlang.org"&gt;official TypeScript site&lt;/a&gt; has a great docs section, and you can learn everything you need there. Start with the &lt;a href="https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html"&gt;TypeScript for JS programmers&lt;/a&gt; intro and then go through the whole &lt;a href="https://www.typescriptlang.org/docs/handbook/intro.html"&gt;handbook&lt;/a&gt; and possibly &lt;a href="https://www.typescriptlang.org/docs/handbook/advanced-types.html"&gt;the reference part&lt;/a&gt; if you want more, and that&amp;rsquo;s it.&lt;/p&gt;

&lt;h2&gt;Setting up the editor&lt;/h2&gt;

&lt;p&gt;A normal person would just download &lt;a href="https://code.visualstudio.com"&gt;VS Code&lt;/a&gt;… however, that&amp;rsquo;s not me. I&amp;nbsp;just refuse to run any IDE or editor without a fully native UI&amp;nbsp;look &amp;amp; feel, so that leaves me with very little choice for those moments when I&amp;rsquo;m not using Xcode. For Ruby and JavaScript, I&amp;nbsp;use TextMate, which I&amp;rsquo;ve been using non stop since 2008 (now the version 2 for the last few years).&lt;/p&gt;

&lt;p&gt;There is a &lt;a href="https://github.com/stanger/TypeScript-TextMate"&gt;TextMate bundle for TypeScript&lt;/a&gt;, however, it only provides code highlighting and formatting. To simplify running the compiler while I&amp;rsquo;m in the editor, I&amp;nbsp;manually added two actions using the bundle editor so that I&amp;nbsp;don&amp;rsquo;t need to switch to the terminal to run &lt;code&gt;npx tsc&lt;/code&gt; every time:&lt;/p&gt;

&lt;p&gt;1) Compile to first error (shows output in a tooltip):&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;#!/usr/bin/env ruby18
require ENV['TM_SUPPORT_PATH'] + '/lib/textmate'

tsc = File.expand_path(File.join(ENV['TM_PROJECT_DIRECTORY'], 'node_modules', '.bin', 'tsc'))

ENV['PATH'] += ':/usr/local/bin'

result = `#{tsc} --noEmit`
first_line = result.each_line.first

if first_line.to_s.strip.length &amp;gt; 0
  puts first_line

  if first_line =~ /\((\d+)\,(\d+)\)\:/
    TextMate.go_to :line =&amp;gt; $1.to_i
  end
else
  puts "Build OK"
end
&lt;/pre&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/typescript/compile-error.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/typescript/compile-error.png?1721484643" width="696"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;2) Compile file (shows output in a special new window):&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;#!/usr/bin/env ruby18
require ENV['TM_SUPPORT_PATH'] + '/lib/textmate'
require ENV["TM_SUPPORT_PATH"] + "/lib/tm/executor"

tsc = File.expand_path(File.join(ENV['TM_PROJECT_DIRECTORY'], 'node_modules', '.bin', 'tsc'))

ENV['PATH'] += ':/usr/local/bin'

TextMate::Executor.run(tsc, '--noEmit')
&lt;/pre&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/typescript/compile-window.jpg"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/typescript/compile-window.jpg?1721484643" width="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a bit hacky, but it works for me. It only supports a scenario with one TypeScript file for now &amp;ndash; there&amp;rsquo;s apparently no way to make &lt;code&gt;tsc&lt;/code&gt; both use the &lt;code&gt;tsconfig.json&lt;/code&gt; config to configure compiler options, but also specify a specific filename on the command line. And I&amp;rsquo;d love to have real autocompletion too, but you can&amp;rsquo;t have everything…&lt;/p&gt;

&lt;p&gt;(If you know a good programmer&amp;rsquo;s editor with a native Mac UI, please let me know!)&lt;/p&gt;

&lt;h2&gt;Setting up the compiler &amp;amp; build&lt;/h2&gt;

&lt;p&gt;Once you download the TypeScript compiler node module, you can run &lt;code&gt;npx tsc --init&lt;/code&gt; to create a &lt;code&gt;tsconfig.json&lt;/code&gt; file at the root of your project. In this file you can specify where to look for &lt;code&gt;.ts&lt;/code&gt; files, how modern JavaScript it should output (I&amp;nbsp;chose &lt;code&gt;es2018&lt;/code&gt; since I&amp;nbsp;don&amp;rsquo;t need to support any old browsers), and turn specific checks on and off. I&amp;rsquo;ve experimented a lot with the compiler options, and eventually I&amp;rsquo;ve left everything on except &lt;code&gt;strict&lt;/code&gt;, &lt;code&gt;strictNullChecks&lt;/code&gt; and &lt;code&gt;strictPropertyInitialization&lt;/code&gt; (which requires &lt;code&gt;strictNullChecks&lt;/code&gt;). The null checks add a ton of additional errors everywhere, and would require me to unwrap everything with &lt;code&gt;!&lt;/code&gt; like in Swift on every step (e.g. every call to &lt;code&gt;querySelector&lt;/code&gt;, &lt;code&gt;querySelectorAll&lt;/code&gt;, &lt;code&gt;parentNode&lt;/code&gt; and such things), and I&amp;nbsp;decided it&amp;rsquo;s just not worth the effort. It could possibly make sense if I&amp;nbsp;was using some framework that was abstracting all interaction with DOM like React.&lt;/p&gt;

&lt;p&gt;If you use any external libraries, like &lt;a href="https://www.chartjs.org"&gt;Chart.js&lt;/a&gt; in my case, you will also want to download their &lt;code&gt;.d.ts&lt;/code&gt; definition files before you start working on your code &amp;ndash; otherwise the compiler will keep telling you &amp;ldquo;I&amp;nbsp;have no idea what this &lt;code&gt;chart&lt;/code&gt; thing is and whether it has such property&amp;rdquo;.&lt;/p&gt;

&lt;p&gt;As for running the build, &lt;code&gt;npx tsc&lt;/code&gt; does everything once you configure it in &lt;code&gt;tsconfig.json&lt;/code&gt; &amp;ndash; the problem is how to make it run when it needs to, and what to do with what it outputs…&lt;/p&gt;

&lt;p&gt;Again, a normal person would just set it up in some Gulp, Grunt, Webpack or whatever it is that people use in JavaScript land this month 😛 In my case however, I&amp;nbsp;have a Ruby project built on top of Sinatra that has no JavaScript build system (since I&amp;nbsp;have very little JavaScript in general outside of the Corona Charts page) and even no proper asset pipeline configured. So I&amp;nbsp;could either set up some new build system just for this, or write some kind of hack. You can guess what I&amp;nbsp;picked.&lt;/p&gt;

&lt;p&gt;Since I&amp;nbsp;only have one TypeScript file for now, and it&amp;rsquo;s only used on one page, I&amp;nbsp;realized I&amp;nbsp;can just manually check the timestamp in the controller action and rebuild if the &lt;code&gt;.ts&lt;/code&gt; file is newer:&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;get '/corona/' do
  # ...
  unless production?
    original = 'public/javascripts/corona.ts'
    compiled = 'public/javascripts/corona.js'

    if File.mtime(original) &amp;gt; File.mtime(compiled)
      puts "Compiling #{original}..."
      `npx tsc`
      puts "Done"
    end
  end

  erb :corona, layout: false
end
&lt;/pre&gt;

&lt;p&gt;That&amp;rsquo;s for development &amp;ndash; for production, I&amp;nbsp;simply run it as one of the deployment phases in my Capistrano script:&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;after 'deploy:update_code', 'deploy:install_node_packages', 'deploy:compile_typescript'

task :install_node_packages do
  run "cd #{release_path}; npm install"
end

task :compile_typescript do
  run "cd #{release_path}; ./node_modules/.bin/tsc"
end
&lt;/pre&gt;

&lt;h2&gt;Updating the code&lt;/h2&gt;

&lt;p&gt;It took me a good day or two to update all the code to silence all TypeScript errors. The good thing is that even though you get errors, the TypeScript compiler still outputs proper JavaScript that looks like what you had before (as long as it makes any sense at all), it just can&amp;rsquo;t promise it will work, so you could possibly deploy it as is, treat the errors like warnings in Xcode and get rid of them gradually. But ideally you want to not have any errors at all, since just like with warnings in Xcode, once you have too many of them, you stop noticing the important ones.&lt;/p&gt;

&lt;p&gt;The changes I&amp;nbsp;had to make can be grouped in a few categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deleting some unused code, variables and parameters (that I&amp;nbsp;haven&amp;rsquo;t realized were unused)&lt;/li&gt;
&lt;li&gt;&lt;p&gt;creating type definitions for all informal data structures used in the code &amp;ndash; e.g. declaring that a &lt;code&gt;DataSeries&lt;/code&gt; is a hash mapping a &lt;code&gt;string&lt;/code&gt; to an array of exactly 4 numbers, what the structure of the downloaded JSON is, or that the &lt;code&gt;valueMode&lt;/code&gt; parameter can only be &amp;ldquo;confirmed&amp;rdquo;, &amp;ldquo;deaths&amp;rdquo; or &amp;ldquo;active&amp;rdquo; (it&amp;rsquo;s so cool that you can have such specific types!):&lt;/p&gt;

&lt;pre class="brush: ts"&gt;type ChartMode = "total" | "daily";
type ValueMode = "confirmed" | "deaths" | "active";
type ValueIndex = 0 | 1 | 2 | 3;

type RankingItem = [number, Place];
type DataPoint = [number, number, number, number]
type DataSeries = Record&amp;lt;string, DataPoint&amp;gt;;

interface DataItem {
  place: Place;
  data: DataSeries;
  ...
}
&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;defining all global variables as explicitly typed properties on &lt;code&gt;Window&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: ts"&gt;interface Window {
  colorSet: string[];
  coronaData: DataItem[];
  chart: Chart;
  ...
}
&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;declaring all custom properties I&amp;nbsp;set on built-in objects like DOM elements, or method extensions added to built-in types like Object or Array:&lt;/p&gt;

&lt;pre class="brush: ts"&gt;interface HTMLAnchorElement {
  country: string;
  region: string;
  place: Place;
}
&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;declaring class variables and their types:&lt;/p&gt;

&lt;pre class="brush: ts"&gt;class Place {
  country?: string;
  region?: string;
  title?: string;
  ...
}
&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;declaring the type of all function parameters (you don&amp;rsquo;t usually need to specify return types, those are inferred):&lt;/p&gt;

&lt;pre class="brush: ts"&gt;function datasetsForSingleCountry(place: Place, dates: string[], json: DataSeries) {
  ...
}
&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;casting some DOM objects to a more specific type like &lt;code&gt;HTMLInputElement&lt;/code&gt; when I&amp;nbsp;want to use the &lt;code&gt;value&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: ts"&gt;let checkbox = document.getElementById('show_trend') as HTMLInputElement;
window.showTrend = checkbox.checked;
&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;providing a type for local vars initialized with &lt;code&gt;[]&lt;/code&gt; or &lt;code&gt;{}&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: ts"&gt;let ranking: RankingItem[] = [];
let autocompleteList: string[] = [];
&lt;/pre&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;It&amp;rsquo;s a lot of changes in total when you look at the diff, but most of it is things I&amp;nbsp;needed to write once somewhere at the top. When I&amp;nbsp;write a new function now, I&amp;nbsp;usually just need to add types to the parameters in the function header.&lt;/p&gt;

&lt;h2&gt;Was it worth it?&lt;/h2&gt;

&lt;p&gt;So far &amp;ndash; I&amp;rsquo;d say, absolutely. Like I&amp;nbsp;wrote above, when adding new functions I&amp;nbsp;usually just need to declare parameter types, unless I&amp;nbsp;start adding completely new types, but most of the time I&amp;nbsp;operate on the ones I&amp;nbsp;already have. Like in most modern languages, you usually don&amp;rsquo;t need to define a type for a local variable like &lt;code&gt;let thing = getThing()&lt;/code&gt;, because the compiler knows that it&amp;rsquo;s of type Thing. And if you return it, it knows this function always returns a Thing when it&amp;rsquo;s called elsewhere.&lt;/p&gt;

&lt;p&gt;So it doesn&amp;rsquo;t add much overhead for new code, but it does give me this nice feeling that someone is checking what I&amp;nbsp;write. I&amp;rsquo;ve done one refactoring since then, modifying the structure of the JSON file to make it smaller, since it naturally got way larger over the last few months (1.2 MB uncompressed, although I&amp;nbsp;managed to compress it to 190 KB now using Brotli compression set at max level).&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;changed the declaration of &lt;code&gt;window.coronaData&lt;/code&gt; at the top to be an object instead of an array, and the &lt;code&gt;DataSeries&lt;/code&gt; to be an array instead of a hash. And the compiler immediately showed me every single place in the code that was using these objects and had to be updated to the new format. I&amp;nbsp;didn&amp;rsquo;t have to use the editor search to hunt down every single place of use, and worry that I&amp;nbsp;might have missed one that I&amp;rsquo;ll only discover after some thorough testing (or a complaint from a user). Once it compiled, it was basically done and it worked from the first run.&lt;/p&gt;

&lt;p&gt;So am I&amp;nbsp;going to use TypeScript now for every 1-page-long piece of JS that adds some animations to a blog? Of course not. But does it make sense to use it in a webapp with dozens of features that builds and manages the whole UI&amp;nbsp;in JavaScript? I&amp;nbsp;think it does.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2020/09/10/watchkit-adventure-4-tables-navigation/</id>
    <title>WatchKit Adventure #4: Tables and Navigation</title>
    <published>2020-09-10T11:36:23Z</published>
    <updated>2020-09-10T11:36:23Z</updated>
    <link href="https://mackuba.eu/2020/09/10/watchkit-adventure-4-tables-navigation/"/>
    <content type="html">&lt;p class="hide-in-intro"&gt;&lt;a href="/2020/08/26/watchkit-adventure-3-app-ui/"&gt;&amp;lt; Previously on WatchKit Adventure…&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two weeks ago I&amp;nbsp;posted the &lt;a href="/2020/08/26/watchkit-adventure-3-app-ui/"&gt;first part of a tutorial&lt;/a&gt; about how to build an Apple Watch app UI&amp;nbsp;using WatchKit, using a &lt;code&gt;WKInterfaceController&lt;/code&gt; and a storyboard. We&amp;rsquo;ve built the main screen for the SmogWatch app, showing a big colored circle with the PM10 value inside and a chart showing data from the last few hours.&lt;/p&gt;

&lt;p&gt;Here&amp;rsquo;s the second part: today we&amp;rsquo;re going to add a second screen that lets the user choose which station they want to load the data from. So far I&amp;rsquo;ve used a hardcoded ID of the station that&amp;rsquo;s closest to me, but there are 8 stations within Krakow and the system includes a total of 20 in the region, so it would be nice to be able to choose a different one.&lt;/p&gt;

&lt;p&gt;(I&amp;nbsp;initially wanted to also include a selection of the measured pollutant &amp;ndash; from things like sulphur oxides, nitrogen oxides, benzene etc. &amp;ndash; and I&amp;rsquo;ve actually &lt;a href="https://github.com/mackuba/SmogWatch/commits/selecting_pollutant"&gt;mostly implemented it&lt;/a&gt;, but that turned out to be way more complex than I&amp;nbsp;thought, so I&amp;nbsp;dropped this idea.)&lt;/p&gt;

&lt;p&gt;The starting point of the code (where the previous part ends) is available &lt;a href="https://github.com/mackuba/SmogWatch/tree/post3"&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;hr /&gt;

&lt;h3&gt;Preparing the data&lt;/h3&gt;

&lt;p&gt;Since the list of stations doesn&amp;rsquo;t change often, we can hardcode a list of stations with their names, locations and IDs in a plist file that we&amp;rsquo;ll bundle inside the app. The list is generated using &lt;a href="https://github.com/mackuba/SmogWatch/blob/master/Scripts/import_channels.rb"&gt;a Ruby script&lt;/a&gt;, in case it needs to be updated later &amp;ndash; you can just download &lt;a href="https://github.com/mackuba/SmogWatch/blob/master/SmogWatch%20WatchKit%20Extension/Stations.plist"&gt;the plist&lt;/a&gt; and add it to the Xcode project.&lt;/p&gt;

&lt;p&gt;At runtime, the list will be available in the &lt;code&gt;DataStore&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;struct Station: Codable {
    let channelId: Int
    let name: String
    let lat: Double
    let lng: Double
}

class DataStore {
    // ...

    lazy private(set) var stations: [Station] = loadStations()

    private func loadStations() -&amp;gt; [Station] {
        let stationsFile = Bundle.main.url(forResource: "Stations", withExtension: "plist")!
        let data = try! Data(contentsOf: stationsFile)

        return try! PropertyListDecoder().decode([Station].self, from: data)
    }
}
&lt;/pre&gt;

&lt;h3&gt;Handling secondary screens&lt;/h3&gt;

&lt;p&gt;When we want to add an additional screen to the app that shows some secondary information or less commonly used feature like this, there are generally three ways we can handle it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;we can add an explicit button somewhere on the screen that opens it (usually in the bottom part)&lt;/li&gt;
&lt;li&gt;we can put it on another page in the &lt;a href="https://developer.apple.com/design/human-interface-guidelines/watchos/app-architecture/navigation/"&gt;page-based layout&lt;/a&gt; (e.g. like sharing and awards in the Activity app)&lt;/li&gt;
&lt;li&gt;or we can put it as an action in the &lt;a href="https://developer.apple.com/design/human-interface-guidelines/watchos/interface-elements/menus/"&gt;menu accessed through Force Touch&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;The third option (Force Touch menus) is going away now. In the watchOS 7 betas, Apple has &lt;a href="https://www.macrumors.com/2020/06/23/apple-drops-force-touch-gesture-in-watchos-7/"&gt;removed all Force Touch interactions&lt;/a&gt; from the OS and their own apps, the APIs for using it in third party apps (&lt;code&gt;addMenuItem&lt;/code&gt; in &lt;code&gt;WKInterfaceController&lt;/code&gt;) are deprecated, and it&amp;rsquo;s highly likely that the upcoming Series 6 watch will not include it as a hardware feature.&lt;/p&gt;

&lt;p&gt;Hiding some actions in a menu had the advantage that it didn&amp;rsquo;t clutter the main view, but it also made those actions less discoverable and harder to use for those who need them. I&amp;nbsp;personally always had a problem with the Force Touch menus in that I&amp;nbsp;rarely remembered that they exist, and I&amp;nbsp;often not realized that an app had some extra features if it put them there… So I&amp;nbsp;guess it&amp;rsquo;s for the better, although it will probably take some time to adjust.&lt;/p&gt;

&lt;p&gt;Using a page view controller and putting settings on the second page could work, but it doesn&amp;rsquo;t feel to me like this feature is important enough to get its whole new section in the main app navigation. I&amp;nbsp;don&amp;rsquo;t think this is something people will do often &amp;ndash; normally they should just select a station close to their home and never change it again. There will probably be some users who might want to often switch between stations to compare the values, but that would rather be a minority. (If you do want to use a page view controller, adding it is kind of unintuitive: there is no &amp;ldquo;Page View Controller&amp;rdquo; in the library, but instead you drag a segue from the first screen to the second and choose &amp;ldquo;Next page&amp;rdquo;.)&lt;/p&gt;

&lt;p&gt;So I&amp;rsquo;ve decided that in this case it&amp;rsquo;s not a problem to have an additional button at the bottom of the main screen, which opens the list in a separate view.&lt;/p&gt;

&lt;p&gt;The second choice is how to show the screen: do we show it as a &lt;a href="https://developer.apple.com/design/human-interface-guidelines/watchos/app-architecture/modal-sheets/"&gt;modal&lt;/a&gt;, or push it onto the &lt;a href="https://developer.apple.com/design/human-interface-guidelines/watchos/app-architecture/navigation/"&gt;navigation stack&lt;/a&gt; (the latter only possible if we aren&amp;rsquo;t using a page-based layout)? Again, both could work and it&amp;rsquo;s mostly a matter of preference. But I&amp;nbsp;think in this case a pushed view integrates better with the rest of the app.&lt;/p&gt;

&lt;h3&gt;Opening the list view&lt;/h3&gt;

&lt;p&gt;So let&amp;rsquo;s look at the storyboard again. We&amp;rsquo;d like to have a button below the chart that looks like a single table cell, which says &amp;ldquo;Choose Station&amp;rdquo; and shows the current station below, and opens a selection dialog when pressed.&lt;/p&gt;

&lt;p&gt;In WatchKit, buttons work in an interesting way: a button can show a text or an image as its title, but it can also include… a group, which itself can contain any structure you want, however complex. You could even wrap the whole screen in one group which acts as a button if you want (though I&amp;rsquo;m not sure what happens if you put a button into a group in a button &amp;ndash; it might be like &lt;a href="https://www.youtube.com/watch?v=OqxLmLUT-qc"&gt;when you type Google into Google&lt;/a&gt;&amp;hellip;). By the way, SwiftUI, which started its life on watchOS, kind of took over this idea &amp;ndash; the &lt;code&gt;Button&lt;/code&gt; takes a closure that returns its label and you can also put almost anything there.&lt;/p&gt;

&lt;p&gt;So let&amp;rsquo;s add a button at the bottom of the view here, and change its &lt;strong&gt;Content&lt;/strong&gt; type to &lt;strong&gt;Group&lt;/strong&gt;. The group is horizontal by default, so change its &lt;strong&gt;Layout&lt;/strong&gt; to &lt;strong&gt;Vertical&lt;/strong&gt;. Next, drag two labels inside: a top label &amp;ldquo;Choose Station&amp;rdquo;, with a &lt;strong&gt;Body&lt;/strong&gt; font, and a bottom label that shows the name of a station, with a &lt;strong&gt;Footnote&lt;/strong&gt; font and &lt;strong&gt;Light Gray color&lt;/strong&gt;. Like on iOS, you can also configure labels so that they&amp;rsquo;re able to shrink if the text is too long &amp;ndash; lower the &lt;strong&gt;Min Scale&lt;/strong&gt; slightly to 0.9, since the station names are kind of long.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/station_button1.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/station_button1.png?1721484643" width="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice that while a button normally has a dark gray background by default when showing a text, it lost the background when we switched it into the group mode. I&amp;rsquo;d like it to look like the standard buttons used e.g. in the Settings app&amp;rsquo;s various dialogs, but I&amp;nbsp;don&amp;rsquo;t think there is any way to restore this default shape and color other than trying to manually recreate it. (Technically, we could probably implement it as a one-row table instead… but that would be too much extra work.)&lt;/p&gt;

&lt;p&gt;So here&amp;rsquo;s how we&amp;rsquo;ll do it: add another plain button to the view. Select the new group, open the select field for the &lt;strong&gt;Color&lt;/strong&gt; property (not Background &amp;ndash; that&amp;rsquo;s used when you have an image background) and choose &amp;ldquo;Custom&amp;rdquo;. Now, in the system color picker use the &amp;ldquo;eyedropper&amp;rdquo; tool at the bottom to read the color value from the standard button:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/eyedropper.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/eyedropper.png?1721484643" width="227"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now you can delete the plain button. Let&amp;rsquo;s also give the group button custom insets to have some padding around the text: 8 on the top, bottom and right, and 10 on the left. And connect the lower label to an outlet in the &lt;code&gt;InterfaceController&lt;/code&gt;, since we&amp;rsquo;re going to need it later:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;@IBOutlet var stationNameLabel: WKInterfaceLabel!
&lt;/pre&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/station_button2.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/station_button2.png?1721484643" width="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;Pushing the list view&lt;/h3&gt;

&lt;p&gt;Now, we&amp;rsquo;re going to add a second screen to our app. Drag a new &lt;strong&gt;interface controller&lt;/strong&gt; from the library and put it on the storyboard on the right side of the main screen. Then, drag a &lt;strong&gt;segue&lt;/strong&gt; from the button to the new screen &amp;ndash; you&amp;rsquo;ll probably have to use the element sidebar on the left, since if you drag from the button&amp;rsquo;s rendering in the scene, it selects the group and not its parent button. Select the &amp;ldquo;&lt;strong&gt;Push&lt;/strong&gt;&amp;rdquo; segue type (for a modal, you&amp;rsquo;d do it the same way, but with a &amp;ldquo;&lt;strong&gt;Modal&lt;/strong&gt;&amp;rdquo; type segue instead).&lt;/p&gt;

&lt;p&gt;Alternatively, we could leave the new screen disconnected, assign it an identifier, and then open the screen manually in code using the &lt;code&gt;pushController(withName:context:)&lt;/code&gt; method, or &lt;code&gt;presentController(withName:context:)&lt;/code&gt; in case of a modal, from the &lt;code&gt;IBAction&lt;/code&gt; triggered from the button. But this sounds like more work, and I&amp;nbsp;generally like to use segues whenever possible, since the storyboard then shows a clearer picture of the whole flow of the app.&lt;/p&gt;

&lt;p&gt;The pushed view shows a &amp;ldquo;&amp;lt;&amp;rdquo; back button at the top, and you can put a title there. However, &lt;strong&gt;these titles work differently than on iOS&lt;/strong&gt;: on iOS, you usually have a &amp;ldquo;&amp;lt; Back&amp;rdquo; button on the left, and a title in the center. On the Watch, there isn&amp;rsquo;t enough space for that &amp;ndash; so the title shown after the &amp;ldquo;&amp;lt;&amp;rdquo; sign is supposed to be the name of the &lt;em&gt;current&lt;/em&gt; view, not the view that you can get back to.&lt;/p&gt;

&lt;p&gt;In this case, let&amp;rsquo;s make it say simply &amp;ldquo;Station&amp;rdquo;, since &amp;ldquo;Choose Station&amp;rdquo; is a bit too long (you need to leave space for the clock on the right). You can type it into the &lt;strong&gt;Title&lt;/strong&gt; field, or just double-click the top area where the title should be (though it won&amp;rsquo;t be displayed on the storyboard).&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/pushed_screen.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/pushed_screen.png?1721484643" width="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;Designing table cells&lt;/h3&gt;

&lt;p&gt;To display a list of things from which you can select one, the obvious choice is a table view. On watchOS it works somewhat similarly to iOS, with some exceptions &amp;ndash; there are no sections, there&amp;rsquo;s only one single section of cells (although you could simulate sections by making different cells that act as section headers). But like on iOS, you use custom classes to handle the cells &amp;ndash; like &lt;code&gt;UITableViewCell&lt;/code&gt;, here they&amp;rsquo;re called &amp;ldquo;Row Controllers&amp;rdquo;; cells also have identifiers, and you can use different kinds of cells in one table.&lt;/p&gt;

&lt;p&gt;To start, drag a &lt;strong&gt;table&lt;/strong&gt; from the library into the second view. You automatically get one standard row type created for you, but you can add more.&lt;/p&gt;

&lt;p&gt;Add a label to the table row&amp;rsquo;s group, use a standard &lt;strong&gt;Body&lt;/strong&gt; font, but set &lt;strong&gt;Min Scale&lt;/strong&gt; to 0.8 to accomodate longer names. By default the label will put itself in the top-left corner, so set its &lt;strong&gt;Vertical Alignment&lt;/strong&gt; to center.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/table1.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/table1.png?1721484643" width="274"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We need one more thing though: it would be nice to show a checkmark symbol on the right when you select a cell &amp;ndash; just like in the Settings app:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/settings.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/settings.png?1721484643" width="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately, there doesn&amp;rsquo;t seem to be any equivalent of &lt;code&gt;UITableViewCell.AccessoryType&lt;/code&gt; here &amp;ndash; if you want to have a checkmark, you need to add it manually as a normal label.&lt;/p&gt;

&lt;p&gt;So, add another label to the same row group, set its title to the unicode symbol &amp;ldquo;✓&amp;rdquo; (which looks very similar to the checkmark in the settings), and &amp;ldquo;borrow&amp;rdquo; the green color from the system checkmark in the Settings using the same eyedropper method as before. Set its &lt;strong&gt;Vertical Alignment&lt;/strong&gt; to center too.&lt;/p&gt;

&lt;p&gt;We have one problem though: the first label takes all available space, and the checkmark is pushed to the edge:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/table2.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/table2.png?1721484643" width="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sadly, there are no &amp;ldquo;compression priorities&amp;rdquo; here like in AutoLayout, so there&amp;rsquo;s no way to tell WatchKit to give the checkmark all the space it needs and then leave the rest to the title. What we can do instead is assign the checkmark a &lt;strong&gt;Fixed width&lt;/strong&gt; which is then enforced &amp;ndash; 15 seems about right; it&amp;rsquo;s not an elegant solution, but it works. Set also its internal &lt;strong&gt;Alignment&lt;/strong&gt; (the text alignment, in the Label section, not the position alignment) to right so that the symbol stays at the right edge, even if we gave it too much space.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/table3.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/table3.png?1721484643" width="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;Handling the channel ID&lt;/h3&gt;

&lt;p&gt;Let&amp;rsquo;s look at our data &amp;amp; networking code for a moment. When the user picks a station, we&amp;rsquo;re going to store the channel ID in the &lt;code&gt;DataStore&lt;/code&gt;. We also need to make sure that when the channel ID is changed, the old data is reset, because it was loaded from another station so it&amp;rsquo;s no longer relevant:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;private let selectedChannelIdKey = "SelectedChannelId"

class DataStore {
    // ...

    var selectedChannelId: Int? {
        get {
            return defaults.object(forKey: selectedChannelIdKey) as? Int
        }
        set {
            if newValue != selectedChannelId {
                defaults.set(newValue, forKey: selectedChannelIdKey)
                invalidateData()
            }
        }
    }

    func invalidateData() {
        defaults.removeObject(forKey: savedPointsKey)
        defaults.removeObject(forKey: lastUpdateDateKey)
    }
}
&lt;/pre&gt;

&lt;p&gt;We also need to actually use the new channel ID when making the request for the data. This is a bit too long to paste here, so here&amp;rsquo;s the &lt;a href="https://github.com/mackuba/SmogWatch/commit/053e867234f4dc35e0a0cd166f254ac2a41a8bb0#diff-4eb3d49c5eaada67ac71e5ddf6b666c3"&gt;relevant change&lt;/a&gt; in the &lt;code&gt;KrakowPiosDataLoader&lt;/code&gt; class. Instead of the hardcoded ID of one specific station that I&amp;rsquo;ve used before, we&amp;rsquo;ll now be passing the ID of the selected station to the query.&lt;/p&gt;

&lt;h3&gt;The table controller&lt;/h3&gt;

&lt;p&gt;We&amp;rsquo;ll handle the table view in code in a new interface controller, which we&amp;rsquo;ll call &lt;code&gt;StationListController&lt;/code&gt;. Create such class in Xcode (inherit from &lt;code&gt;WKInterfaceController&lt;/code&gt; &amp;ndash; there&amp;rsquo;s no special &amp;ldquo;table view controller&amp;rdquo;) and assign it to the new scene on the storyboard. Also add this outlet and connect it:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;@IBOutlet weak var table: WKInterfaceTable!
&lt;/pre&gt;

&lt;p&gt;We&amp;rsquo;ll also need a row controller class (it&amp;rsquo;s required, and there is no default base class with standard outlets like &lt;code&gt;UITableViewCell&lt;/code&gt; that we could use anyway). Use &lt;code&gt;NSObject&lt;/code&gt; as the base class and call it &lt;code&gt;StationListRow&lt;/code&gt; &amp;ndash; like table view cells, it will be very simple:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;class StationListRow: NSObject {
    @IBOutlet weak var titleLabel: WKInterfaceLabel!
    @IBOutlet weak var checkmark: WKInterfaceLabel!

    func showStation(_ station: Station) {
        titleLabel.setText(station.name)
        checkmark.setHidden(true)
    }

    func setCheckmarkVisible(_ visible: Bool) {
        checkmark.setHidden(!visible)
    }
}
&lt;/pre&gt;

&lt;p&gt;Assign the class name to the row on the storyboard and connect the outlets. A row also needs an identifier &amp;ndash; call it &lt;code&gt;BasicListRow&lt;/code&gt; (yes, there will be another&amp;nbsp;:).&lt;/p&gt;

&lt;p&gt;Now, in the &lt;code&gt;StationListController&lt;/code&gt;, the interesting stuff happens in the &lt;code&gt;awake(withContext:)&lt;/code&gt; method. What is a context, you might ask? It&amp;rsquo;s a cool idea that Apple kind of expanded on in the iOS 13 SDK, in the form of &lt;code&gt;UIStoryboard.instantiateViewController&lt;/code&gt;, which lets you have a custom initializer in a &lt;code&gt;UIViewController&lt;/code&gt; in which you can receive any required data, while still using storyboards and segues to navigate to the view controller.&lt;/p&gt;

&lt;p&gt;In WatchKit, instead of custom initializers you have a single context object &amp;ndash; but this context could be anything you want, including any complex structure and non-ObjC types. You can use this object to pass all required data from the parent/presenting controller to the presented controller, and extract it in &lt;code&gt;awake(withContext:)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We&amp;rsquo;ll use the context to pass the new view controller the list of all stations and the id of the currently selected one (if any). And it turns out that it&amp;rsquo;s also possible to pass blocks this way, so we can include a simple block that will act as a selection callback &amp;ndash; this way we can avoid building a &amp;ldquo;delegate protocol&amp;rdquo; to pass the response back.&lt;/p&gt;

&lt;p&gt;Let&amp;rsquo;s prepare a simple struct for the context data:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;struct StationListContext {
    let items: [Station]
    let selectedId: Int?
    let onSelect: ((Station) -&amp;gt; ())
}
&lt;/pre&gt;

&lt;p&gt;The selection controller will get this data from the main interface controller, which assigns it in the &lt;code&gt;contextForSegue(withIdentifier:)&lt;/code&gt; method:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;override func contextForSegue(withIdentifier segueIdentifier: String) -&amp;gt; Any? {
    if segueIdentifier == "ChooseStation" {
        return StationListContext(
            items: dataStore.stations,
            selectedId: dataStore.selectedChannelId,
            onSelect: { _ in }
        )
    }

    return nil
}
&lt;/pre&gt;

&lt;p&gt;For this to work, you need to select the segue on the storyboard and assign it the identifier &lt;code&gt;ChooseStation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;StationListController&lt;/code&gt;, we&amp;rsquo;ll receive the data from the context in &lt;code&gt;awake(withContext:)&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;class StationListController: WKInterfaceController {
    var selectedRowIndex: Int? = nil
    var items: [Station] = []
    var selectionHandler: ((Station) -&amp;gt; ())?

    override func awake(withContext context: Any?) {
        let context = context as! StationListContext

        items = context.items
        selectionHandler = context.onSelect
        ...
&lt;/pre&gt;

&lt;p&gt;Notice that we don&amp;rsquo;t need to call &lt;code&gt;super()&lt;/code&gt; in &lt;code&gt;awake(withContext:)&lt;/code&gt; &amp;ndash; the same is true for &lt;code&gt;willActivate&lt;/code&gt;, &lt;code&gt;didAppear&lt;/code&gt; etc. If you look at the documentation of those, it always says &amp;ldquo;&lt;em&gt;The super implementation of this method does nothing&lt;/em&gt;&amp;rdquo; there.&lt;/p&gt;

&lt;p&gt;To initialize the table, we call the &lt;code&gt;setNumberOfRows&lt;/code&gt; method to set the row count, and then we iterate over the rows to initialize their contents (there is no &amp;ldquo;cell reuse&amp;rdquo; and initializing cells during scrolling, it&amp;rsquo;s all done up front). If you wanted to have multiple types of rows in one table, then you need to call &lt;code&gt;setRowTypes&lt;/code&gt; instead and pass it an array with as many repeated identifiers as you want to have rows.&lt;/p&gt;

&lt;pre class="brush: swift"&gt;table.setNumberOfRows(items.count, withRowType: "BasicListRow")

for i in 0..&amp;lt;items.count {
    let row = table.rowController(at: i) as! StationListRow
    row.showStation(items[i])
}
&lt;/pre&gt;

&lt;p&gt;We&amp;rsquo;ll also preselect the row of the currently selected station, if we can find it (we pass the channel ID from the parent controller, but here we&amp;rsquo;ll store the index of the row, so that we can later deselect it easily).&lt;/p&gt;

&lt;pre class="brush: swift"&gt;if context.selectedId != nil {
    if let index = items.firstIndex(where: { $0.channelId == context.selectedId }) {
        let row = table.rowController(at: index) as! StationListRow
        row.setCheckmarkVisible(true)
        selectedRowIndex = index
    }
}
&lt;/pre&gt;

&lt;p&gt;To handle row selection, we&amp;rsquo;ll implement &lt;code&gt;table(_:didSelectRowAt:)&lt;/code&gt; (you don&amp;rsquo;t need to assign any delegate/data source properties to the table or add any protocols, it works automatically):&lt;/p&gt;

&lt;pre class="brush: swift"&gt;override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) {
    if let previous = selectedRowIndex, previous != rowIndex {
        listRowController(at: previous).setCheckmarkVisible(false)
    }

    listRowController(at: rowIndex).setCheckmarkVisible(true)
    selectedRowIndex = rowIndex

    selectionHandler?(items[rowIndex])
}

func listRowController(at index: Int) -&amp;gt; StationListRow {
    return table.rowController(at: index) as! StationListRow
}
&lt;/pre&gt;

&lt;p&gt;As you can see: we manually hide the checkmark in the previously selected row whose index we&amp;rsquo;ve saved, we show the checkmark on the current row, we store the row index, and we pass the &lt;code&gt;Station&lt;/code&gt; back through the callback block.&lt;/p&gt;

&lt;p&gt;Finally, we need to handle the selection in the parent controller when the callback is called. Specifically, we&amp;rsquo;ll need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;save the channel ID of the new station in the &lt;code&gt;DataStore&lt;/code&gt;, so that further requests to the web service will load data from that station&lt;/li&gt;
&lt;li&gt;update the displayed station ID in the button at the bottom&lt;/li&gt;
&lt;li&gt;show in the UI&amp;nbsp;that we have no data from that station yet&lt;/li&gt;
&lt;li&gt;request to reload the data immediately&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Here&amp;rsquo;s how we do it:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;func setSelectedStation(_ station: Station) {
    dataStore.selectedChannelId = station.channelId
    stationNameLabel.setText(station.name)

    updateDisplayedData()
    gradeLabel.setText("Loading")

    KrakowPiosDataLoader().fetchData { success in
        self.updateDisplayedData()
    }
}
&lt;/pre&gt;

&lt;p&gt;And remember to call this in the callback block from &lt;code&gt;StationListContext&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;return StationListContext(
    items: dataStore.stations,
    selectedId: dataStore.selectedChannelId,
    onSelect: { station in
        self.setSelectedStation(station)
    }
)
&lt;/pre&gt;

&lt;p&gt;The end result should look like this 🙂&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/table4.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/table4.png?1721484643" width="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And one more thing: we need to also remember to initialize the selected station label when the view is first loaded. We&amp;rsquo;ll make it say &amp;ldquo;not selected&amp;rdquo; if nothing was selected yet:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;// call in awake(withContext:)

func updateStationInfo() {
    guard let channelId = dataStore.selectedChannelId else { return }

    if let station = dataStore.stations.first(where: { $0.channelId == channelId }) {
        stationNameLabel.setText(station.name)
    } else {
        stationNameLabel.setText("not selected")
    }
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h2&gt;User location&lt;/h2&gt;

&lt;p&gt;There&amp;rsquo;s still something we could do to improve the user experience: why ask the user which station they want to load the data from, when in the majority of cases they&amp;rsquo;ll only be interested in the one that&amp;rsquo;s closest to them? And why make them scroll through the whole list, if some of the stations are 100 km away from them?&lt;/p&gt;

&lt;p&gt;We can solve this if we ask the user for location access &amp;ndash; after all, almost every Apple Watch has built-in GPS, and those that don&amp;rsquo;t are connected to an iPhone that has one.&lt;/p&gt;

&lt;p&gt;On watchOS we ask for location exactly like on iOS &amp;ndash; so we can follow the &lt;a href="/2015/03/17/accessing-user-location-data-in-ios8/"&gt;instructions I&amp;nbsp;wrote here&lt;/a&gt; a few years ago:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;add an &lt;code&gt;NSLocationWhenInUseUsageDescription&lt;/code&gt; key to the &lt;code&gt;Info.plist&lt;/code&gt; (e.g. &amp;ldquo;&lt;em&gt;SmogWatch uses location data to pick a station that&amp;rsquo;s closest to you.&lt;/em&gt;&amp;rdquo;)&lt;/li&gt;
&lt;li&gt;add a reference to a &lt;code&gt;CLLocationManager&lt;/code&gt; in the &lt;code&gt;InterfaceController&lt;/code&gt; and make it its delegate&lt;/li&gt;
&lt;li&gt;ask for location access when the main screen opens:&lt;/li&gt;
&lt;/ul&gt;


&lt;div&gt;&lt;/div&gt;


&lt;pre class="brush: swift"&gt;var userLocation: CLLocation?

override func willActivate() {
    askForLocationIfNeeded()
}

func askForLocationIfNeeded() {
    guard userLocation == nil, CLLocationManager.locationServicesEnabled() else { return }

    switch CLLocationManager.authorizationStatus() {
    case .notDetermined:
        locationManager.requestWhenInUseAuthorization()
    case .authorizedAlways, .authorizedWhenInUse:
        locationManager.requestLocation()
    default:
        break
    }
}
&lt;/pre&gt;

&lt;p&gt;We&amp;rsquo;re going to store the location in &lt;code&gt;userLocation&lt;/code&gt; when we find it, so that we can use it in the station selection screen.&lt;/p&gt;

&lt;p&gt;⚠️ One warning here &amp;ndash; I&amp;nbsp;initially added &lt;code&gt;askForLocationIfNeeded()&lt;/code&gt; to &lt;code&gt;didAppear&lt;/code&gt; so that we only ask for location once the UI&amp;nbsp;appears, and I&amp;nbsp;was expecting &lt;code&gt;didAppear&lt;/code&gt; to always be called following &lt;code&gt;willActivate&lt;/code&gt; &amp;ndash; but it doesn&amp;rsquo;t seem to work this way. From my testing right now, it seems that &lt;code&gt;didAppear&lt;/code&gt; is only called when the app is launched and when you return to the interface controller from the pushed view, but not when the app is closed and reopened. If you add something to one of these two methods, make sure you test exactly in which cases they get called.&lt;/p&gt;

&lt;p&gt;Next, if the user grants us location access after the launch, we ask for location data then:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;func locationManager(
  _ manager: CLLocationManager,
  didChangeAuthorization status: CLAuthorizationStatus)
{
    switch CLLocationManager.authorizationStatus() {
    case .authorizedAlways, .authorizedWhenInUse:
        locationManager.requestLocation()
    default:
        break
    }
}
&lt;/pre&gt;

&lt;p&gt;We only ask for a single location, we don&amp;rsquo;t need to track it continuously. And we can also set &lt;code&gt;desiredAccuracy&lt;/code&gt; to &lt;code&gt;kCLLocationAccuracyHundredMeters&lt;/code&gt; when setting up the &lt;code&gt;CLLocationManager&lt;/code&gt; &amp;ndash; we won&amp;rsquo;t need more precision than that, and we should get the location much faster this way.&lt;/p&gt;

&lt;p&gt;When we get the location, we save it in the property &lt;code&gt;userLocation&lt;/code&gt; mentioned earlier (we also need to handle an error case &amp;ndash; you actually get an exception immediately if you don&amp;rsquo;t). Also, most importantly, if there is no station selected yet, but we have the user location, we can preselect the closest one automatically &amp;ndash; that way, the user will see some data almost immediately, without having to configure the app first, and it&amp;rsquo;s very likely it will be exactly the data that they want 👍&lt;/p&gt;

&lt;pre class="brush: swift"&gt;func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let currentLocation = locations.last else { return }

    userLocation = currentLocation

    if dataStore.selectedChannelId == nil {
        let closestStation = stationsSortedByDistance(from: currentLocation).first!
        setSelectedStation(closestStation)
    }
}

func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
    NSLog("CLLocationManager error: %@", "\(error)")
}

func stationsSortedByDistance(from userLocation: CLLocation) -&amp;gt; [Station] {
    return dataStore.stations.sorted { (s1, s2) -&amp;gt; Bool in
        let d1 = CLLocation(latitude: s1.lat, longitude: s1.lng).distance(from: userLocation)
        let d2 = CLLocation(latitude: s2.lat, longitude: s2.lng).distance(from: userLocation)

        return d1 &amp;lt; d2
    }
}
&lt;/pre&gt;

&lt;p&gt;We can then pass the saved location to the stations list, where we&amp;rsquo;ll use it to show the distance to each station,  and we can also pass it a list of locations sorted by location:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;override func contextForSegue(withIdentifier segueIdentifier: String) -&amp;gt; Any? {
    if segueIdentifier == "ChooseStation" {
        let stations: [Station]

        if let currentLocation = userLocation {
            stations = stationsSortedByDistance(from: currentLocation)
        } else {
            stations = dataStore.stations
        }

        return StationListContext(
            items: stations,
            selectedId: dataStore.selectedChannelId,
            userLocation: userLocation,
            onSelect: { station in
                self.setSelectedStation(station)
            }
        )
    }

    return nil
}
&lt;/pre&gt;

&lt;p&gt;Add a &lt;code&gt;userLocation: CLLocation?&lt;/code&gt; property to the &lt;code&gt;SelectionListContext&lt;/code&gt;, from which we&amp;rsquo;ll read it in the controller&amp;rsquo;s initializer.&lt;/p&gt;

&lt;h3&gt;Showing distances in the list&lt;/h3&gt;

&lt;p&gt;Let&amp;rsquo;s look back on our storyboard again. We want to have a second label below the station title that shows the distance to the station &amp;ndash; that is, if we know user&amp;rsquo;s location, otherwise we show the old version.&lt;/p&gt;

&lt;p&gt;It&amp;rsquo;s possible that we could somehow make this work with a single cell type, but I&amp;nbsp;figured that a much easier way would be to have two different cells managed by the same class. So make a duplicate of our &lt;code&gt;BasicListRow&lt;/code&gt;, give it an identifier &lt;code&gt;ListRowWithDistance&lt;/code&gt; and keep the same class name.&lt;/p&gt;

&lt;p&gt;In order to have 3 elements in the cell positioned correctly, we&amp;rsquo;re going to need two groups: one horizontal, dividing the checkmark on the right from the two labels on the left, and then an inner vertical group that arranges the two labels. So change the cell this way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrap the left label in a &lt;strong&gt;Vertical group&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Add a second label inside that inner group. Make sure it&amp;rsquo;s below the first one (you can set their vertical alignment, or you can just make sure they&amp;rsquo;re in the right order in the view tree).&lt;/li&gt;
&lt;li&gt;The table row&amp;rsquo;s main group has a fixed &amp;ldquo;Default&amp;rdquo; height configured when created &amp;ndash; but with two labels, this default height is too little. So change the height setting to &lt;strong&gt;Size to fit&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Give the lower label a &lt;strong&gt;Light Gray color&lt;/strong&gt; and a &lt;strong&gt;Footnote&lt;/strong&gt; font. Make it say e.g. &amp;ldquo;3.2 km&amp;rdquo;, and assign it to an outlet in the &lt;code&gt;StationListRow&lt;/code&gt;:&lt;/li&gt;
&lt;/ul&gt;


&lt;div&gt;&lt;/div&gt;


&lt;pre class="brush: swift"&gt;@IBOutlet weak var distanceLabel: WKInterfaceLabel!
&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Give the vertical group &lt;strong&gt;Insets&lt;/strong&gt; of 3 at the top and bottom, and change its &lt;strong&gt;Width&lt;/strong&gt; setting to &amp;ldquo;Size to fit content&amp;rdquo; &amp;ndash; otherwise it will take whole cell width by default and push the checkmark out.&lt;/li&gt;
&lt;li&gt;Customize the outer (horizontal) group&amp;rsquo;s &lt;strong&gt;Spacing&lt;/strong&gt; to 0; we can allow less space between the checkmark and the edge of the title label now, because the checkmark will be positioned in the vertical center, so slightly lower than the label.&lt;/li&gt;
&lt;li&gt;The checkmark&amp;rsquo;s properties should stay as before.&lt;/li&gt;
&lt;/ul&gt;


&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/table5.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/table5.png?1721484643" width="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can then add another helper method to &lt;code&gt;StationListRow&lt;/code&gt; to set the distance. We&amp;rsquo;re going to use &lt;code&gt;MeasurementFormatter&lt;/code&gt; here in order to automatically display kilometers or miles, and we&amp;rsquo;ll also make sure to only print 1 decimal digit, since we asked for a slightly less precise location (the default is something like &amp;ldquo;2.456&amp;nbsp;km&amp;rdquo;):&lt;/p&gt;

&lt;pre class="brush: swift"&gt;let measurementFormatter: MeasurementFormatter = {
    let numberFormatter = NumberFormatter()
    numberFormatter.maximumFractionDigits = 1

    let measurementFormatter = MeasurementFormatter()
    measurementFormatter.numberFormatter = numberFormatter
    return measurementFormatter
}()

func setDistance(_ distance: Double) {
    let text = measurementFormatter.string(
        from: Measurement(value: distance, unit: UnitLength.meters)
    )
    distanceLabel.setText(text)
}
&lt;/pre&gt;

&lt;p&gt;And now in the &lt;code&gt;awake(withContext:)&lt;/code&gt; method in &lt;code&gt;StationListController&lt;/code&gt; we can choose between the two types of cells depending on whether we have the location or not, and if we do, calculate the distance to each station and show it in the lower label:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;let rowType = (context.userLocation == nil) ? "BasicListRow" : "ListRowWithDistance"
table.setNumberOfRows(items.count, withRowType: rowType)

for i in 0..&amp;lt;items.count {
    let row = listRowController(at: i)
    row.showStation(items[i])

    if let location = context.userLocation {
        let itemLocation = CLLocation(latitude: items[i].lat, longitude: items[i].lng)
        row.setDistance(location.distance(from: itemLocation))
    }
}
&lt;/pre&gt;

&lt;p&gt;You should now see something like this:&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/table6.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/table6.png?1721484643" width="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;We&amp;rsquo;ve reached the end of this tutorial. For the next episode, I&amp;nbsp;will try to rewrite the whole UI&amp;nbsp;again from scratch in SwiftUI&amp;nbsp;and compare how much effort it requires to build the same kind of UI&amp;nbsp;in the new framework&amp;nbsp;:)&lt;/p&gt;

&lt;p&gt;The final version of the code after a completed tutorial is available on a branch &lt;a href="https://github.com/mackuba/SmogWatch/tree/post4"&gt;here&lt;/a&gt;, and the slightly different real version on the master would be more or less at &lt;a href="https://github.com/mackuba/SmogWatch/tree/post3_final"&gt;this commit&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2020/08/26/watchkit-adventure-3-app-ui/</id>
    <title>WatchKit Adventure #3: Building the App UI</title>
    <published>2020-08-26T13:25:41Z</published>
    <updated>2020-08-26T13:25:41Z</updated>
    <link href="https://mackuba.eu/2020/08/26/watchkit-adventure-3-app-ui/"/>
    <content type="html">&lt;p class="hide-in-intro"&gt;&lt;a href="/2019/03/06/watchkit-adventure-2-mvc/"&gt;&amp;lt; Previously on WatchKit Adventure…&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the third part of my series about building a WatchKit app that shows current air pollution level on the watch face (&lt;a href="/2018/10/29/watchkit-adventure-0-intro/"&gt;it started here&lt;/a&gt;). In this episode, we&amp;rsquo;re going to build the app&amp;rsquo;s main UI. I&amp;nbsp;will be building on top of some data handling &amp;amp; networking code written in the &lt;a href="/2019/03/06/watchkit-adventure-2-mvc/"&gt;previous episode&lt;/a&gt; about complications, so if you haven&amp;rsquo;t seen that one, you might want to at least skim through it to get some idea about what this is about. Browse through the &lt;a href="/category/watchkit"&gt;WatchKit category&lt;/a&gt; to see the whole list.&lt;/p&gt;

&lt;p&gt;We&amp;rsquo;re venturing into a somewhat uncharted territory now… The WWDC talks about WatchKit are an amazing source of information and they&amp;rsquo;re great to get started (I&amp;nbsp;definitely recommend watching them, especially the earlier ones, from 2015 &amp;amp; 2016), but once you actually start building things and run into a problem, there&amp;rsquo;s surprisingly little help available. Even StackOverflow isn&amp;rsquo;t of much use. There aren&amp;rsquo;t many books out there either that are up to date &amp;ndash; I&amp;nbsp;got one from &lt;a href="https://store.raywenderlich.com/products/watchos-by-tutorials"&gt;raywenderlich.com&lt;/a&gt;, but it doesn&amp;rsquo;t really answer the hard questions, and it wasn&amp;rsquo;t updated since watchOS 4; &lt;a href="https://gumroad.com/l/hwwatchos"&gt;Paul Hudson has another&lt;/a&gt;, and that&amp;rsquo;s pretty much it.&lt;/p&gt;

&lt;p&gt;I&amp;rsquo;ve tried to figure out some things myself, but some questions are left unanswered. If you know how to solve anything better than I&amp;nbsp;did, please let me know in the comments.&lt;/p&gt;
&lt;hr /&gt;

&lt;h2&gt;The two frameworks&lt;/h2&gt;

&lt;p&gt;watchOS SDK launched first in 2015 with a new UI&amp;nbsp;framework called WatchKit. It was a very different framework than what we knew from macOS and iOS, a framework specifically designed for the Watch and all its inherent and temporary limitations &amp;ndash; and also limited in what it could do and what you could do with it. It got people excited, but also very quickly frustrated, once they&amp;rsquo;ve run into these limitations. It didn&amp;rsquo;t help that Apple&amp;rsquo;s own apps were very obviously doing some things in the UI&amp;nbsp;that weren&amp;rsquo;t possible to external developers, clearly using some internal APIs Apple needed to build more powerful apps, but which they didn&amp;rsquo;t want to share with us.&lt;/p&gt;

&lt;p&gt;So of course the hearts of Watch developers started beating faster when we heard the brief mention &amp;ldquo;… and a new native UI&amp;nbsp;framework&amp;rdquo; during the &lt;a href="https://youtu.be/psL_5RIBqnY?t=950"&gt;2019 keynote&lt;/a&gt; &amp;ndash; said almost as if we were supposed to miss it. Of course about two hours later we&amp;rsquo;ve learned that this new framework was SwiftUI, built not only for watchOS (although &lt;a href="https://twitter.com/stroughtonsmith/status/1137056755920396295"&gt;that&amp;rsquo;s how the whole thing started&lt;/a&gt;, apparently!), but for all Apple platforms. A thing that would &lt;a href="/2019/12/16/swiftui-quotes/"&gt;completely change&lt;/a&gt; Apple platform development.&lt;/p&gt;

&lt;p&gt;However, as people who have rushed to try out this new framework quickly discovered, SwiftUI&amp;nbsp;as released in the iOS 13 SDK was &amp;ldquo;&lt;a href="https://www.youtube.com/watch?v=xgya_xBlYRA&amp;amp;t=1492"&gt;a pretty solid version 0.7&lt;/a&gt;&amp;rdquo; &amp;ndash; a massive step forward of course, especially on watchOS, but still more of a beta. The &amp;ldquo;version 2.0&amp;rdquo; released this June seems like a very decent update, but it&amp;rsquo;s not stable yet and at this point it&amp;rsquo;s still unclear if it solves most of the issues that people had with the first release.&lt;/p&gt;

&lt;p&gt;So here we are, with two frameworks, an old one that&amp;rsquo;s very limited, and a new one that&amp;rsquo;s still kind of unfinished. Which one should I&amp;nbsp;use to build an app right now? Which one should I&amp;nbsp;learn?&lt;/p&gt;

&lt;p&gt;Well… &lt;a href="https://www.youtube.com/watch?v=vqgSO8_cRio"&gt;¿Por qué no los dos?&lt;/a&gt; 😎&lt;/p&gt;

&lt;p&gt;Seriously speaking, my intuition tells me that if you&amp;rsquo;re starting to build a new Watch app right now, it probably makes more sense to go with SwiftUI. Since the platform was much more limited previously, the gain from using SwiftUI&amp;nbsp;compared to the old way is probably much bigger than on iOS, and while on iOS you&amp;rsquo;re likely to often run into things you can&amp;rsquo;t do with SwiftUI&amp;nbsp;that you could with UIKit, it&amp;rsquo;s probably more of the opposite on watchOS.&lt;/p&gt;

&lt;p&gt;But since I&amp;nbsp;haven&amp;rsquo;t really built anything with the plain old WatchKit before, I&amp;nbsp;still want to have this experience, if only just to have a broader knowledge of the platform. So let&amp;rsquo;s build the app in the classic WatchKit now, and then we&amp;rsquo;ll do the same thing again in SwiftUI&amp;nbsp;and compare.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Design&lt;/h2&gt;

&lt;p&gt;Before you start writing code, it&amp;rsquo;s good to spend some time first designing and thinking about what you&amp;rsquo;re actually planning to build, how it should look and work and why. Perhaps even away from the computer, with a pen and a piece of paper. This is true for any kind of app, but especially for Apple Watch apps. Watch apps are designed for extremely quick interactions, on the order of a few seconds &amp;ndash; the user should be able to open your app, find the information they need and leave, without spending too long trying to understand your app&amp;rsquo;s layout and navigation. So it&amp;rsquo;s worth spending some time to make sure that your app really works this way.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;started by listing the things the user might want to see and do in this app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;see the current pollution level&lt;/li&gt;
&lt;li&gt;view a history chart with earlier values&lt;/li&gt;
&lt;li&gt;select the monitoring station&lt;/li&gt;
&lt;li&gt;select the specific parameter&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Next, I&amp;nbsp;looked through the apps I&amp;nbsp;have installed on my Watch (mostly first-party ones) to get some idea and inspiration about what layout and navigation they use and find something somewhat similar to what I&amp;nbsp;want to build. In the end, I&amp;nbsp;think the Activity app looked closest to what I&amp;nbsp;imagined: a big circle with the 3 colored arcs, showing you the most important information at a glance, and then by scrolling down you can access some additional information like per-hour charts, steps count and so on.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;got a piece of paper and did some planning and drawing, and ended up with something like this&amp;nbsp;:)&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/design-draft.jpg"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/design-draft.jpg?1721484643" width="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The option 2 is what I&amp;nbsp;went with &amp;ndash; I&amp;nbsp;figured that having a big colored circle with the PM value on the first screen would fit the idea of making the app &amp;ldquo;glanceable&amp;rdquo; by providing the most important information first, same as in the Activity app.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;managed to build the complete app in about 4-5 days. I&amp;nbsp;want to describe the whole process here, but this got a bit long so I&amp;nbsp;broke it into two parts: the main screen and the list that lets you select the measuring station.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;The status screen&lt;/h2&gt;

&lt;p&gt;Like from the beginning, I&amp;rsquo;m doing anything in the open, in a &lt;a href="https://github.com/mackuba/SmogWatch"&gt;repo on GitHub&lt;/a&gt; &amp;ndash; licensed under WTFPL, so you&amp;rsquo;re free to reuse any piece of code for anything you want. If you want to follow with me in Xcode, here is the &lt;a href="https://github.com/mackuba/SmogWatch/tree/post3_start"&gt;commit from which we start&lt;/a&gt;, and here&amp;rsquo;s the &lt;a href="https://github.com/mackuba/SmogWatch/tree/post3"&gt;complete version&lt;/a&gt; (after this first part of the tutorial).&lt;/p&gt;

&lt;p&gt;Open our &lt;code&gt;Interface.storyboard&lt;/code&gt;. Let&amp;rsquo;s remove the notification scenes added by the template for now to clear up some space.&lt;/p&gt;

&lt;p&gt;Let&amp;rsquo;s start with something I&amp;nbsp;completely missed until the moment I&amp;nbsp;had the app finished and this blog post ready to ship &amp;ndash; you can learn from my mistakes 😅 Here it is: a Watch app needs to have a title label!&lt;/p&gt;

&lt;p&gt;Look at some of the system apps &amp;ndash; they all show their name in the top bar next to the clock:&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/apps_appstore.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/apps_appstore.png?1721484643" width="156"&gt;&lt;/a&gt; &lt;a href="/images/posts/watchkit3/apps_activity.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/apps_activity.png?1721484643" width="156"&gt;&lt;/a&gt; &lt;a href="/images/posts/watchkit3/apps_podcasts.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/apps_podcasts.png?1721484643" width="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is not &lt;code&gt;CFBundleDisplayName&lt;/code&gt; or something else that happens automatically &amp;ndash; you need to set it as the title of your main interface controller. Set it either in the Attributes inspector (the &amp;ldquo;Title&amp;rdquo; field), or by double-clicking the top bar area on the storyboard (the title won&amp;rsquo;t be displayed there, only when the app runs).&lt;/p&gt;

&lt;p&gt;If you have some kind of brand color that you use in your icon and logo that the users associate with your app or service, you should also set it on the storyboard as the &amp;ldquo;&lt;a href="https://developer.apple.com/documentation/watchkit/storyboard_elements/building_watchos_app_interfaces_using_the_storyboard/setting_the_app_s_tint_color"&gt;Global Tint&lt;/a&gt;&amp;rdquo; (in the File inspector &amp;ndash; first tab), then it will be used to color that title label. I&amp;rsquo;m going to keep the default gray tint color.&lt;/p&gt;

&lt;h3&gt;The value circle&lt;/h3&gt;

&lt;p&gt;Now, we&amp;rsquo;re going to work on the thing that the user will see when they first open the app &amp;ndash; the big circle showing the measured value.&lt;/p&gt;

&lt;p&gt;Drag the first item from the Library to the view &amp;ndash; a &lt;strong&gt;label&lt;/strong&gt;. Make it say &lt;strong&gt;&amp;ldquo;PM10&amp;rdquo;&lt;/strong&gt; and use the &lt;strong&gt;Title 3&lt;/strong&gt; font style. In general, you should try to use one of the 10 &lt;a href="https://developer.apple.com/design/human-interface-guidelines/watchos/visual-design/typography/#text-styles"&gt;standard semantic font styles&lt;/a&gt; if possible, to take advantage of Dynamic Type and have all fonts adapt to user&amp;rsquo;s chosen text size.&lt;/p&gt;

&lt;p&gt;Notice that you can&amp;rsquo;t really position the label wherever you want in the view by dragging &amp;ndash; the way you position things in WatchKit is by using the &amp;ldquo;&lt;strong&gt;Alignment&lt;/strong&gt;&amp;rdquo; and &amp;ldquo;&lt;strong&gt;Size&lt;/strong&gt;&amp;rdquo; properties in the Inspector, and by wrapping items in &lt;strong&gt;Groups&lt;/strong&gt; which act like stack views on macOS/iOS. Position this label to &lt;strong&gt;Center&lt;/strong&gt; horizontally. (In this case, you could achieve the same effect by setting its size to 100% of the container width and then setting its internal text alignment &amp;ndash; the one in the &amp;ldquo;Label&amp;rdquo; section &amp;ndash; to centered.)&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/status1.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/status1.png?1721484643" width="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add another &lt;strong&gt;label&lt;/strong&gt; (or you can make a copy of the first one), make it say &lt;strong&gt;&amp;ldquo;Good&amp;rdquo;&lt;/strong&gt;, use the same &lt;strong&gt;Title 3&lt;/strong&gt; font and also position it to &lt;strong&gt;Center&lt;/strong&gt; horizontally.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/status2.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/status2.png?1721484643" width="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, we need the main circle. We could use an image, but we can also use a group and set its background and corner radius so that the four corners form a circle.&lt;/p&gt;

&lt;p&gt;Drag a &lt;strong&gt;group&lt;/strong&gt; into the view between the two labels, and a &lt;strong&gt;label&lt;/strong&gt; inside it. Set the group&amp;rsquo;s &lt;strong&gt;Color&lt;/strong&gt; to e.g. light gray or green, and the label&amp;rsquo;s text to some number like &amp;ldquo;32&amp;rdquo;, its font to &lt;strong&gt;System 42&lt;/strong&gt; (we won&amp;rsquo;t be using Dynamic Type for this one since it should be large enough for everyone), its &lt;strong&gt;Text Color&lt;/strong&gt; to &lt;strong&gt;black with 80% alpha&lt;/strong&gt;, and position it to &lt;strong&gt;Center/Center&lt;/strong&gt; inside the group.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/status3.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/status3.png?1721484643" width="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now, we run into two problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;first, I&amp;rsquo;d like to position the top &amp;amp; bottom labels and the circle so that they always take whole height of the screen: the two labels take as much as they need, and the circle takes whatever is left&lt;/li&gt;
&lt;li&gt;second, I&amp;rsquo;d like to have the circle always keep a 1:1 aspect ratio, so that its width is equal to whatever height it&amp;rsquo;s allowed to have&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Unfortunately, this doesn&amp;rsquo;t seem to be possible in WatchKit. You can position something at the top and bottom, but there isn&amp;rsquo;t really such width or height setting as &amp;ldquo;take whatever is left&amp;rdquo;. There is also no aspect ratio constraint (since there are no constraints in general). You can only size a thing to fit its content (not helpful here, since we want to make the circle big, with plenty of space around the number), size it relatively to the container &amp;ndash; but ignoring whatever else is inside it, or use a fixed size.&lt;/p&gt;

&lt;p&gt;So I&amp;nbsp;think the best we can do here is to use a fixed size. Make the circle group &lt;strong&gt;100×100&lt;/strong&gt; in size and give it a &lt;strong&gt;Corner Radius&lt;/strong&gt; of 48. Also &lt;strong&gt;center&lt;/strong&gt; the group horizontally in the view.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/status4.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/status4.png?1721484643" width="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice that it would probably be nice to have some more spacing between the labels and the circle. We can add spacing using groups. The top level view is actually a group itself &amp;ndash; if you select the &lt;strong&gt;Interface Controller&lt;/strong&gt;, you can set e.g. its background, insets and spacing &amp;ndash; but you can only set one consistent spacing per group, and we might want to use different spacings further down.&lt;/p&gt;

&lt;p&gt;So instead lets wrap the three elements in a new &lt;strong&gt;vertical group&lt;/strong&gt; &amp;ndash; you can do that by selecting them and using the &amp;ldquo;embed&amp;rdquo; button in the bottom toolbar:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/embed.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/embed.png?1721484643" width="130"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now give the group a &lt;strong&gt;Spacing&lt;/strong&gt; of 7. Also, override the default &lt;strong&gt;Insets&lt;/strong&gt; and set &lt;strong&gt;Top&lt;/strong&gt; to 8 in order to add some margin from the title in the title bar &amp;ndash; which you don&amp;rsquo;t see now, but you will once you run the app.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/status5.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/status5.png?1721484643" width="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Looks good! Because of the limitations I&amp;rsquo;ve mentioned it won&amp;rsquo;t always look perfect depending on the watch and selected text size, but it seems to work well enough in most cases. The difference between the smallest and largest text sizes isn&amp;rsquo;t as drastic here as on iOS.&lt;/p&gt;

&lt;h3&gt;Interface controller&lt;/h3&gt;

&lt;p&gt;Now, let&amp;rsquo;s write some code to show the right values. We&amp;rsquo;ll be adding it in our &lt;code&gt;InterfaceController&lt;/code&gt;, which is WatchKit&amp;rsquo;s equivalent of a view controller. First, add these 3 outlets:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;@IBOutlet var valueCircle: WKInterfaceGroup!
@IBOutlet var valueLabel: WKInterfaceLabel!
@IBOutlet var gradeLabel: WKInterfaceLabel!
&lt;/pre&gt;

&lt;p&gt;Connect them on the storyboard to the elements we&amp;rsquo;ve added: the circle, the label inside it, and the bottom label, respectively. We&amp;rsquo;ll also need a reference to the &lt;code&gt;DataStore&lt;/code&gt; that we&amp;rsquo;ll load values from (see &lt;a href="/2019/03/06/watchkit-adventure-2-mvc/#data_store"&gt;Episode 2&lt;/a&gt;).&lt;/p&gt;

&lt;pre class="brush: swift"&gt;let dataStore = DataStore()
&lt;/pre&gt;

&lt;p&gt;Now we need to have some logic for how to interpret the raw numbers we get from the web service &amp;ndash; how much is good enough, and how much is not? Let&amp;rsquo;s add an enum &lt;code&gt;SmogLevel&lt;/code&gt; which will cover this.&lt;/p&gt;

&lt;p&gt;⚠️ Note: the ranges configured below are my personal subjective interpretation of how I&amp;nbsp;feel about the given pollution levels. They are somewhat skewed by the fact that the smog levels in southern Poland during winter are more often above the safety limits than within them (although it got much better in the last year or two, fortunately). In theory, anything above 50 µg/m&lt;sup&gt;3&lt;/sup&gt; should be considered bad.&lt;/p&gt;

&lt;pre class="brush: swift"&gt;enum SmogLevel: Int, CaseIterable {
    case great = 30,
        good = 50,
        poor = 75,
        prettyBad = 100,
        reallyBad = 150,
        horrible = 200,
        extremelyBad = 10000,
        unknown = -1

    static func levelForValue(_ value: Double) -&amp;gt; SmogLevel {
        let levels = SmogLevel.allCases
        return levels.first(where: { Double($0.rawValue) &amp;gt;= value }) ?? .unknown
    }

    var title: String {
        switch self {
        case .great: return "Great"
        case .good: return "Good"
        case .poor: return "Poor"
        case .prettyBad: return "Pretty Bad"
        case .reallyBad: return "Really Bad"
        case .horrible: return "Horrible"
        case .extremelyBad: return "Extremely Bad"
        case .unknown: return "Unknown"
        }
    }
}
&lt;/pre&gt;

&lt;p&gt;We&amp;rsquo;ll also use different colors for each range &amp;ndash; for simplicity, we&amp;rsquo;ll use the HSB system and use colors of the same saturation and brightness, differing only in the hue.&lt;/p&gt;

&lt;pre class="brush: swift"&gt;var color: UIColor {
    let hue: CGFloat

    switch self {
    case .great: hue = 120
    case .good: hue = 80
    case .poor: hue = 55
    case .prettyBad: hue = 35
    case .reallyBad: hue = 10
    case .horrible: hue = 0
    case .extremelyBad: hue = 280
    case .unknown: hue = 0
    }

    if self == .unknown {
        return UIColor.lightGray
    } else {
        return UIColor(hue: hue/360, saturation: 0.95, brightness: 0.9, alpha: 1.0)
    }
}
&lt;/pre&gt;

&lt;p&gt;Finally, let&amp;rsquo;s add a method that reloads the values in the UI, and call this method from &lt;code&gt;awake(withContext:)&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;func updateDisplayedData() {
    let smogLevel: SmogLevel

    if let amount = dataStore.currentLevel {
        let displayedValue = Int(amount.rounded())
        valueLabel.setText(String(displayedValue))
        smogLevel = SmogLevel.levelForValue(amount)
    } else {
        valueLabel.setText("?")
        smogLevel = .unknown
    }

    valueCircle.setBackgroundColor(smogLevel.color)
    gradeLabel.setText(smogLevel.title)
}
&lt;/pre&gt;

&lt;p&gt;Looks pretty nice already, doesn&amp;rsquo;t it? (This is a real live value from a nearby air monitoring station.)&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/status6.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/status6.png?1721484643" width="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;Last update time&lt;/h3&gt;

&lt;p&gt;We could also add a label showing last update time, so that you can easily see if the value is up to date.&lt;/p&gt;

&lt;p&gt;Add another group below the previous one (it&amp;rsquo;s sometimes hard to position a new thing in the right place, so that it&amp;rsquo;s added at the root level and not into an exising group &amp;ndash; you need to have some patience), make sure that its &lt;strong&gt;Layout&lt;/strong&gt; is &lt;strong&gt;Horizontal&lt;/strong&gt;. Add two labels inside, which will be arranged left to right. Give them a font style of &lt;strong&gt;Caption 1&lt;/strong&gt; and make them say &amp;ldquo;Updated:&amp;rdquo; and e.g. &amp;ldquo;15:00&amp;rdquo; (we&amp;rsquo;ll use a formatter to print time in the right format).&lt;/p&gt;

&lt;p&gt;Change the group&amp;rsquo;s &lt;strong&gt;Width&lt;/strong&gt; to &lt;strong&gt;Size to fit content&lt;/strong&gt; and its &lt;strong&gt;Horizontal Alignment&lt;/strong&gt; to &lt;strong&gt;Center&lt;/strong&gt;. Note, that&amp;rsquo;s something different than horizontal layout: layout means how the group arranges &lt;em&gt;its child elements&lt;/em&gt;, and alignment means how &lt;em&gt;the group positions itself&lt;/em&gt; inside the parent, which is the root container here. (For extra confusion, labels also have an additional text alignment property…)&lt;/p&gt;

&lt;p&gt;Override also the group&amp;rsquo;s &lt;strong&gt;insets&lt;/strong&gt; and set the &lt;strong&gt;Top&lt;/strong&gt; and &lt;strong&gt;Bottom&lt;/strong&gt; to 2 to keep some spacing from the &amp;ldquo;Good&amp;rdquo; label and from what we&amp;rsquo;ll add below.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/updated_at.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/updated_at.png?1721484643" width="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You might notice a &lt;code&gt;WKInterfaceDate&lt;/code&gt; item in the library that seems to be a customized label for showing date:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/date_label.jpg"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/date_label.jpg?1721484643" width="625"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why not use that one, sounds like a perfect place to use it? I&amp;rsquo;ve actually tried to do that at first, but I&amp;nbsp;couldn&amp;rsquo;t find a way to set its date… Turns out, if you read the description carefully, you can see it says that it&amp;rsquo;s only meant for showing &lt;em&gt;current&lt;/em&gt; date 😉 It&amp;rsquo;s a way to avoid using an &lt;code&gt;NSTimer&lt;/code&gt; to keep the date label updated if it&amp;rsquo;s meant to show the current time which constantly changes &amp;ndash; but in our case, we&amp;rsquo;re showing a past time that will only change when the data is refreshed. So we can just use a plain label and manually format the time once using a &lt;code&gt;DateFormatter&lt;/code&gt; &amp;ndash; which also gives us more options for customizing the format than the date label would.&lt;/p&gt;

&lt;p&gt;In the interface controller, add two more outlets and connect them on the storyboard to the right label and the whole group:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;@IBOutlet var updatedAtLabel: WKInterfaceLabel!
@IBOutlet var updatedAtRow: WKInterfaceGroup!
&lt;/pre&gt;

&lt;p&gt;We&amp;rsquo;ll also need the date formatter. I&amp;rsquo;ve been thinking what would be the best way to show the time: should I&amp;nbsp;include just the hour, day, or full date? But then I&amp;nbsp;realized: if the data is more than a few hours old, it&amp;rsquo;s useless anyway! What does it matter if the pollution was high or low a week ago?&lt;/p&gt;

&lt;p&gt;So there are really two cases: either the data was updated at most a few hours ago today, or it&amp;rsquo;s e.g. 2am and it was updated shortly before midnight yesterday. If it&amp;rsquo;s more than a few hours old, we&amp;rsquo;re going to treat it as if we had no data at all, because doing otherwise could just be misleading.&lt;/p&gt;

&lt;p&gt;So we&amp;rsquo;re going to use two different time formats: when the data was updated earlier today, we&amp;rsquo;ll only show the time, and if it was yesterday, we&amp;rsquo;ll add the short day name for clarity. We&amp;rsquo;re using the &lt;code&gt;DateFormatter.dateFormat&lt;/code&gt; method here which generates an appropriate specific format string that includes the listed fields for your current locale &amp;ndash; so depending on your settings the hour might be in the 24- or 12-hour system, 0-padded or not, and so on.&lt;/p&gt;

&lt;pre class="brush: swift"&gt;let dateFormatter = DateFormatter()

let shortTimeFormat = DateFormatter.dateFormat(
  fromTemplate: "j:m", options: 0, locale: Locale.current
)
let longTimeFormat = DateFormatter.dateFormat(
  fromTemplate: "E j:m", options: 0, locale: Locale.current
)
&lt;/pre&gt;

&lt;p&gt;We&amp;rsquo;ll also hide the whole &lt;code&gt;updatedAtRow&lt;/code&gt; if we have no data to show. The updated &lt;code&gt;updateDisplayedData()&lt;/code&gt; method will look like this:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;func updateDisplayedData() {
    var smogLevel: SmogLevel = .unknown
    var valueText = "?"

    if let updatedAt = dataStore.lastMeasurementDate {
        updatedAtRow.setHidden(false)

        dateFormatter.dateFormat = isSameDay(updatedAt) ? shortTimeFormat : longTimeFormat
        updatedAtLabel.setText(dateFormatter.string(from: updatedAt))

        if let amount = dataStore.currentLevel, Date().timeIntervalSince(updatedAt) &amp;lt; 6 * 3600 {
            smogLevel = SmogLevel.levelForValue(amount)
            valueText = String(Int(amount.rounded()))
        }
    } else {
        updatedAtRow.setHidden(true)
    }

    valueCircle.setBackgroundColor(smogLevel.color)
    valueLabel.setText(valueText)
    gradeLabel.setText(smogLevel.title)
}

func isSameDay(_ date: Date) -&amp;gt; Bool {
    let calendar = Calendar.current
    let updatedDay = calendar.component(.day, from: date)
    let currentDay = calendar.component(.day, from: Date())

    return updatedDay == currentDay
}
&lt;/pre&gt;

&lt;p&gt;The app should now look like this:&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/status7.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/status7.png?1721484643" width="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;History chart&lt;/h2&gt;

&lt;p&gt;Now we&amp;rsquo;re getting to the exciting part: we&amp;rsquo;re going to do some drawing!&lt;/p&gt;

&lt;p&gt;At this point I&amp;nbsp;took a break from coding to research the options. The main problems: there is no &lt;code&gt;UIView.drawRect:&lt;/code&gt; in WatchKit, and there&amp;rsquo;s no &lt;code&gt;UIScrollView&lt;/code&gt; that would let you scroll parts of the screen independently. I&amp;nbsp;initially imagined some kind of chart that can be scrolled horizontally to show more points than fit on the screen. I&amp;nbsp;could possibly simulate the scrolling with some horrible hacks, but I&amp;rsquo;ve realized that this would probably be both unnecessary and possibly confusing in terms of UX. I&amp;nbsp;usually don&amp;rsquo;t care about the numbers from yesterday or earlier, and if I&amp;nbsp;do, I&amp;nbsp;can check them on the web &amp;ndash; it would be enough to show the last few points, say, 6-8, so that you get the idea of what the trend is and if you should expect the value to rise or fall.&lt;/p&gt;

&lt;p&gt;Like I&amp;nbsp;said, there is no &lt;code&gt;drawRect:&lt;/code&gt; &amp;ndash; however, there is a &lt;code&gt;WKInterfaceImage&lt;/code&gt; and it accepts dynamically generated images, and we can generate one with Core Graphics. &lt;code&gt;UIGraphicsImageRenderer&lt;/code&gt; is not available, but you can use the older function-based API&amp;nbsp;and capture an image using &lt;code&gt;UIGraphicsGetImageFromCurrentImageContext()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Alternatively, I&amp;nbsp;could use a &lt;code&gt;WKInterfaceSKScene&lt;/code&gt; and draw the chart using SpriteKit &amp;ndash; it&amp;rsquo;s a framework made mainly for games, but Apple have said explicitly in their WatchKit talks that it can be used for things like animations inside apps.&lt;/p&gt;

&lt;p&gt;That said, I&amp;nbsp;have zero experience with SpriteKit, so I&amp;nbsp;would have to spend some additional time learning it from scratch &amp;ndash; and I&amp;nbsp;have a feeling that this would be an overkill for something like this. However, it&amp;rsquo;s still something to keep in mind if you need to do some more advanced drawing on watchOS, or especially animations. (Although at this point building it in SwiftUI&amp;nbsp;might be a better idea &amp;ndash; even if you just embed one tiny piece of it in a classic WatchKit interface.)&lt;/p&gt;

&lt;h3&gt;Getting the data&lt;/h3&gt;

&lt;p&gt;First, we&amp;rsquo;ll need to store some more data &amp;ndash; right now we only store a single point (a value and a date). Luckily, we don&amp;rsquo;t need to change that much &amp;ndash; we&amp;rsquo;re already getting all points from the given day in the response, it&amp;rsquo;s just that we&amp;rsquo;re discarding all except the last one, and now we need to keep them. In some cases we might also need to load data from the previous day, so that you don&amp;rsquo;t see an empty chart at 2am &amp;ndash; normally we&amp;rsquo;ll simply remember old points from previous requests, but this will be needed later once we add a way to change the station we get data from.&lt;/p&gt;

&lt;p&gt;However, this is a lot of code and it&amp;rsquo;s kind of not relevant to the topic of building a UI, so just assume that we now have an updated &lt;code&gt;DataStore&lt;/code&gt; with an interface like this:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;struct DataPoint {
    let date: Date
    let value: Double
}

class DataStore {
    var currentLevel: Double? {
        points.last?.value
    }

    var lastMeasurementDate: Date? {
        points.last?.date
    }

    var points: [DataPoint] { ... }  // keeps last 8 points
}
&lt;/pre&gt;

&lt;p&gt;You can see the full (final) implementation on GitHub here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/mackuba/SmogWatch/blob/master/SmogWatch%20WatchKit%20Extension/DataStore.swift"&gt;DataStore&lt;/a&gt; &amp;ndash; stores and retrieves the data to/from &lt;code&gt;UserDefaults&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mackuba/SmogWatch/blob/master/SmogWatch%20WatchKit%20Extension/KrakowPiosDataLoader.swift"&gt;KrakowPiosDataLoader&lt;/a&gt; loads the data from Krakow&amp;rsquo;s regional air monitoring service&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mackuba/SmogWatch/blob/master/SmogWatch%20WatchKit%20Extension/DataManager.swift"&gt;DataManager&lt;/a&gt; decides when to load which data, and makes sure that complications are reloaded when needed&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Or alternatively, copy the version of &lt;code&gt;DataStore&lt;/code&gt; and &lt;code&gt;KrakowPiosDataLoader&lt;/code&gt; from &lt;a href="https://github.com/mackuba/SmogWatch/commit/319626dfb9c543dea38a3fb5fa64750204908608"&gt;this commit&lt;/a&gt; that adds just the changes needed for this part.&lt;/p&gt;

&lt;p&gt;It would also be nice to be notified in the UI&amp;nbsp;when the loader loads the data in the background. To achieve that, we&amp;rsquo;ll send a notification from &lt;code&gt;ExtensionDelegate&lt;/code&gt; when the data is received:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;NotificationCenter.default.post(name: DataStore.dataLoadedNotification, object: nil)
&lt;/pre&gt;

&lt;p&gt;And in the InterfaceController, we&amp;rsquo;ll subscribe to this notification:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;override func awake(withContext context: Any?) {
    super.awake(withContext: context)

    updateDisplayedData()

    NotificationCenter.default.addObserver(
        forName: DataStore.dataLoadedNotification,
        object: nil,
        queue: nil
    ) { _ in
        self.updateDisplayedData()
    }
}
&lt;/pre&gt;

&lt;h3&gt;Adding a chart container&lt;/h3&gt;

&lt;p&gt;Add another group into the view, below the update time label, and add an &lt;strong&gt;Image&lt;/strong&gt; into the group. Set the group&amp;rsquo;s &lt;strong&gt;Insets&lt;/strong&gt; to 15 at the top and 15 at the bottom. Leave the size settings at the default (size to fit content + the group filling whole container width) &amp;ndash; we&amp;rsquo;ll specify the image dimensions in the code.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/chart0.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/chart0.png?1721484643" width="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bind the image on the storyboard to an outlet in the &lt;code&gt;InterfaceController&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;@IBOutlet var chartView: WKInterfaceImage!
&lt;/pre&gt;

&lt;p&gt;One problem with WatchKit that I&amp;rsquo;ve run into a few times is that all the view objects &lt;strong&gt;only have setters and no getters&lt;/strong&gt;. It might have something to do with the fact that the UI&amp;nbsp;is technically running in another process, or rather a subprocess now &lt;a href="/2018/12/18/watchkit-adventure-1-the-big-picture/#whats-changed"&gt;since watchOS 4&lt;/a&gt;. So reading information from the view would involve something more than simply accessing some place in the process memory. If you look at the documentation of e.g. &lt;a href="https://developer.apple.com/documentation/watchkit/wkinterfacelabel"&gt;WKInterfaceLabel&lt;/a&gt;, you can see that it has methods like &lt;code&gt;setText&lt;/code&gt;, &lt;code&gt;setTextColor&lt;/code&gt; and other inherited from &lt;a href="https://developer.apple.com/documentation/watchkit/wkinterfaceobject"&gt;WKInterfaceObject&lt;/a&gt; &amp;ndash; but they don&amp;rsquo;t have matching getters like &lt;code&gt;textColor&lt;/code&gt;. Which means that you can&amp;rsquo;t ask any view element at runtime what its current text or color is, and specifically you can&amp;rsquo;t ask it what its current size is. So if we want to render the image in code for a specific size, this size needs to be hardcoded in the code.&lt;/p&gt;

&lt;p&gt;The only exception is that an interface controller can call the method &lt;code&gt;self.contentFrame&lt;/code&gt;, which returns the frame of the whole view it manages &amp;ndash; which we will be using here to at least get the width of the rendered image, since that will depend on the size of the watch (we&amp;rsquo;ll keep the same height for all sizes).&lt;/p&gt;

&lt;h3&gt;Drawing the chart&lt;/h3&gt;

&lt;p&gt;This will be quite a lot of code, so let&amp;rsquo;s put it in a separate class to avoid the Massive View Controller pattern. However, there&amp;rsquo;s an important difference here from iOS and UIKit &amp;ndash; &lt;strong&gt;you&amp;rsquo;re not really supposed to subclass view classes in WatchKit&lt;/strong&gt;. Again, if you look at the documentation for &lt;a href="https://developer.apple.com/documentation/watchkit/wkinterfacelabel"&gt;WKInterfaceLabel&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/watchkit/wkinterfaceimage"&gt;WKInterfaceImage&lt;/a&gt; etc., they all say: &amp;ldquo;&lt;em&gt;Do not subclass or create instances of this class yourself&lt;/em&gt;&amp;rdquo;.&lt;/p&gt;

&lt;p&gt;But we can always have a separate class that just handles rendering a chart to a &lt;code&gt;UIImage&lt;/code&gt;, and that&amp;rsquo;s what we&amp;rsquo;re going to do:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;class ChartRenderer {

    let chartFontAttributes: [NSAttributedString.Key: Any] = [
        .foregroundColor: UIColor.lightGray,
        .font: UIFont.systemFont(ofSize: 8.0)
    ]

    let leftMargin: CGFloat = 17
    let bottomMargin: CGFloat = 10
    let rightMargin: CGFloat = 10

    func generateChart(points: [DataPoint], size chartSize: CGSize) -&amp;gt; UIImage? {
        ...
    }
}
&lt;/pre&gt;

&lt;p&gt;We&amp;rsquo;re going to call this class from &lt;code&gt;InterfaceController&lt;/code&gt;, at the end of the &lt;code&gt;updateDisplayedData&lt;/code&gt; method:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;let chartRenderer = ChartRenderer()

let points = dataStore.points
let chartSize = CGSize(width: self.contentFrame.width, height: 65.0)

if points.count &amp;gt;= 2, let chart = chartRenderer.generateChart(points: points, size: chartSize) {
    chartView.setImage(chart)
    chartView.setHidden(false)
} else {
    chartView.setHidden(true)
}
&lt;/pre&gt;

&lt;p&gt;In the &lt;code&gt;generateChart&lt;/code&gt; method, first we need some boilerplate to get the context and then capture the image at the end:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;UIGraphicsBeginImageContextWithOptions(chartSize, true, 0)
guard let context = UIGraphicsGetCurrentContext() else { return nil }

let width = chartSize.width
let height = chartSize.height

// ... drawing ...

let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image
&lt;/pre&gt;

&lt;p&gt;Next, let&amp;rsquo;s draw the Y axis on the left and the X at the bottom:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;context.setStrokeColor(UIColor.lightGray.cgColor)
context.move(to: CGPoint(x: leftMargin, y: 0))
context.addLine(to: CGPoint(x: leftMargin, y: height - bottomMargin))
context.addLine(to: CGPoint(x: width - rightMargin + 2, y: height - bottomMargin))
context.drawPath(using: .stroke)
&lt;/pre&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/chart1.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/chart1.png?1721484643" width="160"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, we&amp;rsquo;ll print the min and max value on the left side of the Y axis. For that we&amp;rsquo;ll make a helper function &lt;code&gt;drawText&lt;/code&gt; that can print text towards the left or right, or centered on the given point (in this case, these two labels will be right-aligned):&lt;/p&gt;

&lt;pre class="brush: swift"&gt;enum TextAlignment {
    case left, right, center
}

func drawText(_ text: String, x: CGFloat, y: CGFloat, alignment: TextAlignment = .left) {
    var leftPosition = x

    if alignment != .left {
        let width = text.size(withAttributes: chartFontAttributes).width
        leftPosition -= (alignment == .right) ? ceil(width) : ceil(width / 2)
    }

    text.draw(at: CGPoint(x: leftPosition, y: y), withAttributes: chartFontAttributes)
}
&lt;/pre&gt;

&lt;p&gt;And we print the values like this:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;let values = points.map { $0.value }
let minValue = Int(values.min()!.rounded())
let maxValue = Int(values.max()!.rounded())

drawText(String(maxValue),
         x: leftMargin - 2,
         y: -2,
         alignment: .right)
drawText(String(minValue),
         x: leftMargin - 2,
         y: height - bottomMargin - 10,
         alignment: .right)
&lt;/pre&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/watchkit3/chart2.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/chart2.png?1721484643" width="166"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Finally, we&amp;rsquo;ll calculate the position of each point based on the values from the &lt;code&gt;DataStore&lt;/code&gt; and draw a line through them, and print matching hour labels exactly below the points, below the X axis. We&amp;rsquo;ll need two more helper functions for this:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;func chartPosition(forPointAt index: Int, from values: [Double], chartSize: CGSize) -&amp;gt; CGPoint {
    let xPadding: CGFloat = 3
    let yPadding: CGFloat = 3
    let innerWidth = chartSize.width - leftMargin - 2 * xPadding - rightMargin
    let innerHeight = chartSize.height - bottomMargin - 2 * yPadding

    let minValue = values.min()!
    let maxValue = values.max()!

    let xOffset = innerWidth * CGFloat(index) / CGFloat(values.count - 1)
    let yOffset = innerHeight * CGFloat(values[index] - minValue) / CGFloat(maxValue - minValue)

    return CGPoint(
        x: leftMargin + xPadding + xOffset,
        y: chartSize.height - bottomMargin - yPadding - yOffset
    )
}

func hour(for point: DataPoint) -&amp;gt; Int {
    return Calendar.current.component(.hour, from: point.date)
}
&lt;/pre&gt;

&lt;p&gt;Next, we set some line properties to make it look better at the joints:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;context.setLineWidth(1.0)
context.setLineCap(.round)
context.setLineJoin(.bevel)
&lt;/pre&gt;

&lt;p&gt;And we draw the line by starting at the first point and then jumping through the rest one by one (while also printing the hours below):&lt;/p&gt;

&lt;pre class="brush: swift"&gt;let firstPosition = chartPosition(forPointAt: 0, from: values, chartSize: chartSize)
context.move(to: firstPosition)

drawText(String(hour(for: points[0])),
         x: firstPosition.x,
         y: height - bottomMargin,
         alignment: .center)

for i in 1..&amp;lt;values.count {
    let position = chartPosition(forPointAt: i, from: values, chartSize: chartSize)
    context.addLine(to: position)

    drawText(String(hour(for: points[i])),
             x: position.x,
             y: height - bottomMargin,
             alignment: .center)
}

context.setStrokeColor(UIColor.white.cgColor)
context.drawPath(using: .stroke)
&lt;/pre&gt;

&lt;p&gt;Voila 😄&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/chart3.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/chart3.png?1721484643" width="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(I&amp;nbsp;suppose we could use a time formatter to print the hour labels here in a 12-hour format if the device uses one… but let&amp;rsquo;s leave that as an exercise for the reader&amp;nbsp;;)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Testing on different screen sizes&lt;/h2&gt;

&lt;p&gt;There are currently 4 different Apple Watch screen sizes, and we need to make sure that our app works on each of them. Fortunately, in our case it seems to mostly work fine on any screen and also with different text sizes set in the Settings. I&amp;rsquo;ve built the storyboard and tested the app mostly on the 42mm variant, since I&amp;rsquo;m using a Series 3 42mm Watch right now, and I&amp;nbsp;think that in general, just like on iOS, it&amp;rsquo;s a good strategy to design for compact/medium-sized devices first, and then scale up to larger ones and down to the smallest ones. 40mm is more or less the same, 44mm has more space but usually fills it in the right way automatically, and 38mm might require some minor tweaks.&lt;/p&gt;

&lt;p&gt;In this case, we&amp;rsquo;ll adapt a few things on the main screen for the 38mm:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The main circle is a bit too large there. Select the circle group, and in the inspector where you have the width &amp;amp; height set to 100, there is a tiny &amp;ldquo;+&amp;rdquo; on the left side &amp;ndash; if you press it, you can add an exception for any property. Override the circle&amp;rsquo;s size to 90×90 on the 38mm (it&amp;rsquo;s good to do this when you have the storyboard set to render the scenes on this specific device, in the toolbar at the bottom, so that you see the effects immediately).&lt;/li&gt;
&lt;/ul&gt;


&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/overrides.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/overrides.png?1721484643" width="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Also, in the circle group, make the radius 43 on the 38mm watch, otherwise the circle is going to look funny.&lt;/li&gt;
&lt;li&gt;For the number label inside the circle, make the font slightly smaller too, 40pt let&amp;rsquo;s say.&lt;/li&gt;
&lt;/ul&gt;


&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/w38-before.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/w38-before.png?1721484643" width="296"&gt;&lt;/a&gt; &lt;a href="/images/posts/watchkit3/w38-after.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/w38-after.png?1721484643" width="296"&gt;&lt;/a&gt;&lt;br&gt;Left: before the change, right: after the tweaks&lt;/p&gt;

&lt;p&gt;Another tiny change I&amp;nbsp;made was that on the new-style watches (40mm and 44mm) I&amp;nbsp;changed the top spacing for the first group from 8 to 5 &amp;ndash; these watches seem to automatically have some larger margin at the top, so we don&amp;rsquo;t need to add that much, and on the 44mm watch this allows us to fully see the update time label below.&lt;/p&gt;

&lt;p&gt;The rest should look ok &amp;ndash; 38mm watches also have a smaller default text size than the larger ones (although you can change it of course), and 44mm has a larger default text size.&lt;/p&gt;

&lt;p&gt;We could possibly make the chart height slightly smaller or larger depending on the width (that would have to be changed in code, since that&amp;rsquo;s where we hardcoded it), but it looks ok as it is.&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/w42.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/w42.png?1721484643" width="250"&gt;&lt;/a&gt; &lt;a href="/images/posts/watchkit3/w40.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/w40.png?1721484643" width="246"&gt;&lt;/a&gt; &lt;a href="/images/posts/watchkit3/w44.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/w44.png?1721484643" width="265"&gt;&lt;/a&gt; &lt;br&gt;42mm, 40mm &amp;amp; 44mm&lt;/p&gt;

&lt;p&gt;You&amp;rsquo;re going to have a bit more work if you use static images in your app. In that case, images will usually be scaled exactly as they are saved in the file, and they will not change their size automatically based on the screen size (unless you set their widths relatively to the container). So it would probably make sense to have different variants of the same image for each screen size. Since watchOS 6, App Store uses app thinning to only download assets for a given Watch size, so it doesn&amp;rsquo;t make the bundle larger for your users, and by providing smaller image variants you can save some space on smaller devices.&lt;/p&gt;

&lt;p class="image noborder"&gt;&lt;a href="/images/posts/watchkit3/assets.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/watchkit3/assets.png?1721484643" width="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are also some gotchas related to the rounded corners, safe areas and margins on the Series 4+ watches, but this should mostly be handled automatically if you&amp;rsquo;re using system controls, and you might only run into problems if you&amp;rsquo;re using SpriteKit/SceneKit views. You can learn about that from &lt;a href="https://developer.apple.com/videos/play/tech-talks/802"&gt;this 9-minute talk&lt;/a&gt; on Apple&amp;rsquo;s site.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;The main app screen is complete now and we&amp;rsquo;ve reached the end of the first part of the tutorial. If you want to look at the code on GitHub, the version after this part is available &lt;a href="https://github.com/mackuba/SmogWatch/tree/post3"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the second part, we&amp;rsquo;ll build a second screen for choosing the station providing the data from a list of available stations.&lt;/p&gt;

&lt;p class="right"&gt;&lt;a href="/2020/09/10/watchkit-adventure-4-tables-navigation/"&gt;Next post: #4 Tables and Navigation &amp;gt;&lt;/a&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2020/08/17/swiftui-beta/</id>
    <title>SwiftUI betas - what changed before 1.0</title>
    <published>2020-08-17T12:26:14Z</published>
    <updated>2020-08-17T12:26:14Z</updated>
    <link href="https://mackuba.eu/2020/08/17/swiftui-beta/"/>
    <content type="html">&lt;p&gt;In the last few weeks I&amp;rsquo;ve been trying to catch up on SwiftUI&amp;nbsp;- watching WWDC videos, reading tutorials. Not the new stuff that was announced 2 months ago though - but the things that people have been using for the past year.&lt;/p&gt;

&lt;p&gt;Last June, like everyone else I&amp;nbsp;immediately started playing with SwiftUI&amp;nbsp;like a kid with a new box of Legos. In the first month I&amp;nbsp;managed to build a &lt;a href="/2019/06/17/swiftui-appkit-dark-mode-switcher/"&gt;sample Mac app for switching dark mode in apps&lt;/a&gt;. However, after that I&amp;nbsp;got busy with some other things, and never really got back to SwiftUI&amp;nbsp;until recently, so by the time the &amp;ldquo;version 2&amp;rdquo; was announced at the online-only WWDC, I&amp;rsquo;ve already forgotten most of it. So in order to not get this all mixed up, I&amp;nbsp;decided to first remember everything about the existing version, before I&amp;nbsp;look at the new stuff.&lt;/p&gt;

&lt;p&gt;Back then, when I&amp;nbsp;was watching all the videos and doing the tutorial, I&amp;nbsp;was taking a lot of notes about all the components, modifiers and APIs you can use, every single detail I&amp;nbsp;noticed on a slide. However, I&amp;nbsp;was surprised to see how many of those things I&amp;nbsp;wrote down don&amp;rsquo;t work anymore. After the first version that most people have played with and that the videos are based on, there were apparently a lot of changes in subsequent betas (especially in betas 3 to 5). Classes and modifiers changing names, initializers taking different parameters, some things redesigned completely.&lt;/p&gt;
&lt;p&gt;And the problem is that all those old APIs are still there in the WWDC videos from last year. But WWDC videos are usually a very good source of knowledge, people come back to them years later looking for information that can&amp;rsquo;t be found in the docs, Apple even often references videos from previous years in new videos, because they naturally can&amp;rsquo;t repeat all information every year.&lt;/p&gt;

&lt;p&gt;This was bothering me enough that I&amp;nbsp;decided to spend some time collecting all the major changes in the APIs that were presented in June 2019, but were changed later in one place. If you&amp;rsquo;re reading this in 2021 or 2022 (hopefully that damn pandemic is over!), watching the first SwiftUI&amp;nbsp;videos and wondering why things don&amp;rsquo;t work when typed into Xcode - this is for you.&lt;/p&gt;

&lt;p&gt;Here&amp;rsquo;s a list of what was changed between the beta 1 from June 2019 and the final version from September (includes only things that were mentioned in videos or tutorials):&lt;/p&gt;

&lt;hr /&gt;

&lt;h3&gt;NavigationButton&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Building Lists and Navigation&amp;rdquo; tutorial, &amp;ldquo;Platforms State of the Union&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;ForEach(store.trails) { trail in
    NavigationButton(destination: TrailDetailView(trail)) {
        TrailCell(trail)
    }
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;NavigationLink&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;ForEach(store.trails) { trail in
    NavigationLink(destination: TrailDetailView(trail)) {
        TrailCell(trail)
    }
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;PresentationButton / PresentationLink&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Composing Complex Interfaces&amp;rdquo; tutorial, &amp;ldquo;Platforms State of the Union&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;.navigationBarItems(trailing:
    PresentationButton(
        Image(systemName: "person.crop.circle"),
        destination: ProfileScreen()
    )
)
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;PresentationLink&lt;/code&gt;, which was later removed and replaced with &lt;code&gt;.sheet&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;.navigationBarItems(trailing:
    Button(action: { self.showingProfile.toggle() }) {
        Image(systemName: "person.crop.circle")
    }
)
.sheet(isPresented: $showingProfile) {
    ProfileScreen()
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;SegmentedControl&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Working With UI&amp;nbsp;Controls&amp;rdquo; tutorial&lt;/p&gt;

&lt;pre class="brush: swift"&gt;SegmentedControl(selection: $profile.seasonalPhoto) {
    ForEach(Profile.Season.allCases) { season in
        Text(season.rawValue).tag(season)
    }
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;Picker&lt;/code&gt; with &lt;code&gt;SegmentedPickerStyle()&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
    ForEach(Profile.Season.allCases) { season in
        Text(season.rawValue).tag(season)
    }
}
.pickerStyle(SegmentedPickerStyle())
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;TabbedView and tabItemLabel&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;SwiftUI&amp;nbsp;Essentials&amp;rdquo;, &amp;ldquo;SwiftUI&amp;nbsp;on All Devices&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;TabbedView {
    ExploreView().tabItemLabel(Text("Explore"))
    HikesView().tabItemLabel(Text("Hikes"))
    ToursView().tabItemLabel(Text("Tours"))
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;TabView&lt;/code&gt; and &lt;code&gt;tabItem&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;TabView {
    ExploreView().tabItem { Text("Explore") }
    HikesView().tabItem { Text("Hikes") }
    ToursView().tabItem { Text("Tours") }
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;DatePicker(selection:minimumDate:maximumDate:displayedComponents:)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Working With UI&amp;nbsp;Controls&amp;rdquo; tutorial&lt;/p&gt;

&lt;pre class="brush: swift"&gt;DatePicker(
    $profile.goalDate,
    minimumDate: startDate,
    maximumDate: endDate,
    displayedComponents: .date
)
&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;minimumDate&lt;/code&gt; and &lt;code&gt;maximumDate&lt;/code&gt; parameters were replaced with a &lt;code&gt;ClosedRange&amp;lt;Date&amp;gt;&lt;/code&gt;, and a &lt;code&gt;label&lt;/code&gt; parameter was added:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;DatePicker(
    selection: $profile.goalDate,
    in: startDate...endDate,
    displayedComponents: .date
) {
  Text("Goal Date")
}
&lt;/pre&gt;

&lt;p&gt;You can also use this shorthand variant with a string label:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;DatePicker(
    "Goal Date",
    selection: $profile.goalDate,
    in: startDate...endDate,
    displayedComponents: .date
)
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;TextField(_:placeholder:), SecureField(_:placeholder:)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Platforms State of the Union&amp;rdquo;, &amp;ldquo;Working With UI&amp;nbsp;Controls&amp;rdquo; tutorial&lt;/p&gt;

&lt;pre class="brush: swift"&gt;TextField($profile.username, placeholder: Text(“Username”))
SecureField($profile.password, placeholder: Text(“Password”))
&lt;/pre&gt;

&lt;p&gt;Replaced with:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;TextField("Username", text: $profile.username)
SecureField("Password", text: $profile.password)
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;ScrollView(showsHorizontalIndicator: false)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Composing Complex Interfaces&amp;rdquo; tutorial&lt;/p&gt;

&lt;pre class="brush: swift"&gt;ScrollView(showsHorizontalIndicator: false) {
    HStack(alignment: .top, spacing: 0) {
        ForEach(self.items) { landmark in
            CategoryItem(landmark: landmark)
        }
    }
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;ScrollView(.horizontal, showsIndicators: false)&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;ScrollView(.horizontal, showsIndicators: false) {
    HStack(alignment: .top, spacing: 0) {
        ForEach(self.items) { landmark in
            CategoryItem(landmark: landmark)
        }
    }
}
&lt;/pre&gt;

&lt;p&gt;It doesn&amp;rsquo;t seem to be possible now to have a scroll view that scrolls in both directions, but only shows indicators on one side (?)&lt;/p&gt;

&lt;hr /&gt;

&lt;h3&gt;List(_:action:)&lt;/h3&gt;

&lt;p&gt;Appeared in: Keynote, &amp;ldquo;Platforms State of the Union&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;List(model.items, action: model.selectItem) { item in
    Image(item.image)
    Text(item.title)
}
&lt;/pre&gt;

&lt;p&gt;Removed sometime in later betas - you can use &lt;code&gt;selection:&lt;/code&gt; instead:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;List(model.items, selection: $selectedItem) { item in
    Image(item.image)
    Text(item.title)
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;Text.color()&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Composing Complex Interfaces&amp;rdquo; tutorial, Keynote, &amp;ldquo;Platforms State of the Union&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Text(item.subtitle).color(.gray)
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;.foregroundColor()&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Text(item.subtitle).foregroundColor(.gray)
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;Text.lineLimit(nil)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Platforms State of the Union&amp;rdquo;, &amp;ldquo;SwiftUI&amp;nbsp;on All Devices&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Text(trail.description)
    .lineLimit(nil)
&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;Text&lt;/code&gt; used to have a default line limit of 1, so if you wanted to have a multi-line text control showing some longer text, you had to add &lt;code&gt;.lineLimit(nil)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This was changed later and now no limit is the default. Instead, you may need to add &lt;code&gt;.lineLimit(1)&lt;/code&gt; if you want to make sure that label contents don&amp;rsquo;t overflow into a second line if it&amp;rsquo;s too long.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3&gt;Text(verbatim:)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Building Lists and Navigation&amp;rdquo; tutorial, &amp;ldquo;SwiftUI&amp;nbsp;on All Devices&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Text(verbatim: landmark.name)
&lt;/pre&gt;

&lt;p&gt;It&amp;rsquo;s unclear if and when anything has changed in this API&amp;nbsp;since beta 1. The current docs say that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Text("string")&lt;/code&gt; used with a literal string is automatically localized&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Text(model.field)&lt;/code&gt; used with variable is &lt;em&gt;not&lt;/em&gt; localized&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Text(verbatim: "string")&lt;/code&gt; should be used with a literal string that should not be localized&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;So &lt;code&gt;verbatim:&lt;/code&gt; shouldn&amp;rsquo;t (or can&amp;rsquo;t) be used with variables like in the code above anymore, since in this variant the text will not be translated anyway. The parameter was removed from later versions of the tutorial code.&lt;/p&gt;

&lt;p&gt;If you do want to localize a text that comes from a model property, use &lt;code&gt;Text(LocalizedStringKey(value))&lt;/code&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3&gt;.animation(&amp;hellip;)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Animating Views and Transitions&amp;rdquo; tutorial, &amp;ldquo;Introducing SwiftUI&amp;rdquo;, &amp;ldquo;SwiftUI&amp;nbsp;on All Devices&amp;rdquo;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.animation&lt;/code&gt; modifier has a number of different animation styles that you can choose from. This set of options has changed between the first beta and the final version.&lt;/p&gt;

&lt;p&gt;In beta 1 you could do:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.animation(.basic())
.animation(.basic(duration: 5.0, curve: .linear))
.animation(.basic(duration: 5.0, curve: .easeIn))
.animation(.basic(duration: 5.0, curve: .easeInOut))
.animation(.basic(duration: 5.0, curve: .easeOut))

.animation(.default)
.animation(.empty)

.animation(.fluidSpring())
.animation(.fluidSpring(
  stiffness: 1.0, dampingFraction: 1.0, blendDuration: 1.0, timestep: 1.0, idleThreshold: 1.0
))

.animation(.spring())
.animation(.spring(mass: 1.0, stiffness: 1.0, damping: 1.0, initialVelocity: 1.0))
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;code&gt;.basic&lt;/code&gt; animations were replaced with options named after the selected curve:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.animation(.linear)
.animation(.linear(duration: 1.0))
.animation(.easeIn)
.animation(.easeIn(duration: 1.0))
.animation(.easeInOut)
.animation(.easeInOut(duration: 1.0))
.animation(.easeOut)
.animation(.easeOut(duration: 1.0))
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;(I&amp;rsquo;m not sure what &lt;code&gt;.basic()&lt;/code&gt; without any parameters used to do exactly.)&lt;/p&gt;

&lt;p&gt;What was called &lt;code&gt;.spring&lt;/code&gt; is now &lt;code&gt;.interpolatingSpring&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.animation(.interpolatingSpring(mass: 1.0, stiffness: 1.0, damping: 1.0, initialVelocity: 1.0))
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And &lt;code&gt;.fluidSpring&lt;/code&gt; is now either &lt;code&gt;.spring&lt;/code&gt; or &lt;code&gt;.interactiveSpring&lt;/code&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;.animation(.spring())
.animation(.spring(response: 1.0, dampingFraction: 1.0, blendDuration: 1.0))
.animation(.interactiveSpring())
.animation(.interactiveSpring(response: 1.0, dampingFraction: 1.0, blendDuration: 1.0))
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;.default&lt;/code&gt; is still available (I&amp;rsquo;m not sure what it does though) and &lt;code&gt;.empty&lt;/code&gt; was removed.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3&gt;.background(_:cornerRadius:)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Platforms State of the Union&amp;rdquo;, &amp;ldquo;SwiftUI&amp;nbsp;Essentials&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Text("🥑🍞")
    .background(Color.green, cornerRadius: 12)
&lt;/pre&gt;

&lt;p&gt;Removed later - you can use separate &lt;code&gt;.background&lt;/code&gt; and &lt;code&gt;.cornerRadius&lt;/code&gt; modifiers to achieve the same effect:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Text("🥑🍞")
    .background(Color.green)
    .cornerRadius(12)
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;.identified(by:) method on collections&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Building Lists and Navigation&amp;rdquo; tutorial, &amp;ldquo;Platforms State of the Union&amp;rdquo;, &amp;ldquo;SwiftUI&amp;nbsp;on All Devices&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;ForEach(categories.keys.identified(by: \.self)) { key in
    CategoryRow(categoryName: key)
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;id:&lt;/code&gt; parameter&lt;/p&gt;

&lt;pre class="brush: swift"&gt;ForEach(categories.keys, id: \.self) { key in
    CategoryRow(categoryName: key)
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;.listStyle, .pickerStyle etc. with enum cases&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Introducing SwiftUI&amp;rdquo;, &amp;ldquo;SwiftUI&amp;nbsp;Essentials&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;.listStyle(.grouped)
.pickerStyle(.radioGroup)
.textFieldStyle(.roundedBorder)
&lt;/pre&gt;

&lt;p&gt;Replaced with: creating instances of specific types&lt;/p&gt;

&lt;pre class="brush: swift"&gt;.listStyle(GroupedListStyle())
.pickerStyle(RadioGroupPickerStyle())
.textFieldStyle(RoundedBorderTextFieldStyle())
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;.navigationBarItem(title:)&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Platforms State of the Union&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;NavigationView {
    List {
        ...
    }
    .navigationBarItem(title: Text("Explore"))
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;.navigationBarTitle&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;NavigationView {
    List {
        ...
    }
    .navigationBarTitle("Explore")
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;.onPaste, .onPlayPause, .onExit&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Integrating SwiftUI&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;.onPaste(of: types) { provider in
    self.handlePaste(provider)
}
.onPlayPause {
    self.pause()
}
.onExit {
    self.close()
}
&lt;/pre&gt;

&lt;p&gt;Replaced with &lt;code&gt;.onPasteCommand&lt;/code&gt;, &lt;code&gt;.onPlayPauseCommand&lt;/code&gt;, &lt;code&gt;.onExitCommand&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;.onPasteCommand(of: types) { provider in
    self.handlePaste(provider)
}
.onPlayPauseCommand {
    self.pause()
}
.onExitCommand {
    self.close()
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;.tapAction&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Platforms State of the Union&amp;rdquo;, &amp;ldquo;Introducing SwiftUI&amp;rdquo;, &amp;ldquo;SwiftUI&amp;nbsp;on All Devices&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Image(room.imageName)
    .tapAction { self.zoomed.toggle() }

MacLandmarkRow(landmark: landmark)
    .tapAction(count: 2) { self.showDetail(landmark) }
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;.onTapGesture&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Image(room.imageName)
    .onTapGesture { self.zoomed.toggle() }

MacLandmarkRow(landmark: landmark)
    .onTapGesture(count: 2) { self.showDetail(landmark) }
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;BindableObject and didChange&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Handling User Input&amp;rdquo; tutorial, &amp;ldquo;Introducing SwiftUI&amp;rdquo;, &amp;ldquo;Data Flow Through SwiftUI&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;class UserData: BindableObject {
    let didChange = PassthroughSubject&amp;lt;UserData, Never&amp;gt;()

    var showFavorites = false {
        didSet {
            didChange.send(self)
        }
    }
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;ObservableObject&lt;/code&gt; with &lt;code&gt;objectWillChange&lt;/code&gt; (needs to be called &lt;em&gt;before&lt;/em&gt; the change!). &lt;code&gt;objectWillChange&lt;/code&gt; is automatically included, so you don&amp;rsquo;t need to declare it.&lt;/p&gt;

&lt;pre class="brush: swift"&gt;class UserData: ObservableObject {
    var showFavorites = false {
        willSet {
            objectWillChange.send(self)
        }
    }
}
&lt;/pre&gt;

&lt;p&gt;In simple cases, you can use the &lt;code&gt;@Published&lt;/code&gt; attribute instead which handles this automatically for you:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;class UserData: ObservableObject {
    @Published var showFavorites = false
}
&lt;/pre&gt;

&lt;p&gt;BindableObject was used together with:&lt;/p&gt;

&lt;h4&gt;@ObjectBinding&lt;/h4&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Handling User Input&amp;rdquo; tutorial, &amp;ldquo;Introducing SwiftUI&amp;rdquo;, &amp;ldquo;Data Flow Through SwiftUI&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;@ObjectBinding var store = RoomStore()
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;@ObservedObject&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;@ObservedObject var store = RoomStore()
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;Collection methods on Bindings&lt;/h3&gt;

&lt;p&gt;I&amp;nbsp;don&amp;rsquo;t think this was mentioned in any talks or tutorials, but there were some tweets going around last June showing how you can do some cool tricks with bindings by calling methods on them, e.g.:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;Toggle(landmark.name, isOn: $favorites.contains(landmarkID))
&lt;/pre&gt;

&lt;p&gt;Sadly, this was removed in a later beta. The &lt;a href="https://developer.apple.com/documentation/ios-ipados-release-notes/ios-13-release-notes"&gt;release notes&lt;/a&gt; include some extension code that you can add to your project to reimplement something similar.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3&gt;Command&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;SwiftUI&amp;nbsp;on All Devices&amp;rdquo;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;extension Command {
    static let showExplore = Command(Selector("showExplore"))
}

.onCommand(.showExplore) { self.selectedTab = .explore }
&lt;/pre&gt;

&lt;p&gt;Replaced with: using &lt;code&gt;Selector&lt;/code&gt; directly&lt;/p&gt;

&lt;pre class="brush: swift"&gt;.onCommand(Selector("showExplore")) { self.selectedTab = .explore }
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;Length&lt;/h3&gt;

&lt;p&gt;Appeared in: &amp;ldquo;Animating Views and Transitions&amp;rdquo; tutorial&lt;/p&gt;

&lt;pre class="brush: swift"&gt;var heightRatio: Length {
    max(Length(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}
&lt;/pre&gt;

&lt;p&gt;Replaced with: &lt;code&gt;CGFloat&lt;/code&gt;&lt;/p&gt;

&lt;pre class="brush: swift"&gt;var heightRatio: CGFloat {
    max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;h3&gt;#if DEBUG&lt;/h3&gt;

&lt;p&gt;Appeared in: all tutorials, &amp;ldquo;Platforms State of the Union&amp;rdquo;, &amp;ldquo;Introducing SwiftUI&amp;rdquo;&lt;/p&gt;

&lt;p&gt;This was automatically added around the preview definition in all SwiftUI&amp;nbsp;view files created in beta versions of Xcode 11:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;#if DEBUG
struct CircleImage_Previews: PreviewProvider {
    static var previews: some View {
        CircleImage()
    }
}
#endif
&lt;/pre&gt;

&lt;p&gt;It was removed from the templates in one of the final betas - you &lt;a href="https://forums.swift.org/t/swiftui-previewprovider-no-longer-surrounded-with-if-debug-endif-what-does-this-mean/29943"&gt;no longer need to add that&lt;/a&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;struct CircleImage_Previews: PreviewProvider {
    static var previews: some View {
        CircleImage()
    }
}
&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2020/07/07/photo-library-changes-ios-14/</id>
    <title>Photo library changes in iOS 14</title>
    <published>2020-07-07T14:36:05Z</published>
    <updated>2020-07-07T14:36:05Z</updated>
    <link href="https://mackuba.eu/2020/07/07/photo-library-changes-ios-14/"/>
    <content type="html">&lt;p&gt;I&amp;rsquo;m the kind of person who cares a lot about their digital privacy. It makes me very uncomfortable when I&amp;nbsp;see ads on Facebook for something I&amp;nbsp;opened on another site a moment ago, and I&amp;nbsp;generally don&amp;rsquo;t like it when companies are learning more about me than they should, even if the effects of that tracking aren&amp;rsquo;t as obvious.&lt;/p&gt;

&lt;p id="foot1back"&gt;That&amp;rsquo;s why for example I&amp;rsquo;ve been trying to move away from Google services as much as possible (I&amp;nbsp;use &lt;a href="https://protonmail.com"&gt;ProtonMail&lt;/a&gt; as my main email and Apple&amp;rsquo;s iWork for documents), I&amp;nbsp;also started using &lt;a href="https://tresorit.com"&gt;Tresorit&lt;/a&gt; and iCloud&lt;a href="#footnotes"&gt;1)&lt;/a&gt; for file sync instead of Dropbox. That&amp;rsquo;s also one of the reasons why I&amp;rsquo;ve always used some kind of ad &amp;amp; tracker blocker in my browsers &amp;ndash; previously Ghostery, now I&amp;nbsp;also use &lt;a href="https://brave.com"&gt;Brave&lt;/a&gt; and I&amp;rsquo;ve been experimenting with making &lt;a href="/2020/05/26/building-an-adblocker/"&gt;my own ad blocker&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So it always makes me happy when Apple introduces another change to their OSes that limits the kinds of data that Mac and iOS apps can use without our permission. I&amp;nbsp;especially liked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when iOS 11 introduced the &lt;a href="http://localhost:3000/2017/07/13/changes-to-location-tracking-in-ios-11/"&gt;&amp;ldquo;While Using&amp;rdquo; option&lt;/a&gt; for location access that was non-optional for apps&lt;/li&gt;
&lt;li&gt;the &amp;ldquo;Allow Once&amp;rdquo; option for location access in iOS 13&lt;/li&gt;
&lt;li&gt;permissions to things like camera, microphone or screen recording on the Mac&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This year Apple made another batch of changes that limit apps' access to data. The most interesting ones are the approximate location access and the limited photo library &amp;ndash; in this post I&amp;rsquo;ll talk about the latter.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;Most of us have thousands of photos on our phones, often going a few years back &amp;ndash; after all, our iPhones are our primary cameras these days. These photos and videos capture everything we do, the places we go to, who we meet with and what we do together. They also include location info in their metadata. This is all possibly extremely sensitive data.&lt;/p&gt;

&lt;p&gt;So far however if you wanted to upload a single photo or screenshot to e.g. Twitter or Facebook or send it to a friend through a messaging app, you had to grant them access to your whole photo library &amp;ndash; it was all or nothing. And you could never be sure what they do with it &amp;ndash; are they just looking at this single picture, or maybe looking through your whole 30 GB library for any interesting stuff they can find there, and uploading that to their servers? Hopefully they aren&amp;rsquo;t, but you just had to trust them on this.&lt;/p&gt;

&lt;p&gt;Apple had previously provided a system image picker (&lt;code&gt;UIImagePickerController&lt;/code&gt;) that lets the user choose a photo from their library and pass it to the app without giving it access to the library, as well as a way to save photos to the library without seeing what else is there (&lt;code&gt;UIImageWriteToSavedPhotosAlbum()&lt;/code&gt;). However, for various reasons these don&amp;rsquo;t seem to be widely used in popular apps &amp;ndash; most apps that do anything with photos currently ask for full read-write access to the whole library, just because they can.&lt;/p&gt;

&lt;p&gt;So this year Apple is taking a bit of a carrot and stick approach: the carrot is a new improved system photo picker, while the stick is a new way for the user to only give the app access to selected photos.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;PHPicker&lt;/h2&gt;

&lt;p&gt;PHPicker (not an actual name you can find in the docs, but a general name Apple uses for this new API) is a new system photo picker, a replacement for the old &lt;code&gt;UIImagePickerController&lt;/code&gt;. The two most important differences are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it has an integrated search, so it can help you find some specific photos that may not be recent&lt;/li&gt;
&lt;li&gt;unlike &lt;code&gt;UIImagePickerController&lt;/code&gt; it allows multiple selection&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;It also has an updated design, and while you&amp;rsquo;re scrolling the photo grid you can zoom in and out to see more or less photos at the same time:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/photo-library/picker.jpg"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/photo-library/picker.jpg?1721484643" width="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;How to use the picker&lt;/h3&gt;

&lt;p&gt;The API&amp;nbsp;consists of a few types with the &lt;code&gt;PHPicker*&lt;/code&gt; prefix, and most of them are surprisingly simple.&lt;/p&gt;

&lt;p&gt;The main class that handles the picker screen is &lt;code&gt;PHPickerViewController&lt;/code&gt;. It has a delegate protocol, &lt;code&gt;PHPickerViewControllerDelegate&lt;/code&gt;, which you need to implement. You also need a &lt;code&gt;PHPickerConfiguration&lt;/code&gt; object to pass it to the picker controller, in which you can set a few options for the picker.&lt;/p&gt;

&lt;p&gt;You create a picker controller like this:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;var config = PHPickerConfiguration()
// ...
let picker = PHPickerViewController(configuration: config)
picker.delegate = self
&lt;/pre&gt;

&lt;p&gt;There are currently two options you can set in the picker configuration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1) selectionLimit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the maximum number of items that the user can pick. The default is 1, and you can set it to some specific number, or to 0 to allow unlimited selection.&lt;/p&gt;

&lt;pre class="brush: swift"&gt;config.selectionLimit = 0
&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;2) filter&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The filter can be one of &lt;code&gt;.images&lt;/code&gt;, &lt;code&gt;.livePhotos&lt;/code&gt;, &lt;code&gt;.videos&lt;/code&gt;, or a subset of those created using the &lt;code&gt;.any(of:)&lt;/code&gt; helper:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;config.filter = .images
config.filter = .any(of: [.images, .livePhotos])
&lt;/pre&gt;

&lt;p&gt;Once the picker is configured, you can present it in the usual way:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;present(picker, animated: true)
&lt;/pre&gt;

&lt;p&gt;The last thing to do is to implement &lt;code&gt;PHPickerViewControllerDelegate&lt;/code&gt;, which includes literally a single method: &lt;code&gt;picker(_: didFinishPicking results:)&lt;/code&gt;. This method is called with a list of one or more &lt;code&gt;PHPickerResult&lt;/code&gt; objects in the response when the user confirms their selection. With single selection it returns immediately when the user taps a photo, and with multi-selection they need to confirm it with a toolbar button when they finish selecting.&lt;/p&gt;

&lt;p id="foot2back"&gt;The only part here that is not simple is that the photos are returned wrapped in &lt;code&gt;NSItemProvider&lt;/code&gt; objects (used e.g. in the drag &amp;amp; drop API, or in some kinds of extensions). You need to get that item provider and first call &lt;code&gt;canLoadObject(ofClass:)&lt;/code&gt; and then &lt;code&gt;loadObject(ofClass:)&lt;/code&gt; (though I&amp;rsquo;m not 100% sure if the first is technically required).&lt;a href="#footnotes"&gt;2)&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You also need to dismiss the picker view &amp;ndash; it doesn&amp;rsquo;t hide itself automatically:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;func picker(
    _ picker: PHPickerViewController, 
    didFinishPicking results: [PHPickerResult])
{
    picker.dismiss(animated: true)

    for result in results {
        let provider = result.itemProvider

        if provider.canLoadObject(ofClass: UIImage.self) {
            provider.loadObject(ofClass: UIImage.self) { image, error
                // ... save or display the image, if we got one
            }
        }
    }
}
&lt;/pre&gt;

&lt;p&gt;Apple is expecting that most apps that only access the photo library to attach one or two pictures to a post will switch to this new system picker now. (The old &lt;code&gt;UIImagePickerController&lt;/code&gt; is deprecated &amp;ndash; that is, the class itself is not, but it&amp;rsquo;s only keeping the camera part of its functionality.)&lt;/p&gt;

&lt;p&gt;And if they don’t like the carrot… well, then there’s still the stick.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Limited photo library&lt;/h2&gt;

&lt;p&gt;The stick is that there is now a standard way for the user to only grant an app access to a selected subset of photos (most likely just a few, since they need to manually tap each one). This is *not opt-in* for apps &amp;ndash; it affects every app immediately, even those that have been built on older SDKs.&lt;/p&gt;

&lt;p&gt;The way it works is that when the app tries to access the photo library (or explicitly asks for authorization), the user will now see a popup that looks like this:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/photo-library/auth-dialog.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/photo-library/auth-dialog.png?1721484643" width="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The top option leads them to a selection dialog which is the same new picker you&amp;rsquo;ve seen above.&lt;/p&gt;

&lt;p&gt;When the user confirms the selection, the app gets access to a kind of “virtual” photo library that only contains those few photos they’ve selected. To the app it looks almost like a normal photo library, it just has 5 photos in it instead of 2000 &amp;ndash; that’s how it can work in existing apps. The app can’t access the remaining photos in any way, or even have any idea how many there are in total.&lt;/p&gt;

&lt;p&gt;It can however tell whether it got access to the full library, or some limited subset. You can use the &lt;code&gt;PHPhotoLibrary.authorizationStatus&lt;/code&gt; method for this (which has an updated API&amp;nbsp;&amp;ndash; it now requires an &lt;code&gt;accessLevel&lt;/code&gt; parameter, which is &lt;code&gt;.addOnly&lt;/code&gt; or &lt;code&gt;.readWrite&lt;/code&gt;):&lt;/p&gt;

&lt;pre class="brush: swift"&gt;switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
case .notDetermined:
    // ask for access
case .restricted, .denied:
    // sorry
case .authorized:
    // we have full access

// new option: 
case .limited:
    // we only got access to a part of the library
}
&lt;/pre&gt;

&lt;p&gt;To ask for access, you also need to pass the accessLevel parameter (remember to include a &lt;code&gt;NSPhotoLibraryUsageDescription&lt;/code&gt; key in your Info.plist):&lt;/p&gt;

&lt;pre class="brush: swift"&gt;PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
    // …
}
&lt;/pre&gt;

&lt;p&gt;For backwards compatibility, the old (deprecated) versions without the parameter return &lt;code&gt;.authorized&lt;/code&gt; even when you get limited access.&lt;/p&gt;

&lt;h3&gt;Updating the selection&lt;/h3&gt;

&lt;p&gt;You’re probably asking now: how can the user update the selection? If we’re talking about an app like Twitter or Facebook Messenger, the user will only select a few photos that they want to share, but next time when they want to post a photo, they will already be authorized &amp;ndash; so the popup won’t appear, and they will just be choosing from the same few photos they chose last time. Not good.&lt;/p&gt;

&lt;p&gt;So there are a few ways to solve this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1) Settings&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The user can always go to the Settings app, the Privacy section and update their selection there. However, they need to first know that there is such option and where to find it (the app can’t even deep-link to this specific page), so this is more like a last resort fallback.&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/photo-library/settings.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/photo-library/settings.png?1721484643" width="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2) Repeated alert&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By default if the user grants limited access to the photo library to an app, they will see the same popup again after the app is restarted, and through that popup they can update their selection. This is also more of a way to somehow imperfectly support apps that haven’t been updated to the latest SDK &amp;ndash; it solves the problem, but in an awkward way and only partially, since the popup won’t appear if you just hide the app, take a few more photos and open it again to share them, and the app doesn’t restart in the meantime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3) Showing the selection UI&amp;nbsp;again&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want handle this properly, the recommended way is to manually request to show the selection UI&amp;nbsp;again. Apple explains that if you have an app that requires full access to the photo library (e.g. some app whose main purpose is to let you browse and organize the photo library), you should add some kind of button in your UI&amp;nbsp;that triggers the selection screen again. This button should only appear if &lt;code&gt;authorizationStatus&lt;/code&gt; is &lt;code&gt;.limited&lt;/code&gt;, and hide if the user grants the app full access.&lt;/p&gt;

&lt;p&gt;To show the selection UI, call this new method:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self)
&lt;/pre&gt;

&lt;p&gt;The selection screen hides automatically when the user completes selection. You will not be notified of the result through any delegate method &amp;ndash; you need to use a “change observer” to track when the set of available photos changes. Implement the protocol &lt;code&gt;PHPhotoLibraryChangeObserver&lt;/code&gt; and call the &lt;code&gt;register(_ observer:)&lt;/code&gt; method on the &lt;code&gt;PHPhotoLibrary&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;PHPhotoLibrary.shared().register(self)

func photoLibraryDidChange(_ changeInstance: PHChange) {
    // ...
}
&lt;/pre&gt;

&lt;p&gt;Once you do that, it makes sense to disable that automatic alert mentioned in point 2 above &amp;ndash; to do that, add the key &lt;code&gt;PHPhotoLibraryPreventAutomaticLimitedAccessAlert&lt;/code&gt; to your Info.plist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4) Using a system picker&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The best option though is that you don’t ask for access to the photo library at all 😏 Remember the carrot? If you use the new system picker, you don’t need to ask for photo library authorization. The picker runs in a separate process, it handles the selection for you and sends you back only what the user selected, so they implicitly grant you access to those photos they picked. No other popups, no checking for authorization.&lt;/p&gt;

&lt;p&gt;So if you have an app that currently uses some kind of sliding sheet showing recent photos from which the user picks one to attach it to a post, you really, really should consider just using the system picker, instead of keeping the sheet as a kind of “staging area” and adding another unnecessary step to the flow.&lt;/p&gt;

&lt;h3&gt;Saving to the library&lt;/h3&gt;

&lt;p&gt;One special case is when you only need to save to the library, but don’t need to read from it &amp;ndash; e.g. you want to let your users save some pictures from a feed or a website. In this case, you only need to ask for an “add only” access, which users may be more likely to grant if it’s obvious that your app doesn’t have any legitimate need for a read access. This is mostly unchanged from earlier iOS versions.&lt;/p&gt;

&lt;p&gt;To save a picture to user’s library, you can use this method:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;UIImageWriteToSavedPhotosAlbum(image, self, #selector(onImageSaved), nil)
&lt;/pre&gt;

&lt;p&gt;Or just pass nils if you don’t need a callback:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
&lt;/pre&gt;

&lt;p&gt;If you don’t have any authorization at this point yet, it will trigger a popup asking about one &amp;ndash; but it uses a different wording and options that the one for read-write access, making it clear that this is about add-only access:&lt;/p&gt;

&lt;p class="image"&gt;&lt;a href="/images/posts/photo-library/save-dialog.png"&gt;&lt;img alt="" src="https://mackuba.eu/images/posts/photo-library/save-dialog.png?1721484643" width="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You also need to include the usage key &lt;code&gt;NSPhotoLibraryAddUsageDescription&lt;/code&gt; in your Info.plist.&lt;/p&gt;

&lt;p&gt;If you’d prefer to ask the user for write access explicitly, you can use the same &lt;code&gt;requestAuthorization&lt;/code&gt; method:&lt;/p&gt;

&lt;pre class="brush: swift"&gt;PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
    …
}
&lt;/pre&gt;

&lt;hr /&gt;

&lt;p&gt;It will be interesting to see in the coming months how popular apps like Twitter, Facebook, Messenger etc. react to this new situation. The ideal scenario would be that they all switch to PHPicker and avoid the trouble with limited access &amp;ndash; this is also, I&amp;nbsp;believe, better UX for the users than if they insist on using library access and &lt;code&gt;presentLimitedLibraryPicker&lt;/code&gt;. The worst case scenario is that they do nothing, assume that most users don’t care about privacy or will be too lazy and will just grant them full access, and those who insist on protecting their private photos will be left with working but kinda awkward user experience. Or maybe they’ll figure out something that works well &amp;ndash; we’ll see.&lt;/p&gt;

&lt;p class="footnote" id="footnotes"&gt;1) Yes, I&amp;nbsp;know that iCloud Drive is not end-to-end encrypted &amp;ndash; but I&amp;nbsp;trust Apple infinitely more than I&amp;nbsp;trust Google and Dropbox. Hopefully they will add full encryption at some point &amp;ndash; they are slowly expanding the range of things that are end-to-end encrypted, e.g. last year they&amp;rsquo;ve added some synced &lt;a href="https://support.apple.com/en-us/HT202303"&gt;Safari data&lt;/a&gt; to the list.&amp;nbsp;&lt;a href="#foot1back"&gt;↩︎&lt;/a&gt;&lt;/p&gt;

&lt;p class="footnote"&gt;2) This doesn&amp;rsquo;t seem to currenly work in the simulator in beta 1, including in Apple&amp;rsquo;s &lt;a href="https://developer.apple.com/documentation/photokit/selecting_photos_and_videos_in_ios"&gt;sample code&lt;/a&gt; from the talk about the picker. I&amp;nbsp;haven&amp;rsquo;t tried on a real device.&amp;nbsp;&lt;a href="#foot2back"&gt;↩︎&lt;/a&gt;&lt;/p&gt;
</content>
  </entry>
</feed>
