<?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/iOS &amp; web dev</subtitle>
  <link href="https://mackuba.eu" rel="alternate" type="text/html"/>
  <link href="https://mackuba.eu/feed-all.xml" rel="self" type="application/atom+xml"/>
  <updated>2026-04-22T18:22:36Z</updated>
  <author>
    <name>Kuba Suder</name>
    <email>jakub.suder@gmail.com</email>
  </author>
  <entry>
    <id>https://mackuba.eu/2026/02/04/pds-undockered/</id>
    <title>Running Bluesky PDS undockered</title>
    <published>2026-02-04T19:14:20Z</published>
    <updated>2026-02-04T19:14:20Z</updated>
    <link href="https://mackuba.eu/2026/02/04/pds-undockered/"/>
    <content type="html">&lt;p&gt;A bit over a year ago, in the first week of January 2025, I&amp;nbsp;migrated my main Bluesky account to my own PDS on a Netcup VPS. It&amp;rsquo;s been quite easy to set up using the official installer, and it&amp;rsquo;s been running pretty much without any problems or maintenance the whole year.&lt;/p&gt;

&lt;p&gt;Despite that, I&amp;nbsp;haven&amp;rsquo;t been 100% happy with this setup for one reason: Docker. So I&amp;nbsp;decided to try to take it out of the box, and I&amp;nbsp;made it run first on the same VPS installed separately, and then moved it to another machine this month with a clean install. This blog post is a guide to how I&amp;nbsp;did this, if you&amp;rsquo;re interested. There are a few existing posts about this already:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://benharri.org/bluesky-pds-without-docker/"&gt;https://benharri.org/bluesky-pds-without-docker/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://char.lt/blog/2024/10/atproto-pds/"&gt;https://char.lt/blog/2024/10/atproto-pds/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cprimozic.net/notes/posts/notes-on-self-hosting-bluesky-pds-alongside-other-services/"&gt;https://cprimozic.net/notes/posts/notes-on-self-hosting-bluesky-pds-alongside-other-services/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;But I&amp;nbsp;figured it doesn&amp;rsquo;t hurt to make another one that does things slightly differently again. &amp;ldquo;There are many like this, but this one is mine&amp;rdquo;. (I&amp;nbsp;mostly followed the &lt;a href="https://benharri.org/bluesky-pds-without-docker/"&gt;benharri.org version&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Note: I&amp;rsquo;m describing what I&amp;nbsp;did to migrate an &lt;em&gt;existing&lt;/em&gt; PDS from in-Docker to outside-Docker, so I&amp;nbsp;already had existing data and &lt;code&gt;pds.env&lt;/code&gt; config; if you wanted to install one from scratch this way, you&amp;rsquo;d probably need to also set up the config manually.&lt;/p&gt;

&lt;p class="image"&gt;
  &lt;video width="480" autoplay preload loop&gt;&lt;source src="https://mackuba.eu/images/posts/why.mp4?1777132788" type="video/mp4"&gt;&lt;/video&gt;
&lt;/p&gt;


&lt;p&gt;You might be asking: why? And that&amp;rsquo;s a good question. I&amp;nbsp;mostly wouldn&amp;rsquo;t recommend this setup over the standard Docker one by default, unless you know what you&amp;rsquo;re doing. The standard installation is literally running one command and answering some questions, and then it auto-updates and manages everything.&lt;/p&gt;

&lt;p&gt;My reason is that I&amp;rsquo;m generally pretty familiar with installing things on Linux servers manually, but I&amp;rsquo;m completely unfamiliar with Docker. I&amp;nbsp;always wanted to do some modifications on the PDS, but I&amp;nbsp;didn&amp;rsquo;t know how, because the Docker setup basically takes over the whole server for itself. I&amp;nbsp;don&amp;rsquo;t know where it pulls code from, I&amp;nbsp;don&amp;rsquo;t know where it puts it, and I&amp;nbsp;don&amp;rsquo;t know when it can overwrite any changes I&amp;nbsp;make. I&amp;nbsp;don&amp;rsquo;t feel in control. (And to be clear, this is likely a me problem.)&lt;/p&gt;

&lt;p&gt;So here&amp;rsquo;s what I&amp;nbsp;did (this setup is for Ubuntu 24.04 Noble):&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Install Nginx&lt;/h2&gt;

&lt;p&gt;The standard PDS distribution uses Caddy, but I&amp;nbsp;use Nginx everywhere and I&amp;nbsp;have configs built for it, so I&amp;rsquo;ve set up Nginx:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# install Nginx
sudo apt-get install --no-install-recommends nginx-light

# enable HTTP on the firewall
sudo ufw allow http/tcp
sudo ufw allow https/tcp

# if you haven't enabled ufw before:
sudo ufw limit log ssh/tcp
sudo ufw enable
&lt;/pre&gt;

&lt;p&gt;Also here&amp;rsquo;s a standard thing I&amp;nbsp;do on VPSes to let me install webapps in &lt;code&gt;/var/www&lt;/code&gt; from my account:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# set up environment for webapps

sudo groupadd deploy
sudo adduser psionides deploy
sudo chown root:deploy /var/www
sudo chmod 775 /var/www
&lt;/pre&gt;

&lt;p&gt;I&amp;nbsp;also need Certbot for LetsEncrypt:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# install Certbot

sudo apt-get install --no-install-recommends certbot python3-certbot-nginx
sudo certbot plugins --nginx --prepare
&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;certbot plugins --nginx --prepare&lt;/code&gt; does some initial setup of some config files in &lt;code&gt;/etc/letsencrypt&lt;/code&gt; that are unrelated to specific certificates.&lt;/p&gt;

&lt;p&gt;One thing to note is that the standard setup with Caddy uses a &lt;code&gt;.well-known&lt;/code&gt; route to verify any handles under &lt;code&gt;*.yourdomain.com&lt;/code&gt;, and it automatically creates HTTPS certificates for those subdomains; this wouldn&amp;rsquo;t be as simple here, but I&amp;nbsp;don&amp;rsquo;t need to be able to mass create new handles under my domain. If I&amp;nbsp;ever need one or two, I&amp;rsquo;ll just set them up manually.&lt;/p&gt;

&lt;h2&gt;Email&lt;/h2&gt;

&lt;p&gt;We&amp;rsquo;ll also need email – you can use an external SMTP, but I&amp;nbsp;like to use local sendmail that&amp;rsquo;s configured to forward emails to my main account:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# install Postfix
sudo apt-get install --no-install-recommends postfix

  # choose: "Internet site"
  # enter domain: "lab.martianbase.net"

# set up the email forwarding
sudo nano /etc/postfix/virtual

  # add a line like this:
  # kuba@lab.martianbase.net my.real.email@domain.com

sudo nano /etc/postfix/main.cf

  # add:
  # virtual_alias_domains = lab.martianbase.net
  # virtual_alias_maps = hash:/etc/postfix/virtual

sudo postmap /etc/postfix/virtual
sudo service postfix reload

# enable SMTP on firewall
sudo ufw allow smtp/tcp
&lt;/pre&gt;

&lt;p&gt;If you set things up this way, you need to set the &lt;code&gt;PDS_EMAIL_SMTP_URL&lt;/code&gt; in &lt;code&gt;pds.env&lt;/code&gt; to &lt;code&gt;smtp:///?sendmail=true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Note: you will probably need to set up a few things the right way in the DNS and might also need more tweaks in the &lt;code&gt;/etc/postfix/main.cf&lt;/code&gt; for the email sending and forwarding to work. I&amp;nbsp;honestly don&amp;rsquo;t understand the Postfix config well enough, I&amp;nbsp;just have a setup that works and I&amp;nbsp;don&amp;rsquo;t touch it, but it&amp;rsquo;s old and I&amp;nbsp;don&amp;rsquo;t know how correct it is, so I&amp;nbsp;won&amp;rsquo;t share it here. From my experience, it&amp;rsquo;s generally not super hard to configure self-hosted email in such way that it works for sending emails &lt;em&gt;to you&lt;/em&gt; and only to you (like I&amp;nbsp;have with my PDS and my Mastodon instance). If it goes to spam, you know where to look, and if you move it to the inbox, generally the email service should remember and whitelist the sender. (Sending emails to &lt;em&gt;others&lt;/em&gt; is a whole different story of course.)&lt;/p&gt;

&lt;h2&gt;NodeJS&lt;/h2&gt;

&lt;p&gt;Next, NodeJS. I&amp;nbsp;used &lt;a href="https://asdf-vm.com"&gt;asdf&lt;/a&gt; to install it (the version 0.15 is because in 0.16 they did a complete rewrite in Go from the original Bash version, and some things don&amp;rsquo;t work as before).&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# install asdf
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.15.0
nano .bashrc

  # add:
  # source "$HOME/.asdf/asdf.sh"

# ---
# log out &amp;amp; back in here - this is also needed for the deploy group to take effect
# ---

# install node
asdf plugin add nodejs

NODE_VER=`asdf latest nodejs 22`
asdf install nodejs $NODE_VER
asdf global nodejs $NODE_VER

corepack enable
asdf reshim nodejs
&lt;/pre&gt;

&lt;p&gt;The Docker version runs on 20.x, but that&amp;rsquo;s almost EOL, so I&amp;rsquo;ve upgraded to the latest 22.x. 24.x doesn&amp;rsquo;t work at the moment, I&amp;nbsp;get some ugly errors during installation.&lt;/p&gt;

&lt;h2&gt;Install the PDS&lt;/h2&gt;

&lt;p&gt;Now the actual PDS code. I&amp;rsquo;ve decided to keep the code in &lt;code&gt;/var/www&lt;/code&gt;, fetch it from git and use &lt;code&gt;git pull&lt;/code&gt; for updates, and keep the data separately in &lt;code&gt;/var/lib&lt;/code&gt;.&lt;/p&gt;

&lt;pre class="brush: bash"&gt;cd /var/www
git clone https://github.com/bluesky-social/pds
cd pds/service

pnpm install --production --frozen-lockfile
&lt;/pre&gt;

&lt;h2&gt;Migrate the data&lt;/h2&gt;

&lt;p&gt;Now it&amp;rsquo;s time to copy over the data from the previous setup.&lt;/p&gt;

&lt;p&gt;On the first server I&amp;nbsp;did it like this (remember to also turn off and disable the &lt;em&gt;old&lt;/em&gt; PDS service in Docker):&lt;/p&gt;

&lt;pre class="brush: bash"&gt;sudo rsync -rlpt /pds /var/lib/
sudo chown -R psionides:psionides /var/lib/pds
&lt;/pre&gt;

&lt;p&gt;For the second one, I&amp;nbsp;made a temporary SSH key on the old server:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;ssh-keygen -t ed25519 -f ~/.ssh/migration -N "" -C "pds migration"
&lt;/pre&gt;

&lt;p&gt;Added it to authorized keys on the new one:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;nano ~/.ssh/authorized_keys
&lt;/pre&gt;

&lt;p&gt;Prepared an empty directory:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;sudo mkdir /var/lib/pds
sudo chown psionides:psionides /var/lib/pds
&lt;/pre&gt;

&lt;p&gt;And rsynced the data from the old one to the new one:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# install if missing on the target server:
sudo apt-get install rsync

rsync -rltv -e "ssh -i ~/.ssh/migration" /var/lib/pds/ newserver:/var/lib/pds/
&lt;/pre&gt;

&lt;p&gt;I&amp;nbsp;tried to sync it from a local machine first, but I&amp;nbsp;realized it would take much longer, and between two servers on the same network it took less than a minute for many GBs.&lt;/p&gt;

&lt;h2&gt;Start the service&lt;/h2&gt;

&lt;p&gt;With the data copied, it&amp;rsquo;s time to finish the installation. First, an HTTPS certificate – for now, I&amp;nbsp;made one using manual DNS registration before switching the DNS records:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;sudo certbot certonly --manual --preferred-challenges dns --key-type ecdsa \
    -m $myemail --agree-tos --no-eff-email -d lab.martianbase.net
&lt;/pre&gt;

&lt;p&gt;The way it works is that it gives me a kind of verification token, and I&amp;nbsp;need to put it in a TXT DNS record at &lt;code&gt;_acme-challenge.lab.martianbase.net&lt;/code&gt; before pressing continue.&lt;/p&gt;

&lt;p&gt;Then, I&amp;nbsp;added a systemd service at &lt;code&gt;/etc/systemd/system/pds.service&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[Unit]
Description=Bluesky PDS
After=network.target

[Service]
Type=simple
User=psionides
WorkingDirectory=/var/www/pds/service
ExecStart=/home/psionides/.asdf/shims/node --enable-source-maps index.js
Restart=on-failure
EnvironmentFile=/var/lib/pds/pds.env
Environment="NODE_ENV=production"
TimeoutSec=15
Restart=on-failure
RestartSec=1
StandardOutput=append:/var/lib/pds/pds.log

[Install]
WantedBy=default.target
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I&amp;nbsp;also told the PDS what port to run on in &lt;code&gt;pds.env&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;PDS_PORT=3000
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And then enabled it like this:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;sudo systemctl daemon-reload
sudo systemctl enable --now pds
&lt;/pre&gt;

&lt;p&gt;Test?&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ curl http://localhost:3000

         __                         __
        /\ \__                     /\ \__
    __  \ \ ,_\  _____   _ __   ___\ \ ,_\   ___
  /'__'\ \ \ \/ /\ '__'\/\''__\/ __'\ \ \/  / __'\
 /\ \L\.\_\ \ \_\ \ \L\ \ \ \//\ \L\ \ \ \_/\ \L\ \
 \ \__/.\_\\ \__\\ \ ,__/\ \_\\ \____/\ \__\ \____/
  \/__/\/_/ \/__/ \ \ \/  \/_/ \/___/  \/__/\/___/
                   \ \_\
                    \/_/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;It&amp;rsquo;s working! Now, the Nginx config.&lt;/p&gt;

&lt;h2&gt;Nginx config for the PDS&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;/etc/nginx/sites-available/pds.site&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# this is needed to proxy the relevant HTTP headers for websocket
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

upstream pds {
  server 127.0.0.1:3000 fail_timeout=0;
}

server {
  server_name lab.martianbase.net;
  listen 80;
  listen [::]:80;  # ipv6

  # redirect any http requests to https
  location / {
    return 301 https://$host$request_uri;
  }

  # except for certbot challenges
  location /.well-known/acme-challenge/ {
    root /var/www/html;
  }
}

server {
  server_name lab.martianbase.net;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;

  ssl_certificate /etc/letsencrypt/live/lab.martianbase.net/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/lab.martianbase.net/privkey.pem;

  access_log /var/log/nginx/pds-access.log combined buffer=16k flush=10s;
  error_log /var/log/nginx/pds-error.log;

  client_max_body_size 100M;

  location / {
    include sites-available/proxy.inc;
    proxy_pass http://pds;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In &lt;code&gt;/etc/nginx/sites-available/proxy.inc&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin "";
proxy_set_header Proxy "";

proxy_buffering off;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

tcp_nodelay on;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;sudo ln -s /etc/nginx/sites-available/pds.site /etc/nginx/sites-enabled/
sudo nginx -t
sudo service nginx reload
&lt;/pre&gt;

&lt;h2&gt;DNS&lt;/h2&gt;

&lt;p&gt;At this point it was time to update the DNS and wait…&lt;/p&gt;

&lt;p&gt;Once it started working for me, I&amp;nbsp;also had to wait a bit more for the existing relays to notice the IP change and I&amp;nbsp;needed to poke them a few times to reconnect to me again, like this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;curl https://bsky.network/xrpc/com.atproto.sync.requestCrawl \
  --json '{"hostname": "lab.martianbase.net"}'
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Restarting the server with &lt;code&gt;sudo service pds restart&lt;/code&gt; also sends a &lt;code&gt;requestCrawl&lt;/code&gt; at least to &lt;code&gt;bsky.network&lt;/code&gt;. What you want is to see in the &lt;code&gt;pds.log&lt;/code&gt; that after &lt;code&gt;pds has started&lt;/code&gt; it says &lt;code&gt;request to com.atproto.sync.subscribeRepos&lt;/code&gt;. There was a brief moment when I&amp;nbsp;could post something, but after reloading bsky.app I&amp;nbsp;wouldn&amp;rsquo;t see my post, because the AppView hasn&amp;rsquo;t indexed it yet… thankfully, after the relay finally reconnected, it backfilled the missing events.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;also updated the Certbot certificate again the normal way so I&amp;nbsp;wouldn&amp;rsquo;t forget about it later:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;sudo certbot certonly --webroot --webroot-path /var/www/html \
    --key-type ecdsa -d lab.martianbase.net
&lt;/pre&gt;

&lt;p&gt;(There&amp;rsquo;s also a more automated Nginx verification method, where it adds the required certificate lines to your Nginx configs automatically, but I&amp;nbsp;don&amp;rsquo;t like it because it makes a terrible mess of the configs…)&lt;/p&gt;

&lt;h2&gt;Gatekeeper&lt;/h2&gt;

&lt;p&gt;There was one more thing I&amp;nbsp;did while I&amp;nbsp;was already messing with the PDS, which was to add Bailey Townsend&amp;rsquo;s &lt;a href="https://tangled.org/baileytownsend.dev/pds-gatekeeper"&gt;Gatekeeper service&lt;/a&gt;, which restores support for email-based 2FA. For some reason, the email 2FA was implemented in the &amp;ldquo;entryway&amp;rdquo; PDS &lt;code&gt;bsky.social&lt;/code&gt; and not in the main code, and as of today it still hasn&amp;rsquo;t been added to the self-hosted PDS distribution… so Bailey decided that &amp;ldquo;we can just do things&amp;rdquo; and went and added it himself. You just need to install it separately (it&amp;rsquo;s written in Rust).&lt;/p&gt;

&lt;p&gt;First, we install the 🦀:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# install Rust

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --profile minimal
# (press enter)

source ~/.cargo/env
&lt;/pre&gt;

&lt;p&gt;I&amp;nbsp;used &lt;a href="https://rustup.rs"&gt;rustup&lt;/a&gt;, because Gatekeeper requires a fairly new version of Rust and the one I&amp;nbsp;had in Ubuntu LTS repo didn&amp;rsquo;t cut it.&lt;/p&gt;

&lt;p&gt;We also need some additional dependencies for building:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# some general compilers and stuff
sudo apt-get install --no-install-recommends build-essential

# openssl and pkg-config
sudo apt-get install --no-install-recommends libssl-dev pkg-config
&lt;/pre&gt;

&lt;p&gt;Now, time to clone and build the service:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;git clone https://tangled.org/baileytownsend.dev/pds-gatekeeper /var/www/gatekeeper
cd /var/www/gatekeeper/
cargo build --release
sudo cp target/release/pds_gatekeeper /usr/local/bin/
&lt;/pre&gt;

&lt;p&gt;(It takes a bit of time to compile, go make yourself a coffee ☕️)&lt;/p&gt;

&lt;p&gt;The current version is missing support for sendmail email transport, so I&amp;nbsp;had to make some tweaks to make it work (&lt;a href="https://tangled.org/baileytownsend.dev/pds-gatekeeper/pulls/6"&gt;see PR&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;There are a couple of ENV entries we need to add to the &lt;code&gt;pds.env&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;PDS_BASE_URL=http://localhost:3000
GATEKEEPER_PORT=8000
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And again, we need to write a systemd service at &lt;code&gt;/etc/systemd/system/gatekeeper.service&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[Unit]
Description=Bluesky Gatekeeper
After=network.target

[Service]
Type=simple
User=psionides
ExecStart=/usr/local/bin/pds_gatekeeper
Restart=on-failure
Environment="PDS_ENV_LOCATION=/var/lib/pds/pds.env"
TimeoutSec=15
Restart=on-failure
RestartSec=1
StandardOutput=append:/var/lib/pds/gatekeeper.log

[Install]
WantedBy=default.target
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And launch it:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;sudo systemctl daemon-reload
sudo systemctl enable --now gatekeeper
&lt;/pre&gt;

&lt;p&gt;Finally, a few updates to the Nginx config:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;upstream gatekeeper {
  server 127.0.0.1:8000 fail_timeout=0;
}

server {
  ...

  location /xrpc/com.atproto.server.createSession {
    include sites-available/proxy.inc;
    proxy_pass http://gatekeeper;
  }

  location /xrpc/com.atproto.server.getSession {
    include sites-available/proxy.inc;
    proxy_pass http://gatekeeper;
  }

  location /xrpc/com.atproto.server.updateEmail {
    include sites-available/proxy.inc;
    proxy_pass http://gatekeeper;
  }

  location /@atproto/oauth-provider/~api/sign-in {
    include sites-available/proxy.inc;
    proxy_pass http://gatekeeper;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And reload it again:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;sudo nginx -t &amp;amp;&amp;amp; sudo service nginx reload
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;At this point the PDS should be fully operational, including email 2FA when you try to log in 🎉&lt;/p&gt;

&lt;p&gt;For creating accounts and other admin stuff, you can use the &lt;code&gt;pdsadmin&lt;/code&gt; scripts from the source code dir, but you need to pass a &lt;code&gt;PDS_ENV_FILE&lt;/code&gt; env var with the path to &lt;code&gt;pds.env&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;cd /var/www/pds
PDS_ENV_FILE=/var/lib/pds/pds.env bash pdsadmin/account.sh list
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Backups&lt;/h2&gt;

&lt;p&gt;There&amp;rsquo;s one last thing – it would also be nice to have backups of your nearly 3 years worth of Bluesky posts…&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;do it like this:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;# create a backup user
sudo adduser archivist

# add a separate SSH key from local Mac
sudo mkdir /home/archivist/.ssh
echo "ssh-ed25519 ... archivist@martianbase.net" | sudo tee /home/archivist/.ssh/authorized_keys
sudo chown -R archivist:archivist /home/archivist/.ssh
&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;sudo nano /usr/local/sbin/backup_pds&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;#!/bin/bash

set -e

service pds stop
rsync -rlpt --exclude="pds.log*" /var/lib/pds/ /var/backups/pds
chmod -R g+rX /var/backups/pds
chown -R root:archivist /var/backups/pds
service pds start
&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;sudo chmod a+x /usr/local/sbin/backup_pds&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo nano /etc/cron.d/backup_pds&lt;/code&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;10 10 * * *   root   /usr/local/sbin/backup_pds
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And then I&amp;nbsp;have a cron job on the local Mac which runs this backup script:&lt;/p&gt;

&lt;pre class="brush: bash"&gt;#!/bin/bash

SSH_COMMAND="ssh -i ~/.ssh/archivist"

rsync -rltq -e "$SSH_COMMAND" "archivist@lab.martianbase.net:/var/backups/pds/" pds
&lt;/pre&gt;

&lt;p&gt;This way, at one point during the day the PDS data is copied to another folder on the server, shutting down the PDS for a moment to make sure the .sqlite files don&amp;rsquo;t end up corrupted, and then at some point later, my Mac separately rsyncs the copy of the data from that second folder, while the files aren&amp;rsquo;t being actively written to. This obviously doubles the data size requirements, so it wouldn&amp;rsquo;t be feasible for a larger PDS, but it&amp;rsquo;s totally fine on mine.&lt;/p&gt;

&lt;p&gt;And that&amp;rsquo;s it. &lt;em&gt;A bit&lt;/em&gt; more than &lt;code&gt;curl | bash&lt;/code&gt;, but I&amp;nbsp;now control every piece of it and I&amp;nbsp;can change them as I&amp;nbsp;please. The old-school way, as God intended 😎&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2025/11/18/atproto-blog-posts/</id>
    <title>ATProto blog posts collection</title>
    <published>2025-11-18T18:59:04Z</published>
    <updated>2025-11-18T18:59:04Z</updated>
    <link href="https://mackuba.eu/2025/11/18/atproto-blog-posts/"/>
    <content type="html">&lt;p&gt;I&amp;nbsp;come across a lot of blog posts about the AT Protocol and Bluesky technicals – both on Bluesky official blogs and those of the team members, and by independent developers from the community. So many people are blogging now (especially now that &lt;a href="https://leaflet.pub"&gt;Leaflet&lt;/a&gt; got popular in these circles) that I&amp;nbsp;&lt;a href="https://lab.mackuba.eu/3m5tyjv3ssc2p"&gt;started using an RSS reader again&lt;/a&gt; just to keep up with everything.&lt;/p&gt;

&lt;p&gt;These posts are usually shared widely for a day or two, and then kind of forgotten – but a lot of them contain some valuable knowledge that is still relevant much later. Even if someone remembers that something like this has been written, it&amp;rsquo;s not always easy to dig it out from the archive.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;thought it would be nice to have one place collecting those old and newer blog posts to make them easier to find. So I&amp;nbsp;went through those RSS feeds, my &lt;a href="https://lab.mackuba.eu/3m46zeyme3e6n"&gt;like archives&lt;/a&gt; and other places, and collected everything I&amp;nbsp;could find here in an organized list. I&amp;nbsp;also included the documents from the &lt;a href="https://github.com/bluesky-social/proposals"&gt;&amp;ldquo;Proposals&amp;rdquo; GitHub repo&lt;/a&gt;, and various posts from the &lt;a href="https://github.com/bluesky-social/atproto/discussions"&gt;&amp;ldquo;Discussions&amp;rdquo; section&lt;/a&gt; in the ATProto repo.&lt;/p&gt;

&lt;p&gt;This is a subjective selection – from many blogs I&amp;nbsp;skipped some less relevant posts or only included a couple out of many – so if you&amp;rsquo;re interested, click through to the home page from any post and look for the other posts there.&lt;/p&gt;
&lt;hr /&gt;

&lt;p class="noindent"&gt;Search posts by title: &lt;input type="search" id="blog_post_search"&gt;&lt;/p&gt;


&lt;h2&gt;Bluesky official sources&lt;/h2&gt;

&lt;h4&gt;atproto.com&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://atproto.com/articles/atproto-for-distsys-engineers"&gt;ATProto for distributed systems engineers&lt;/a&gt; (Sep 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://atproto.com/articles/atproto-ethos"&gt;Atproto Ethos&lt;/a&gt; (Apr 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;bsky.social/about/blog&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/4-13-2023-moderation"&gt;Composable Moderation&lt;/a&gt; (Apr 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"&gt;How to verify your Bluesky account&lt;/a&gt; (Apr 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/5-5-2023-federation-architecture"&gt;Federation Architecture Overview&lt;/a&gt; (May 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/02-22-2024-open-social-web"&gt;Bluesky: An Open Social Web&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/03-12-2024-stackable-moderation"&gt;Bluesky’s Stackable Approach to Moderation&lt;/a&gt; (Mar 2024)&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; (May 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/08-06-2024-board"&gt;Bluesky Welcomes Mike Masnick to Board of Directors&lt;/a&gt; (Aug 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/10-24-2024-series-a"&gt;Bluesky Announces Series A to Grow Network of 13M+ Users&lt;/a&gt; (Oct 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/12-30-2024-year-in-review"&gt;2024 In Review&lt;/a&gt; (Dec 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/10-01-2025-patent-pledge"&gt;Bluesky&amp;rsquo;s Patent Non-Aggression Pledge&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.social/about/blog/01-26-2026-whats-next-at-bluesky"&gt;What&amp;rsquo;s Next at Bluesky&lt;/a&gt; (Jan 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;docs.bsky.app&lt;/h4&gt;

&lt;p&gt;&lt;details&gt;
  &lt;summary&gt;Click to expand&lt;/summary&gt;&lt;/p&gt;

&lt;h4&gt;2023&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/block-implementation"&gt;Why are blocks on Bluesky public?&lt;/a&gt; (Jun 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/feature-skyfeed"&gt;Featured Community Project: Skyfeed&lt;/a&gt; (Aug 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/create-post"&gt;Posting via the Bluesky API&lt;/a&gt; (Aug 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/repo-sync-update"&gt;Updates to Repository Sync Semantics&lt;/a&gt; (Aug 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/rate-limits-pds-v3"&gt;Rate Limits, PDS Distribution v3, and More&lt;/a&gt; (Sep 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/bgs-and-did-doc"&gt;Bluesky BGS and DID Document Formatting Changes&lt;/a&gt; (Oct 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/protocol-roadmap"&gt;2023 Protocol Roadmap&lt;/a&gt; (Oct 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/repo-export"&gt;Download and Parse Repository Exports&lt;/a&gt; (Nov 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/feature-bridgyfed"&gt;Featured Community Project: Bridgy Fed&lt;/a&gt; (Dec 2023)&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;2024&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/self-host-federation"&gt;Early Access Federation for Self-Hosters&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/atproto-grants"&gt;Announcing AT Protocol Grants&lt;/a&gt; (Mar 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/blueskys-moderation-architecture"&gt;Bluesky’s Moderation Architecture&lt;/a&gt; (Mar 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/atproto-grants-recipients"&gt;Meet the second batch of AT Protocol Grant Recipients&lt;/a&gt; (Apr 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/2024-protocol-roadmap"&gt;2024 Protocol Roadmap&lt;/a&gt; (May 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/label-grants"&gt;Labeling Services Microgrants&lt;/a&gt; (May 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/ts-api-refactor"&gt;Typescript API&amp;nbsp;Package Auth Refactor&lt;/a&gt; (Aug 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/oauth-atproto"&gt;OAuth for AT Protocol&lt;/a&gt; (Sep 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/pinned-posts"&gt;Lexicons, Pinned Posts, and Interoperability&lt;/a&gt; (Oct 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/jetstream"&gt;Introducing Jetstream&lt;/a&gt; (Oct 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/relay-ops"&gt;Relay Operational Updates&lt;/a&gt; (Nov 2024)&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;2025&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/looking-back-2024"&gt;Looking Back At 2024 AT Protocol Development&lt;/a&gt; (Jan 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/2025-protocol-roadmap-spring"&gt;2025 Protocol Roadmap (Spring and Summer)&lt;/a&gt; (Mar 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/relay-sync-updates"&gt;Relay Updates for Sync v1.1&lt;/a&gt; (May 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/account-management"&gt;Network Account Management&lt;/a&gt; (May 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/oauth-improvements"&gt;OAuth Improvements&lt;/a&gt; (Jun 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/plc-directory-org"&gt;Creating an Independent Public Ledger of Credentials (PLC) Directory Organization&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/taking-at-to-ietf"&gt;Taking AT to the IETF&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/incoming-migration"&gt;Enabling Account Migration Back to Bluesky’s PDS&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/protocol-checkin-fall-2025"&gt;Protocol Check-in (Fall 2025)&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/contact-import-rfc"&gt;Request For Comments: A secure contact import scheme for social networks&lt;/a&gt; (Nov 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/introducing-tap"&gt;Introducing Tap: Repository Synchronization Made Simple&lt;/a&gt; (Dec 2025)&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;2026&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/blog/relay-rollout"&gt;Upcoming Relay Transition&lt;/a&gt; (Jan 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;/details&gt;&lt;/p&gt;

&lt;h4&gt;github.com/bluesky-social/atproto/discussions&lt;/h4&gt;

&lt;p&gt;&lt;details&gt;
  &lt;summary&gt;Click to expand&lt;/summary&gt;&lt;/p&gt;

&lt;h4&gt;2023&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1156"&gt;brainstorm ideas for Cool Developer Tools&lt;/a&gt; (Feb 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1410"&gt;Intention to remove repository history&lt;/a&gt; (Jul 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1510"&gt;Planned Changes to DID Documents (August 2023)&lt;/a&gt; (Aug 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1632"&gt;DID PLC Rate Limits and Validation&lt;/a&gt; (Sep 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1711"&gt;Upcoming Disruptive Protocol and Infra Changes&lt;/a&gt; (Oct 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1832"&gt;Migrating bsky.social to Multiple PDS Instances&lt;/a&gt; (Nov 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1847"&gt;That which we call a “BGS”, By any other name would smell as sweet&lt;/a&gt; (Nov 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/1910"&gt;Tightening Datetime, Record Key, and TID validation&lt;/a&gt; (Dec 2023)&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;2024&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2128"&gt;Protocol Tech Debt&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2220"&gt;Summary of Recent Changes&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2293"&gt;March 2024 Protocol Updates&lt;/a&gt; (Mar 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2350"&gt;What does a PDS implementation entail?&lt;/a&gt; (Mar 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2415"&gt;What goes in to a Bluesky or atproto SDK?&lt;/a&gt; (Apr 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2656"&gt;OAuth Roadmap&lt;/a&gt; (Jul 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2687"&gt;Service auth token iteration – method binding &amp;amp; nonces&lt;/a&gt; (Aug 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2754"&gt;Brainstorming: atproto Dev Tooling and Experience&lt;/a&gt; (Aug 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/2961"&gt;What does an AppView implementation entail?&lt;/a&gt; (Nov 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3036"&gt;Relay Operational Updates&lt;/a&gt; (Nov 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3049"&gt;Call for Developer Projects&lt;/a&gt; (Nov 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3074"&gt;RFC: Lexicon Resolution&lt;/a&gt; (Nov 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3175"&gt;Account Lifecycle Best Practices&lt;/a&gt; (Dec 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3176"&gt;Account Migration Details&lt;/a&gt; (Dec 2024)&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;2025&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3655"&gt;Proposal: OAuth Scopes&lt;/a&gt; (Mar 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3928"&gt;Relaxing DID PLC Verification Method Constraints&lt;/a&gt; (Jul 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/3950"&gt;OAuth Client Security in the Atmosphere&lt;/a&gt; (Jul 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4068"&gt;Adding internal repositories to Bluesky’s workflows&lt;/a&gt; (Jul 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4118"&gt;Progress on Auth Scopes Implementation (August 2025)&lt;/a&gt; (Aug 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4245"&gt;Draft Lexicon Style Guide (Lexinomicon)&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4316"&gt;PLC Operational Updates&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4343"&gt;Lexicon Language Corner Cases&lt;/a&gt; (Nov 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4437"&gt;Early Permission Sets&lt;/a&gt; (Dec 2025)&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;2025&lt;/h4&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4508"&gt;PLC Export API&amp;nbsp;Update&lt;/a&gt; (Jan 2026)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/atproto/discussions/4618"&gt;Adding content-disposition on getBlob HTTP responses&lt;/a&gt; (Feb 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;/details&gt;&lt;/p&gt;

&lt;h4&gt;github.com/bluesky-social/proposals&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0001-user-lists-replygating-and-thread-moderation"&gt;0001: User Lists, Reply-Gating, and Thread Moderation&lt;/a&gt; (Jun 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0002-labeling-and-moderation-controls"&gt;0002: Labeling and Moderation Controls&lt;/a&gt; (Jun 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0003-hashtags"&gt;0003: Hashtags&lt;/a&gt; (Jun 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0004-oauth"&gt;0004: OAuth 2.0 for the AT Protocol&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0005-mod-history"&gt;0005: Ozone Moderation History&lt;/a&gt; (Oct 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0006-sync-iteration"&gt;0006: AT Protocol Sync v1.1&lt;/a&gt; (Feb 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0007-mod-report-routing"&gt;0007: Moderation Report Routing&lt;/a&gt; (Feb 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0008-user-intents"&gt;0008: User Intents for Data Reuse&lt;/a&gt; (Mar 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0009-mod-report-granularity"&gt;0009: Moderation Report Granularity&lt;/a&gt; (May 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend"&gt;0010: Client assertion backend for browser-based applications&lt;/a&gt; (Jun 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0011-auth-scopes"&gt;0011: Auth Scopes for ATProto&lt;/a&gt; (Jun 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/bluesky-social/proposals/tree/main/0012-infra-abuse-notices"&gt;0012: Infrastructure Abuse Notices&lt;/a&gt; (Nov 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

&lt;h2&gt;Bluesky team&lt;/h2&gt;

&lt;h4&gt;jaygraber.medium.com (Jay Graber, CEO)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://jaygraber.medium.com/web3-is-self-certifying-9dad77fd8d81"&gt;Web3 is Self-Certifying&lt;/a&gt; (Dec 2021)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;pfrazee.com (Paul Frazee, CTO)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.pfrazee.com/blog/why-facets"&gt;Why RichText facets in Bluesky&lt;/a&gt; (Jan 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.pfrazee.com/blog/why-not-rdf"&gt;Why not RDF in the AT Protocol?&lt;/a&gt; (Jan 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.pfrazee.com/blog/why-not-p2p"&gt;Why isn&amp;rsquo;t Bluesky a peer-to-peer network?&lt;/a&gt; (Jan 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.pfrazee.com/blog/lexicon-guidance"&gt;Guidance on Authoring Lexicons&lt;/a&gt; (Mar 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.pfrazee.com/blog/atmospheric-computing"&gt;Atmospheric Computing&lt;/a&gt; (Dec 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.pfrazee.com/blog/practical-decentralization"&gt;Practical Decentralization&lt;/a&gt; (Feb 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;pfrazee.leaflet.pub (Paul Frazee, CTO)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://pfrazee.leaflet.pub/3lyucxaykg22w"&gt;We probably need to rename the AppView&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pfrazee.leaflet.pub/3lz4sgu7iec2k"&gt;Update on Protocol Moderation&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pfrazee.leaflet.pub/3lzhmtognls2q"&gt;Private data: developing a rubric for success&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pfrazee.leaflet.pub/3lzhui2zbxk2b"&gt;Three schemes for shared-private storage&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pfrazee.leaflet.pub/3m3dogbx2es2g"&gt;Social platforms are not neutral&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pfrazee.leaflet.pub/3m5hwua4sh22v"&gt;The politics of purely client-side apps&lt;/a&gt; (Nov 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;bnewbold.net (Bryan Newbold)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bnewbold.net/2024/atproto_progress/"&gt;Progress on atproto Values and Value Proposition&lt;/a&gt; (Aug 2024)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;whtwnd.com/bnewbold.net (Bryan Newbold)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/bnewbold.net/3kwzl7tye6u2y"&gt;Notes on Running a Full-Network atproto Relay&lt;/a&gt; (Jul 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/bnewbold.net/3l5ii332pf32u"&gt;Migrating PDS Account with &amp;lsquo;goat&amp;rsquo;&lt;/a&gt; (Oct 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/bnewbold.net/3lbvbtqrg5t2t"&gt;Reply on Bluesky and Decentralization&lt;/a&gt; (Nov 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/bnewbold.net/3lj7jmt2ct72r"&gt;Registering Identity Recovery Keys via PDS, using goat&lt;/a&gt; (Feb 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/bnewbold.net/3lo7a2a4qxg2l"&gt;A Full-Network Relay for $34 a Month&lt;/a&gt; (May 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/bnewbold.net/3m2j6ccx2bs2t"&gt;AT Moderation Architecture&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/bnewbold.net/3mdc7fpbxhk26"&gt;Creating a did:web atproto account using goat&lt;/a&gt; (Jan 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;bnewbold.leaflet.pub (Bryan Newbold)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bnewbold.leaflet.pub/3m2x7bilyrc23"&gt;AT Namespaces for Community Spaces&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bnewbold.leaflet.pub/3m5jsx7qrws2n"&gt;Record Versioning&lt;/a&gt; (Nov 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bnewbold.leaflet.pub/3m7e3hk57rs2u"&gt;Big Indexing&lt;/a&gt; (Dec 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bnewbold.leaflet.pub/3me3ea64bhk26"&gt;Community Spaces on AT Protocol&lt;/a&gt; (Feb 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;dholms.leaflet.pub (Daniel Holms)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dholms.leaflet.pub/3m6zswymcqk2p"&gt;PLC Threat-modeling &amp;amp; Auditability&lt;/a&gt; (Dec 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dholms.leaflet.pub/3meluqcwky22a"&gt;Permissioned Data Diary 1: To Encrypt or Not to Encrypt&lt;/a&gt; (Feb 2026)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dholms.leaflet.pub/3mfrsbcn2gk2a"&gt;Permissioned Data Diary 2: Buckets&lt;/a&gt; (Feb 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;jimray-bsky.leaflet.pub (Jim Ray)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://jimray-bsky.leaflet.pub/3maenxn7kqc2a"&gt;The Importance of Backfillability&lt;/a&gt; (Dec 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;mozzius.dev (Samuel)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mozzius.dev/post/3l777nhz4h32w"&gt;React Native, and &amp;ldquo;the native feel&amp;rdquo;&lt;/a&gt; (Oct 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mozzius.dev/post/3ljlqmchv2b2a"&gt;ATProto by example part 1: Records and Views&lt;/a&gt; (Mar 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;emilyliu.me (Emily Liu, ex-Bluesky)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://emilyliu.me/blog/comments"&gt;Using Bluesky posts as blog comments&lt;/a&gt; (Nov 2024)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;jazco.dev (Jaz, ex-Bluesky)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2023/09/28/request-coalescing/"&gt;Solving Thundering Herds with Request Coalescing in Go&lt;/a&gt; (Sep 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2024/01/10/golang-and-epoll/"&gt;Scaling Go to 192 Cores with Heavy I/O&lt;/a&gt; (Jan 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2024/04/15/in-memory-graphs/"&gt;Your Data Fits in Memory (GraphD Part 1)&lt;/a&gt; (Apr 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2024/04/20/roaring-bitmaps/"&gt;An entire Social Network in 1.6GB (GraphD Part 2)&lt;/a&gt; (Apr 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2024/07/05/hls/"&gt;How HLS Works&lt;/a&gt; (Jul 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2024/09/24/jetstream/"&gt;Jetstream: Shrinking the AT Proto Firehose by &gt;99%&lt;/a&gt; (Sep 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2025/02/19/imperfection/"&gt;When Imperfect Systems are Good, Actually: Bluesky&amp;rsquo;s Lossy Timelines&lt;/a&gt; (Feb 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jazco.dev/2025/09/26/interning/"&gt;Turning Billions of Strings into Integers Every Second Without Collisions&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

&lt;h2&gt;ATProto community&lt;/h2&gt;

&lt;h4&gt;whtwnd.com/futur.blue (Futur)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/futur.blue/3lkubavdilf2m"&gt;atproto relay any% speedrun&lt;/a&gt; (Mar 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whtwnd.com/futur.blue/3ls7sbvpsqc2w"&gt;in and out, quick appview adventure&lt;/a&gt; (Jun 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;overreacted.io (Dan Abramov, ex-Bluesky)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://overreacted.io/open-social/"&gt;Open Social&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://overreacted.io/where-its-at/"&gt;Where It&amp;rsquo;s at://&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://overreacted.io/a-social-filesystem/"&gt;A Social Filesystem&lt;/a&gt; (Jan 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;underreacted.leaflet.pub (Dan Abramov, ex-Bluesky)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://underreacted.leaflet.pub/3m23gqakbqs2j"&gt;we can just do things&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;bsky.bad-example.com (Phil)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bsky.bad-example.com/consuming-the-firehose-correctly/"&gt;consuming the jetstream firehose correctly&lt;/a&gt; (Feb 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.bad-example.com/can-atproto-scale-down/"&gt;Can atproto scale down?&lt;/a&gt; (Feb 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;mackuba.eu 🙃&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mackuba.eu/2024/02/21/bluesky-guide/"&gt;A complete guide to Bluesky&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mackuba.eu/2025/08/20/introduction-to-atproto/"&gt;Introduction to AT Protocol&lt;/a&gt; (Aug 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://mackuba.eu/2026/02/04/pds-undockered/"&gt;Running Bluesky PDS undockered&lt;/a&gt; (Feb 2026)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;shreyanjain.net (Shreyan)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://shreyanjain.net/2024/07/05/nostr-and-atproto.html"&gt;Nostr and ATProto&lt;/a&gt; (Jul 2024)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;jcsalterego.leaflet.pub (Jerry Chen)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://jcsalterego.leaflet.pub/3m3x2oftqbs2v"&gt;We live in a space station&lt;/a&gt; (Oct 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;da.vidbuchanan.co.uk (David Buchanan, @retr0.id)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.da.vidbuchanan.co.uk/blog/hacking-bluesky.html"&gt;Hijacking Bluesky Identities with a Malleable Deputy&lt;/a&gt; (Sep 2023)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.da.vidbuchanan.co.uk/blog/adversarial-pds-migration.html"&gt;Adversarial ATProto PDS Migration&lt;/a&gt; (Jul 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;marvins-guide.leaflet.pub (Bailey Townsend)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://marvins-guide.leaflet.pub/3m5fjxkcans2i"&gt;Host a PDS via a Cloudflare Tunnel&lt;/a&gt; (Jul 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marvins-guide.leaflet.pub/3m5fknwepe22y"&gt;A blob in the bucket&lt;/a&gt; (Aug 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marvins-guide.leaflet.pub/3lyqxqbbqkc2p"&gt;What the hell is the atmosphere anyway&lt;/a&gt; (Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marvins-guide.leaflet.pub/3m4qzoj6ubc2h"&gt;What the hell is a rotation key? &lt;/a&gt; (Nov 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;dame.is (Dame)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dame.is/writing/blogs/how-i-made-an-automated-dynamic-avatar-for-my-bluesky-profile/"&gt;How I&amp;nbsp;made an automated dynamic avatar for my Bluesky profile&lt;/a&gt; (Feb 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dame.is/writing/blogs/creating-a-decentralized-bathroom-at-protocol/"&gt;Creating a decentralized bathroom (powered by the AT Protocol)&lt;/a&gt; (Mar 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dame.is/writing/blogs/a-guestbook-and-welcome-message-for-my-pds/"&gt;A guestbook and welcome message for my atproto PDS&lt;/a&gt; (May 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;steveklabnik.com (Steve Klabnik)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://steveklabnik.com/writing/how-does-bluesky-work/"&gt;How Does BlueSky Work?&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;knotbin.leaflet.pub (Roscoe Rubin-Rottenberg)&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://knotbin.leaflet.pub/3lx3uqveyj22f"&gt;Wherever you get your Podcasts&lt;/a&gt; (Aug 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;blog.smokesignal.events&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.smokesignal.events/posts/3lwopvsmtx22a-creating-a-did-method-web-identity-for-atprotocol"&gt;Creating a did-method-web Identity for ATProtocol&lt;/a&gt; (Aug 2025)&lt;/li&gt;
&lt;/ul&gt;


&lt;h4&gt;graysky.app&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://graysky.app/blog/2024-02-05-adding-blog-comments"&gt;Adding comments to this blog&lt;/a&gt; (Feb 2024)&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

&lt;h2&gt;Other recommended blogs&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://connectedplaces.online"&gt;Connected Places newsletter&lt;/a&gt; (Laurens Hof)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://connectedplaces.leaflet.pub"&gt;Connected Places Leaflet&lt;/a&gt; (Laurens Hof)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.stream.place"&gt;How Streamplace Works&lt;/a&gt; (Eli Mallon)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.tangled.org"&gt;Tangled engineering&lt;/a&gt; (@tangled.org)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngerakines.leaflet.pub"&gt;Nick&amp;rsquo;s Blog&lt;/a&gt; (Nick Gerakines)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://retrobailey.leaflet.pub"&gt;Bailey&amp;rsquo;s Weekly Retrospective&lt;/a&gt; (Bailey Townsend)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dame.leaflet.pub"&gt;dame&amp;rsquo;s leaflets&lt;/a&gt; (Dame)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://icy.leaflet.pub"&gt;icy takes&lt;/a&gt; (Anirudh Oppiliappan)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://juliet.leaflet.pub"&gt;Juliet&amp;rsquo;s rambles&lt;/a&gt; (Juliet)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://samuel.leaflet.pub"&gt;mildly at-musing&lt;/a&gt; (Samuel)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://smokesignal.leaflet.pub"&gt;The Smoke Signal blog&lt;/a&gt; (@smokesignal.events)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://matthieu.leaflet.pub"&gt;Matthieu&amp;rsquo;s Leaflet&lt;/a&gt; (Matthieu Sieben)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://augment.leaflet.pub"&gt;augment&lt;/a&gt; (Anuj Ahooja)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.tree.fail"&gt;Tree For You&lt;/a&gt; (Tree)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.anew.social"&gt;A New Social&lt;/a&gt; (Bridgy Fed)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lab.mackuba.eu"&gt;Kuba&amp;rsquo;s Lab Notes&lt;/a&gt; 😉&lt;/li&gt;
&lt;/ul&gt;


&lt;script type="text/javascript"&gt;
  document.getElementById('blog_post_search').addEventListener('input', (e) =&gt; {
    let query = e.target.value.trim().toLowerCase();
    let entries = Array.from(document.querySelectorAll('article ul li'));
    let details = Array.from(document.querySelectorAll('article details'));
    let headers = Array.from(document.querySelectorAll('article h4'));

    function ulHasVisibleEntries(h) {
      return Array.from(h.nextElementSibling.querySelectorAll('li')).some(x =&gt; x.style.display === '');
    }

    if (query.length &gt; 0) {
      entries.forEach(li =&gt; { li.style.display = (li.innerText.toLowerCase().includes(query) ? '' : 'none') });
      headers.forEach(h =&gt; { h.style.display = (ulHasVisibleEntries(h) ? '' : 'none') })
      details.forEach(d =&gt; { d.open = true });
    } else {
      entries.forEach(li =&gt; { li.style.display = '' });
      headers.forEach(h =&gt; { h.style.display = '' })
      details.forEach(d =&gt; { d.open = false });
    }
  });
&lt;/script&gt;

</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2025/10/15/three-bases-one-app/</id>
    <title>How I ran one Ruby app on three SQL databases for six months</title>
    <published>2025-10-15T19:11:16Z</published>
    <updated>2025-10-15T19:11:16Z</updated>
    <link href="https://mackuba.eu/2025/10/15/three-bases-one-app/"/>
    <content type="html">&lt;p&gt;Since June 2023, I’ve been running a service written in Ruby (Sinatra) that provides several &lt;a href="https://blue.mackuba.eu"&gt;Bluesky custom feeds&lt;/a&gt; (initially built with a feed for the &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/apple"&gt;iOS/Mac developers community&lt;/a&gt; in mind, later expanded to many other feeds). If you don’t know much about Bluesky feeds, you make them by basically running a server which somehow collects and picks existing posts from Bluesky using some kind of algorithm (chronological or by popularity, based on keyword matching, personal likes, whatever you want), and then exposes a specific API&amp;nbsp;endpoint. The Bluesky AppView (API&amp;nbsp;server) then calls your service passing some request parameters, and your service responds with a list of URIs of posts (which the API&amp;nbsp;server then turns into full post JSON and returns to the client app). This lets you share such feed with anyone on the platform, so they can add it to their app and use it like any built-in feed. (If you&amp;rsquo;re interested, check out my &lt;a href="https://github.com/mackuba/bluesky-feeds-rb"&gt;example feed service project&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;In order to provide such service, in practice you need to connect to the Bluesky “firehose” streaming API&amp;nbsp;which sends you all posts made by anyone on the network, and then save either those which are needed for your algorithm, or save all of them and filter later. I&amp;nbsp;chose the latter, since that lets me retry the matching at any time after I&amp;nbsp;modify the keyword lists and see what would be added after that change (and also some of the feeds I&amp;nbsp;now run require having all posts). I&amp;nbsp;also use the same database/service to generate e.g. the &lt;a href="https://blue.mackuba.eu/stats/"&gt;total daily/weekly stats here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;All posts made on Bluesky is much less than all posts on Twitter, of course, but it’s still a lot of posts. At the moment (October 2025), there are around 3.5M posts made on average every day; at the last “all time high” in November 2024, it was around 7.5M per day. A post is up to 300 characters of (Unicode) text, but since I&amp;nbsp;also store the other metadata that’s in the record JSON, like timestamp, reply/quote references, embeds like images and link cards, language tags etc., it adds up to a bit less than 1 KB of storage per post on average.&lt;/p&gt;

&lt;p&gt;In addition to that, the firehose stream (if you use the original CBOR stream from a relay, not &lt;a href="https://github.com/bluesky-social/jetstream"&gt;Jetstream&lt;/a&gt;, which is a JSON-serving proxy) includes a lot of overhead data that you don’t need in a service like that, plus all the other types of events like handle changes, likes, follows, blocks, reposts, and so on. The total input traffic is around 15 Mbit/s average right now in October 2025 (or around 5 TB per month), and it used to be around twice that for a moment last year. (Jetstream sends around an order of magnitude less, especially if you ask it to send filtered data, e.g. only the posts.)&lt;/p&gt;

&lt;p&gt;On disk, the millions of posts per day add up to a few gigabytes per day. Since I&amp;nbsp;was running this on a VPS with a 256 GB disk (&lt;a href="https://www.netcup.com/en/?ref=227926"&gt;Netcup, RS 1000&lt;/a&gt; – reflink), I&amp;nbsp;have a cron job set up to regularly prune all older posts and keep only e.g. last 40 days worth of them (since I&amp;nbsp;don’t really need to keep the older posts forever), so around 200-ish gigabytes total, and around 200 millions of rows in the posts table.&lt;/p&gt;

&lt;p&gt;And until March this year, I&amp;nbsp;was keeping all this data in… SQLite 🫠&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Chapter 1: You&amp;rsquo;re probably wondering how I&amp;nbsp;ended up in this situation&lt;/h2&gt;

&lt;p&gt;I&amp;nbsp;think I&amp;nbsp;had never used SQLite in a web app before this – I&amp;nbsp;normally always used MySQL, since that was commonly used in PHP first and then in Ruby webapps (Rails/Sinatra w/ ActiveRecord). I&amp;nbsp;was used to it, I&amp;nbsp;knew how it worked more or less, and I&amp;nbsp;always thought SQLite was only meant to be used in embedded scenarios like native desktop/mobile apps. But the example &lt;a href="https://github.com/bluesky-social/feed-generator"&gt;feed-generator project in JS&lt;/a&gt; shared by Bluesky used SQLite, and since I&amp;nbsp;started out by porting that to Ruby, I&amp;nbsp;ended up also using SQLite, planning to switch to something else later. But you know how it is, that “later” time never comes – and if it ain’t broke, don’t fix it. SQLite worked surprisingly well for much much longer than I&amp;nbsp;expected, with much more data than I&amp;nbsp;expected, and it turns out it can be absolutely ok for server/webapp database purposes, at least in some scenarios. But eventually I&amp;nbsp;started hitting some limitations.&lt;/p&gt;

&lt;p&gt;The main problem is that SQLite doesn’t allow concurrent write access. This means that many processes can read posts or other data simultanously, but only one process at a time can write to the database. This could be a serious problem in most webapps, but it worked fine in this particular architecture. You see, there’s really one entry point to the system where the posts are saved: the firehose stream consumer process. All posts come from there, and nothing else saves posts, the Sinatra API&amp;nbsp;server only makes queries to what was already saved from the firehose. This is why this has worked for me for as long as it did.&lt;/p&gt;

&lt;p&gt;However… at some point I&amp;nbsp;added a second parallel thread, which separately reads data from the &lt;a href="https://plc.directory"&gt;plc.directory&lt;/a&gt; and saves data about some accounts' handles and assigned PDS servers. There are also cron jobs running scripts and Rake tasks, which sometimes modify data and sometimes take a bit to run (like that older posts cleaner), and I&amp;nbsp;sometimes run Rake tasks manually to e.g. rebuild feeds.&lt;/p&gt;

&lt;p&gt;And this is where I&amp;nbsp;started running into a second related problem: how this concurrent writing is/was handled in ActiveRecord. I&amp;nbsp;don’t know if I&amp;nbsp;can explain this all correctly (see &lt;a href="https://github.com/rails/rails/issues/13908"&gt;this GitHub issue&lt;/a&gt; and &lt;a href="https://stackoverflow.com/a/26150137"&gt;this StackOverflow comment&lt;/a&gt;), but the gist is, ActiveRecord used some mode of data locking in SQLite, which resulted in a flow like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A transaction is started which locks the data for reading.&lt;/li&gt;
&lt;li&gt;Some records are loaded and turned into AR models (through a &lt;code&gt;where&lt;/code&gt;/&lt;code&gt;find_by&lt;/code&gt; call etc.).&lt;/li&gt;
&lt;li&gt;Some updates are made to the model objects.&lt;/li&gt;
&lt;li&gt;When I&amp;nbsp;call &lt;code&gt;save&lt;/code&gt; on the model, AR tries to change the lock mode that would allow it to also make a write.&lt;/li&gt;
&lt;li&gt;But something else has made other writes in the meantime.&lt;/li&gt;
&lt;li&gt;Because of how the transaction/locks were set up, SQLite decides that the data I’m trying to save might have been modified in the meantime, and aborts the whole transaction and throws an error (&lt;code&gt;SQLite3::BusyException: database is locked&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;


&lt;p&gt;Changing timeout settings doesn’t fix the problem, because the exception is thrown immediately, without waiting for the other process to finish. What worked around the problem was a mix of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;trying to avoid doing multiple writing operations in parallel at all&lt;/li&gt;
&lt;li&gt;&lt;p&gt;rearranging the code a bit artificially so it opens a transaction and then &lt;em&gt;first&lt;/em&gt; does some kind of write, even a completely pointless one, before doing the reads, which makes it create the right kind of lock from the beginning:&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;def rescan_feed_items(feed)
  ActiveRecord::Base.transaction do
    # lulz
    FeedPost.where(feed_id: -1).update_all(feed_id: -1)

    feed_posts = FeedPost.where(feed_id: feed.feed_id).includes(:post).order('time')
    feed_posts.each do |fp|
      ...
    end
  end
end
&lt;/pre&gt;

&lt;p&gt;or so that it does the write without touching the original model, e.g.:&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;# instead of:
@post.update(thread_id: root.id)

# do:
Post.where(id: @post.id).update_all(thread_id: root.id)
&lt;/pre&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;This has mostly let me avoid the problem for a long time, but this meant it kept popping back up sometimes, and I&amp;nbsp;had to write some code sometimes in a way that didn’t logically make sense and was only like that to avoid the exceptions. In particular, with the &lt;a href="https://plc.directory"&gt;plc.directory&lt;/a&gt; thread, I&amp;nbsp;had to make it pass any changes back to the main thread through a queue so they can be saved to the database from there.&lt;/p&gt;

&lt;p&gt;(Ironically, the ActiveRecord folks finally &lt;a href="https://github.com/rails/rails/pull/50371"&gt;fixed this whole problem&lt;/a&gt; in the 8.0 release, just as I&amp;nbsp;finished migrating away from SQLite…)&lt;/p&gt;

&lt;p&gt;A second problem was that I&amp;nbsp;started having some performance issues that I&amp;nbsp;couldn’t find a good solution for – e.g. post write operations were occasionally randomly taking e.g. 1 or 2 seconds to finish instead of milliseconds; and I&amp;nbsp;wanted to optimize the app to be able to potentially save as many posts per second as possible, to prepare for larger traffic in the future.&lt;/p&gt;

&lt;p&gt;When I&amp;nbsp;was asking for advice, everyone was telling me “dude, just switch to Postgres” 😛 But I&amp;nbsp;haven’t really worked with Postgres before other than briefly, and I&amp;nbsp;knew some things were different there, and I&amp;nbsp;wasn’t sure if I&amp;nbsp;want to switch to something unknown rather than what I&amp;nbsp;knew (MySQL).&lt;/p&gt;

&lt;p&gt;And since I&amp;nbsp;have way too much time, no life, and probably a good bit of neurodivergence, I&amp;nbsp;chose the most obvious solution: set up the app on &lt;em&gt;both&lt;/em&gt; MySQL and on Postgres on two separate &amp;amp; identical VPSes, and compare how it works in both versions… 🫣&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Chapter 2: Getting it to run&lt;/h2&gt;

&lt;p&gt;Turns out, SQL databases are a bit different from each other in a lot of aspects, and migrating a webapp from one to the other is a bit more work than just editing the &lt;code&gt;Gemfile&lt;/code&gt; and &lt;code&gt;database.yml&lt;/code&gt; – who would&amp;rsquo;ve guessed…&lt;/p&gt;

&lt;p&gt;Beyond the obvious, here are some things I&amp;nbsp;had to change on the &lt;strong&gt;MySQL branch&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;Some column type changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;integer column sizes – in SQLite, &lt;a href="https://www.sqlite.org/datatype3.html"&gt;all numbers are just integers of any size&lt;/a&gt;, so here I&amp;nbsp;changed some to &lt;code&gt;smallint&lt;/code&gt; and some to &lt;code&gt;bigint&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;similarly, some &lt;code&gt;string&lt;/code&gt; columns were changed to &lt;code&gt;text&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;for &lt;code&gt;datetime&lt;/code&gt; columns, I’ve set the decimal precision explicitly to 6 digits (this is now the &lt;a href="https://github.com/rails/rails/pull/42297"&gt;default since AR 7.0&lt;/a&gt;, I&amp;nbsp;started on 6.x for some reason)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;In queries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I&amp;nbsp;removed some index hacks like &lt;code&gt;.where("+thread_id IS NULL”)&lt;/code&gt;, which tell the SQLite query optimizer to use/not use a given index&lt;/li&gt;
&lt;li&gt;some date operations had to be rewritten to use different functions, e.g. &lt;code&gt;DATETIME('now', '-7 days')&lt;/code&gt; to &lt;code&gt;SUBDATE(CURRENT_TIMESTAMP, 7)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;some queries had to be rewritten or updated because they were just throwing SQL syntax errors – e.g. I&amp;nbsp;had to explicitly list table names on some fields in &lt;code&gt;SELECT&lt;/code&gt;; or there was this thing in MySQL where I&amp;nbsp;had to &lt;a href="https://stackoverflow.com/a/9843719"&gt;nest a subquery for DELETE one level deeper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ActiveRecord::Base.connection.execute&lt;/code&gt; returns rows as arrays instead of hashes for some reason, indexed by &lt;code&gt;[0]&lt;/code&gt; not by &lt;code&gt;['field']&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;For &lt;strong&gt;Postgres&lt;/strong&gt;, in addition to most of the above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I&amp;nbsp;had to replace &lt;code&gt;0&lt;/code&gt; / &lt;code&gt;1&lt;/code&gt; used as false/true in boolean columns with an explicit &lt;code&gt;FALSE&lt;/code&gt; / &lt;code&gt;TRUE&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;date functions in queries were slightly different again, e.g. &lt;code&gt;DATE_SUBTRACT(CURRENT_TIMESTAMP, INTERVAL '7 days')&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;strings in queries had to be changed to all be in single quotes, not in double quotes, which in Pg are reserved for field names like &lt;code&gt;"post_id"&lt;/code&gt; (what SQLite and MySQL use the backticks for: &lt;code&gt;`post_id`&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;I&amp;nbsp;could also finally remove all the code hacks added to work around the SQLite concurrency issues – start normally saving handles in the PLC importer thread, rewrite some transaction blocks to a more logical form, and so on.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Chapter 3: Migrating the data&lt;/h2&gt;

&lt;p&gt;For migration to MySQL, I&amp;nbsp;used the &lt;a href="https://github.com/techouse/sqlite3-to-mysql"&gt;sqlite3mysql&lt;/a&gt; tool, written in Python:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;pip install sqlite3-to-mysql

sqlite3mysql -K -E -f bluesky.sqlite3 -d bluefeeds_production -u kuba -i DEFAULT -t feed_posts handles post_stats subscriptions unknown_records ...
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For the posts table (which is the vast majority of the database size), I&amp;nbsp;used the &lt;code&gt;-c&lt;/code&gt; (&lt;code&gt;--chunk&lt;/code&gt;) option to import posts in batches:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;sqlite3mysql -K -E -f bluesky.sqlite3 -d bluefeeds_production -u kuba -i DEFAULT -t posts -c 1000000
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For Postgres, I&amp;nbsp;used &lt;a href="https://pgloader.readthedocs.io"&gt;pgloader&lt;/a&gt;. Unlike sqlite3mysql, it isn’t configured through command-line flags, but instead you need to write “command” files with a special DSL and then pass the filename in the argument. So my command files looked something like this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;load database
from sqlite://./db/bluesky.sqlite3
into postgresql:///bluefeeds_production

with data only, truncate
including only table names like 'feed_posts', 'handles', 'post_stats', 'subscriptions', 'unknown_records';
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I’ve split the tables into several command files, because I&amp;nbsp;wanted to do it a bit more step by step since some of the imports were failing.&lt;/p&gt;

&lt;p&gt;For the posts table, I&amp;rsquo;ve similarly set a “prefetch rows” flag to do it in batches:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;load database
from sqlite://./db/bluesky.sqlite3
into postgresql:///bluefeeds_production

with data only, truncate, prefetch rows = 10000
including only table names like 'posts';
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Using the two importers are two very different experiences: sqlite3mysql takes quite a lot of time to import, but shows very nice progress bars and remaining time estimates; pgloader gives you basically no updates until it&amp;rsquo;s finished, and even if some tables fail to import, it&amp;rsquo;s not immediately clear from the summary what happened – however, it does the job in as much as two orders of magnitude less time 😅 I&amp;nbsp;don&amp;rsquo;t know how much this is because of the tool or the database though (and there are probably ways to speed up the import in MySQL, e.g. by dropping indexes first).&lt;/p&gt;

&lt;p&gt;One surprise realization I&amp;nbsp;had during the import: in SQLite, when you define a column as string with limit 50, it doesn’t actually enforce that limit! Apparently I&amp;nbsp;had a whole bunch of records in some tables where the values were much longer than the expected max length… because I&amp;nbsp;was missing Ruby-side AR validations (&lt;code&gt;validates_length_of&lt;/code&gt;) in some models – and those records were being rejected by both new databases. So I&amp;nbsp;had to add all those missing length validations and clean up the invalid data first.&lt;/p&gt;

&lt;p&gt;An additional problem cropped up in MySQL, which has different &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/charset-collation-names.html"&gt;text collation rules&lt;/a&gt; depending on accents and unicode normalization than SQLite &amp;amp; Postgres. I&amp;nbsp;have a “hashtags” table listing all hashtags that appeared anywhere in the posts, with a unique index on the hashtag name – but the import to MySQL was failing, because some hashtags were considered by MySQL as having the same text as some others, while SQLite had considered them different… I&amp;nbsp;tried to pick a different collation for the table (&lt;code&gt;utf8mb4_0900_as_cs&lt;/code&gt;, i.e. both accent-sensitive
and case-sensitive), but that only partially helped with some name pairs (&amp;ldquo;pokemon&amp;rdquo; vs. &amp;ldquo;pokémon&amp;rdquo;), but not with others (different normalization, or invisible control characters, and there are *countless* different types of those, as I&amp;nbsp;have learned…). I&amp;nbsp;eventually gave up and ended up just dropping the unique index for now.&lt;/p&gt;

&lt;p&gt;In Postgres, in turn, the problem was that it apparently doesn’t support strings that contain null bytes, and there are some occasional posts that somehow end up with a &lt;code&gt;\0&lt;/code&gt; in the post text… So I&amp;nbsp;had to just filter out such posts as invalid.&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;if text.include?("\u0000")
  return
end
&lt;/pre&gt;

&lt;p&gt;Finally, something that somehow caused an issue in both versions was a thing that every programmer loves – timezones… When I&amp;nbsp;made a query to count posts added in the last 5 minutes, and it always returned 0, but “last 65 minutes” didn’t, I&amp;nbsp;immediately knew what was going on 🫠&lt;/p&gt;

&lt;p&gt;In Postgres, the solution was to tell ActiveRecord to use the &lt;code&gt;timestamptz&lt;/code&gt; data type for timestamp columns instead of the default &lt;code&gt;timestamp&lt;/code&gt;:&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz
&lt;/pre&gt;

&lt;p&gt;(and then migrate the existing columns to use that type).&lt;/p&gt;

&lt;p&gt;In MySQL, you need to either tell AR to use the timezone the database is using, if it’s not set to UTC:&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;ActiveRecord.default_timezone = :local
&lt;/pre&gt;

&lt;p&gt;Or tell the database to use UTC:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[mysqld]
default-time-zone = '+00:00'
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;(but not both…)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Chapter 4: Optimizing&lt;/h2&gt;

&lt;p&gt;To be able to test both databases with real production traffic in a way that would let me compare them in a fair competition, I&amp;rsquo;ve set up a kind of “database A/B test” 🙃&lt;/p&gt;

&lt;p&gt;The feed was configured to load data from my original SQLite server as before, but in the request handler on that server, instead of calling the feed class locally, the code picked one of the two other servers based on either the &amp;ldquo;&lt;a href="/2025/08/20/introduction-to-atproto/#identity"&gt;DID&lt;/a&gt;&amp;rdquo; (user ID) of the caller or current timestamp, proxied the call to that server, took the response, and returned it back to the caller:&lt;/p&gt;

&lt;pre class="brush: ruby"&gt;get '/xrpc/app.bsky.feed.getFeedSkeleton' do
  feed_key = params[:feed].split('/').last

  if ['hashtag', 'follows-replies'].include?(feed_key)
    server = Time.now.hour.odd? ? SERVER_A : SERVER_B
  elsif ['replies'].include?(feed_key)
    server = Time.now.hour.even? ? SERVER_A : SERVER_B
  else
    did = parse_did_from_token
    server = did.nil? || did =~ /[a-m0-4]$/ ? SERVER_A : SERVER_B
  end

  url = "https://#{server}/xrpc/app.bsky.feed.getFeedSkeleton?" + request.query_string
  headers = env['HTTP_AUTHORIZATION'] ? { 'Authorization' =&amp;gt; env['HTTP_AUTHORIZATION'] } : {}
  response = Net::HTTP.get_response(URI(url), headers)

  content_type :json
  [response.code.to_i, response.body]
end
&lt;/pre&gt;

&lt;p&gt;This meant that each feed load took a bit longer, but it wasn’t very noticeable in practice, and I&amp;nbsp;had the real traffic split into two more  or less equal parts, going to the two servers. (Coincidentally, it&amp;rsquo;s exactly one year today since I&amp;nbsp;deployed that change – I&amp;rsquo;ve been procrastinating way too long on this blog post 🫠)&lt;/p&gt;

&lt;p&gt;And then started my months-long work of optimizing the databases and queries…&lt;/p&gt;

&lt;p&gt;Some problems showed up immediately: a query was returning data immediately in SQLite, and in MySQL or Postgres it just hangs. So in those cases, I&amp;nbsp;often had to add some missing index, or modify the index somehow (e.g. add an additional field, or switch a field in a composite index to &lt;code&gt;DESC&lt;/code&gt;), or rearrange a query to make it use the intended index, add some limiting condition, or occasionally (in MySQL) add &lt;code&gt;FORCE INDEX&lt;/code&gt;. Those issues were generally fairly easy to fix, and the fix either clearly worked or not. I&amp;nbsp;think some queries I&amp;nbsp;had were just logically not fully thought through, but they had been working fine before because SQLite has some things organized differently on disk and some access patterns work better, hiding the issue with the query.&lt;/p&gt;

&lt;p&gt;The bigger problem and one I’ve spent a ton of time on (in the Postgres version) was one specific query in a set of “replies feeds”. I&amp;nbsp;mostly wrote everything about in on my Journal blog on micro.blog back in January, and I&amp;nbsp;remembered much more about it then than I&amp;nbsp;do now, so I’ll just link to &lt;a href="https://journal.mackuba.eu/2025/01/16/postgress-progress/"&gt;that old blog post here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The TLDR is that I&amp;nbsp;have a set of three feeds: &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/follows-replies"&gt;Follows &amp;amp; Replies&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/replies"&gt;Only Replies&lt;/a&gt; and &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/only-posts"&gt;Only Posts&lt;/a&gt;, which share the same code, just with slightly different filters; these are personalized feeds (i.e. having different content depending on who&amp;rsquo;s loading them), which for a given user fetch the list of the accounts that user is following, and then make a query asking the database for &amp;ldquo;most recent N posts from any of the users that this account follows&amp;rdquo; – so basically a reimplementation of the standard &amp;ldquo;Following&amp;rdquo; feed, with some changes.&lt;/p&gt;

&lt;p&gt;This query was much slower on the Postgres server than on the other two databases, and Postgres insisted on using the posts index on &lt;code&gt;(time)&lt;/code&gt; (scanning possibly millions of rows to find the right ones) instead of using the one on &lt;code&gt;(user, time)&lt;/code&gt; some number of times and merging the results (and apparently asking &amp;ldquo;how to do FORCE INDEX in Postgres&amp;rdquo; is a terrible heresy 😛). I&amp;nbsp;spent a lot of time on this and it took me a lot of trial and error, asking more experienced people on Bluesky, reading docs and articles, chatting with ChatGPT, and so on. I&amp;nbsp;went through: bumping up the &lt;code&gt;STATISTICS&lt;/code&gt; target and/or hardcoding &lt;code&gt;n_distinct&lt;/code&gt; (and rolling those back), tweaking some configuration variables, rearranging the query/index in various ways – and what I&amp;nbsp;finally settled on was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;changing the &lt;code&gt;(user, time)&lt;/code&gt; index to also include the &lt;code&gt;id&lt;/code&gt; primary key as the third field, i.e. &lt;code&gt;(user, time DESC, id)&lt;/code&gt;, to let it do &amp;ldquo;Index Only Scans&amp;rdquo; on it – I&amp;nbsp;haven&amp;rsquo;t realized that indexes in Postgres don&amp;rsquo;t reference the primary key, so they can&amp;rsquo;t be used this way unless the &lt;code&gt;id&lt;/code&gt; is explicitly included there!&lt;/li&gt;
&lt;li&gt;and setting up a cron job to do very frequent manual VACUUM (4× a day, with forced &lt;code&gt;index_cleanup&lt;/code&gt;), because otherwise it has to re-check some of the ids fetched from the index to verify if the rows haven&amp;rsquo;t been deleted&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;After those changes, it finally started working really nicely on Postgres, with the mean response time from this query going below 20 ms, while the MySQL version was doing around 50 ms, and the initial Postgres version before the index changes had slowed down to as much as 200-300 ms mean time. (It still occasionally picks the wrong index, for accounts that are somewhere in the middle follows range, over 1000 followed accounts – for those with many thousands, the &lt;code&gt;(time)&lt;/code&gt; index is almost always better – but I&amp;nbsp;think that&amp;rsquo;s somewhat unavoidable.)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2&gt;Epilogue: There can be only one&lt;/h2&gt;

&lt;p&gt;In the end, after all the tweaks and optimizations, both servers on both databases were working quite fine, and I&amp;nbsp;think I&amp;nbsp;would probably be ok with either of them. But I&amp;nbsp;had to pick one.&lt;/p&gt;

&lt;p&gt;I&amp;nbsp;ended up picking…&lt;/p&gt;

&lt;p&gt;…&lt;/p&gt;

&lt;p&gt;…&lt;/p&gt;

&lt;p&gt;Postgres! 🏆&lt;/p&gt;

&lt;p&gt;In those few months, I&amp;nbsp;managed to read sooo many pages of the documentation, articles about various specific settings, spent so much time in the &lt;code&gt;psql&lt;/code&gt; console, reading output from the analyzer, that I&amp;nbsp;got much more comfortable with it than I&amp;nbsp;was at the beginning… So ironically, the fact that I&amp;nbsp;had to spend more time tweaking it to get it to work all smoothly made me prefer it in the end. I&amp;nbsp;felt like specifically because there were so many different dials and switches, I&amp;nbsp;felt more &amp;ldquo;in control&amp;rdquo; with Postgres than with MySQL – the tutorials for tuning Postgres mentioned 5-10 different settings at least, and the ones for MySQL basically said “ah, just set &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; to half the RAM and you’re done”. And if you’ve already set that and you’d like to optimize things further? Well… ¯\_(ツ)_/¯&lt;/p&gt;

&lt;p&gt;Postgres&amp;rsquo;s query analyzer output is also more readable and helps you more with figuring out how it&amp;rsquo;s actually handling the query, and generally various debugging commands seem to provide more readable info about current parameters of the system – while MySQL mostly has a &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; command, which just pukes several pages of text output at you.&lt;/p&gt;

&lt;p&gt;I’ve been doing various manual benchmarks of how different parts of the system work, what the average response times and query times are and so on, on both versions, and keeping the results in tables, but I&amp;nbsp;can’t really get any general conclusion from this on which database is better at what. It was often things like: one does single row deletes faster and the other does multi-row deletes faster, or one is faster at inserts and the other at counts… but generally it was changing a bit too much over time, and I&amp;nbsp;wasn’t doing it in a super scientifically controlled way.&lt;/p&gt;

&lt;p&gt;One thing that I&amp;nbsp;could see on &lt;a href="https://munin-monitoring.org"&gt;Munin charts&lt;/a&gt; in the end was that the Postgres server had higher numbers on the disk &lt;strong&gt;read&lt;/strong&gt; IO/throughput charts, while the MySQL server had noticeably higher numbers on the disk &lt;strong&gt;write&lt;/strong&gt; IO/throughput charts. Not sure if this is a good assumption, but my guess was that with higher traffic in the future, it would generally be easier to scale the read load in Postgres (with various caches, replicas etc.) than to scale the write load in MySQL. Also, in the end after all the optimizations, the key query in the “replies” feeds was working noticeably better in the Postgres version, and in the test “how quickly it can possibly process events when catching up at max speed” (where post record inserts are generally the bottleneck), the Postgres version also ended up with a slightly higher processing speed.&lt;/p&gt;

&lt;p&gt;So since March, the app has been running on a new 512 GB VPS on a Postgres database, and it&amp;rsquo;s been working fine since then.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2025/08/20/introduction-to-atproto/</id>
    <title>Introduction to AT Protocol</title>
    <published>2025-08-20T17:32:45Z</published>
    <updated>2025-08-20T17:32:45Z</updated>
    <link href="https://mackuba.eu/2025/08/20/introduction-to-atproto/"/>
    <content type="html">

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


&lt;p&gt;Some time ago I&amp;nbsp;wrote a long blog post I&amp;nbsp;called “&lt;a href="/2024/02/21/bluesky-guide/"&gt;Complete guide to Bluesky&lt;/a&gt;”, which explains how all the user-facing features of Bluesky work and various tips and tricks. This one is meant to be a bit like a developer version of that – I&amp;nbsp;want to explain in hopefully understandable language what all the pieces of the network architecture are and how they all fit together. I&amp;nbsp;hope this will let you understand better how Bluesky and the underlying protocol works, and how it differs from e.g. the Fediverse. This should also be a good starting point if you want to start building some apps or tools on ATProto.&lt;/p&gt;

&lt;p&gt;This post is a first part of a series – next I&amp;nbsp;want to look at some comparisons with the Fediverse and some common misconceptions that people have, and look at the state of decentralization of this network, but that was way too much for one post; so this one focuses on the “ATProto intro tutorial” part.&lt;/p&gt;
&lt;div class="toc"&gt;&lt;ol&gt;
&lt;li&gt;&lt;a href="#what-is-bluesky"&gt;What is “Bluesky”? Which “Bluesky” are we talking about?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#records"&gt;Records &amp;amp; blobs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#lexicons"&gt;Lexicons&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#identity"&gt;Identity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#at-uris"&gt;AT URIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#repos"&gt;User repositories&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#xrpc"&gt;XRPC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#facets"&gt;Rich text / facets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#pds"&gt;PDS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#relay"&gt;Relay&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#appview"&gt;AppView (app server)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#labellers"&gt;Labellers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#feeds"&gt;Feed generators&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#clients"&gt;Client apps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#dms"&gt;DMs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#flow"&gt;How it all fits together&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#atmosphere-apps"&gt;Non-Bluesky Atmosphere apps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#next"&gt;Where to go next&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;


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

&lt;h2&gt;What is “Bluesky”? Which “Bluesky” are we talking about?&lt;/h2&gt;

&lt;p&gt;Before we start, a little philosophical aside. Discussions about Bluesky sometimes get a little confusing because… “Bluesky” could mean a few different things – language is hard.&lt;/p&gt;

&lt;p&gt;(I&amp;rsquo;m not sure if this section clears things up or just the opposite, so feel free to skip it.)&lt;/p&gt;

&lt;p&gt;First, we have Bluesky the company, the organization. Usually, when people want to clarify that they’re talking about the group of people or the company, they say “&lt;strong&gt;Bluesky PBC&lt;/strong&gt;” (PBC = Public Benefit Corporation), or “Bluesky team”. (If you want to read a bit about where Bluesky came from and what’s the current state of the company, read &lt;a href="/2024/02/21/bluesky-guide/#what-is-bluesky"&gt;these two sections&lt;/a&gt; in the Bluesky Guide blog post.)&lt;/p&gt;

&lt;p&gt;But Bluesky is also the thing they built, the social network, and that one is much more nebulous and hard to define – because unlike the centralized social networks like Twitter or Facebook, Bluesky has built theirs on a distributed protocol they&amp;rsquo;ve called the &lt;strong&gt;Authenticated Transfer Protocol&lt;/strong&gt;, or AT Protocol (or even shorter, ATProto). More and more parts of what people generally experience as &amp;ldquo;Bluesky&amp;rdquo; are actually built, run and controlled by people from outside, independently of the company.&lt;/p&gt;

&lt;p&gt;To complicate things further, Bluesky even in this wider meaning is not all that the AT Protocol is designed to achieve, far from it. The protocol is meant to be a system for building many different social networking apps (and other kinds of apps), which share the underlying mechanism of storing and sharing data, the way accounts and servers are organized and so on, but could be dealing with very different kinds of data and content for very different use cases. The commonly accepted term for the whole shared “multiverse” of all such ATProto apps (like &amp;ldquo;The Fediverse&amp;rdquo; for the ActivityPub network) is “&lt;strong&gt;The&amp;nbsp;Atmosphere&lt;/strong&gt;”, or “ATmosphere” with a capital T (the name was &lt;a href="https://bsky.app/profile/did:plc:bnqkww7bjxaacajzvu5gswdf/post/3k26nw6kwnh2e"&gt;coined&lt;/a&gt; by someone from the community, but was accepted by the team and is now mentioned on the &lt;a href="https://atproto.com/guides/glossary#atmosphere"&gt;official atproto site&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;How do we define which parts of the Atmosphere or ATProto are &amp;ldquo;Bluesky&amp;rdquo;, and which aren&amp;rsquo;t?&lt;/p&gt;

&lt;p&gt;The boundary is generally drawn by the names of the “&lt;a href="#lexicons"&gt;lexicons&lt;/a&gt;”, the identifiers of the data types and API&amp;nbsp;endpoints. I&amp;nbsp;talk about it in a later section, but these identifiers used for record types and endpoints follow the &amp;ldquo;reverse domain name&amp;rdquo; format, so naturally different people/teams building on the protocol use identifiers under different domains that they control. And so: &lt;code&gt;com.atproto&lt;/code&gt; signifies generic, base parts of the protocol meant to be used by everyone; &lt;code&gt;app.bsky&lt;/code&gt; are the parts related to Bluesky apps and everything compatible with them; and people building completely separate apps will use other, different prefixes for their identifiers. So &amp;ldquo;Bluesky&amp;rdquo; would be primarily everything identified with an &lt;code&gt;app.bsky.*&lt;/code&gt; lexicon – the data types like Bluesky posts and follows, the APIs for handling them and for accessing other Bluesky-specific features, the rules according to which they all work together, and the whole “social layer” that is created out of all of this, the virtual “place” – the thing that people have in mind when they say “this website”, even when it’s accessed through a mobile app. Other apps use the same protocol primitives, but have their separate data types, APIs, different rules and UIs.&lt;/p&gt;

&lt;p&gt;And how do we call these different &amp;ldquo;neighborhoods&amp;rdquo; in the Atmosphere, these sets of “data types + rules + custom servers + client apps” that define different use cases of the network? Well… that&amp;rsquo;s a good question that I&amp;nbsp;don&amp;rsquo;t have a good answer for. Bluesky team generally calls them just &amp;ldquo;apps&amp;rdquo; and that tends to be the name used in most cases, but especially in case of &lt;code&gt;app.bsky&lt;/code&gt;, I&amp;nbsp;find this term more confusing than helpful. This is because &amp;ldquo;app&amp;rdquo; kind of implies a client app, or maybe a webapp, so just the front part of the whole thing. There are multiple mobile apps and independent webapps made by other developers from the community, which are still built for making and displaying Bluesky posts and threads, so I&amp;nbsp;consider them also a part of the Bluesky… app? See, that just doesn&amp;rsquo;t work here. I&amp;nbsp;sometimes use the term &amp;ldquo;service&amp;rdquo;, though that in turn kind of implies a specific backend. Daniel from the Bluesky team has recently been calling it a &amp;ldquo;&lt;a href="https://dholms.leaflet.pub/3mfrsbcn2gk2a/l-quote/13_82-13_173#13_82"&gt;social modality&lt;/a&gt;&amp;rdquo; in his blog posts about adding private data to the protocol, but that&amp;rsquo;s probably too technical. So I&amp;nbsp;guess we&amp;rsquo;re stuck with some combination of the above.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;Now, let’s start with defining the various building pieces of the protocol:&lt;/p&gt;

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

&lt;h2&gt;Records &amp;amp; blobs&lt;/h2&gt;

&lt;p&gt;The most basic piece of the ATProto world is a &lt;strong&gt;record&lt;/strong&gt;. Records are basically JSON objects representing the data about a specific entity like a post or profile, organized in a specific way. A post/reply, repost, like, follow, block, list, entry on a list, user profile info – each of these is one record. Most public actions you take on Bluesky, like following someone or liking a post, are performed by creating a record of an appropriate type (or editing/deleting one created before).&lt;/p&gt;

&lt;p&gt;For example, this is a &lt;a href="https://shiitake.us-east.host.bsky.network/xrpc/com.atproto.repo.getRecord?repo=did:plc:vc7f4oafdgxsihk4cry2xpze&amp;amp;collection=app.bsky.feed.post&amp;amp;rkey=3ltxjiss3is2j"&gt;post record&lt;/a&gt;. This is one of the &lt;a href="https://lionsmane.us-east.host.bsky.network/xrpc/com.atproto.repo.getRecord?repo=did:plc:sflxm2fxohaqpfgahgdlm7rl&amp;amp;collection=app.bsky.feed.like&amp;amp;rkey=3luapblv3vd2i"&gt;likes of that post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Records are stored on disk and transferred between servers in a binary format called &lt;a href="https://cbor.io"&gt;CBOR&lt;/a&gt;, although in most API&amp;nbsp;endpoints they’re returned in a JSON form (they are equivalent, just different encodings of the same data).&lt;/p&gt;

&lt;p&gt;The key thing about records, which has very real consequences for user-facing features, is that you can only create and modify &lt;em&gt;your own&lt;/em&gt; records, not those owned by others (and there are no “shared” records at the moment, each record is owned by a specific account). This means that e.g. when you follow someone, you create a follow record on your account, and that other person can’t delete your record, which is why there’s currently no “soft-blocking” feature, i.e. you can’t make someone stop following you (though you can block them). There are workarounds though, as I’ll explain later in the &lt;a href="#appview"&gt;AppView section&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This also means that there’s often an unexpected asymmetry between seemingly similar actions: for example, getting a list of people followed by person X is very simple (they’re all X’s records, so they’re all in one place), but getting a list of all followers of X is much harder (each record is in a different place!). This is something that the AppView helps with too, as we’ll see later.&lt;/p&gt;

&lt;p&gt;A second, complimentary way of storing user data is &lt;strong&gt;blobs&lt;/strong&gt;. Blobs are basically binary files, meant mostly for storing media like images and video. For example, here is a direct link to an &lt;a href="https://lab.martianbase.net/xrpc/com.atproto.sync.getBlob?did=did:plc:oio4hkxaop4ao4wz2pp3f4cr&amp;amp;cid=bafkreib7vmhsk7w36bmrlwi2mjgkkoq44xysdahi226re2a76rlmgamgvu"&gt;image blob&lt;/a&gt; showing a photo of when I&amp;nbsp;started writing this blog post. Blobs are stored on the same server as records, but somewhat separate from them, since it’s a different type of data.&lt;/p&gt;

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

&lt;h2&gt;Lexicons&lt;/h2&gt;

&lt;p&gt;Each record belongs to a specific “record type” and stores its data organized in a specific structure, which defines what kinds of fields it can have with what types, what they mean, which are required, and so on – kind of like XML/JSON Schema. This schema definition which describes a given record type is called a &lt;strong&gt;lexicon&lt;/strong&gt; in ATProto. (If you’re curious why make a new standard, see threads e.g. &lt;a href="https://blue.mackuba.eu/skythread/?author=did:plc:ragtjsm2j2vknwkz3zp4oxrd&amp;amp;post=3juoxe37rez2q"&gt;here&lt;/a&gt;, &lt;a href="https://blue.mackuba.eu/skythread/?author=did:plc:ragtjsm2j2vknwkz3zp4oxrd&amp;amp;post=3kjgebkayik2g"&gt;here&lt;/a&gt;, or &lt;a href="https://blue.mackuba.eu/skythread/?author=did:plc:ragtjsm2j2vknwkz3zp4oxrd&amp;amp;post=3jvf7bakmm22h"&gt;here&lt;/a&gt;, or &lt;a href="https://www.pfrazee.com/blog/why-not-rdf"&gt;this blog post&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A lexicon needs to have an identifier (called &lt;strong&gt;NSID&lt;/strong&gt;, Namespace Identifier), which uses the reverse domain name format, e.g. &lt;code&gt;app.bsky.feed.post&lt;/code&gt;. All lexicons that are used to store the data of a specific app are usually grouped under the same prefix, e.g. Bluesky lexicons all start with &lt;code&gt;app.bsky&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The structure of a given lexicon’s records is defined in a special JSON file – for example, this file defines the &lt;a href="https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/post.json"&gt;app.bsky.feed.post lexicon&lt;/a&gt;. As you can see, this is the place which for example specifies that a post’s text can have at most 300 characters (more specifically, Unicode graphemes). This also means that you can’t create a different server which would make posts longer than 300 characters that would be Bluesky-compatible and displayed on &lt;a href="https://bsky.app"&gt;bsky.app&lt;/a&gt; – such posts would not pass the validation against the post record schema, and would be rejected by any server or client which performs such validation. Essentially, whover designs and controls the given lexicon, decides what kinds of data it can hold and any constraints on it. In order to store a different, incompatible type of data, you need to create a new lexicon (although you &lt;em&gt;can&lt;/em&gt; add additional fields to a record that aren’t defined in its lexicon; many third party apps are doing that, like e.g. &lt;a href="https://github.com/snarfed/bridgy-fed/issues/1092#issuecomment-2164027121"&gt;Bridgy Fed&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Lexicon name prefixes generally define boundaries between “apps” as in “services”, and between the “territory” that’s owned by different parties. The lexicons and endpoints defined by Bluesky are defined either under &lt;code&gt;app.bsky.*&lt;/code&gt; – these are things specific to Bluesky the microblogging service – or under &lt;code&gt;com.atproto.*&lt;/code&gt;, which are things meant to be used by all ATProto apps and services regardless of the use case. There are also a couple of other minor namespaces like &lt;code&gt;chat.bsky.*&lt;/code&gt; for the (centralized) DM service, and &lt;code&gt;tools.ozone.*&lt;/code&gt; for the open source &lt;a href="https://github.com/bluesky-social/ozone/"&gt;Ozone moderation tool&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The lexicon prefix is generally (in most cases) a good way to tell if a piece of the protocol is something Bluesky-specific (specific to the Bluesky service), or something general for all ATProto. There are no record types defined in &lt;code&gt;com.atproto&lt;/code&gt;, so things like post, profile, follow are all Bluesky-specific and under &lt;code&gt;app.bsky&lt;/code&gt;, as are APIs for e.g. searching users, getting timelines, custom feeds and so on. Meanwhile, &lt;code&gt;com.atproto&lt;/code&gt; APIs deal more with things like: info about a repository, fetching a repository, signing up for a new account, refreshing an access token, downloading a blob, etc.&lt;/p&gt;

&lt;p&gt;Third party developers and teams building apps on ATProto/Bluesky, which either extend Bluesky’s features or make something completely separate, use their own namespaces for new lexicons, like &lt;code&gt;blue.flashes&lt;/code&gt;, &lt;code&gt;dev.npmx&lt;/code&gt;, &lt;code&gt;events.smokesignal&lt;/code&gt;, &lt;code&gt;pub.leaflet&lt;/code&gt;, &lt;code&gt;sh.tangled&lt;/code&gt;, and so on. (There is a lot of nuance to whether you should use your own lexicons or reuse or extend existing ones when building things, and there have been a lot of discussions about it on Bluesky, and even &lt;a href="https://www.youtube.com/watch?v=O8GTcyPNPXI"&gt;conference talks&lt;/a&gt;. A good starting point is &lt;a href="https://www.pfrazee.com/blog/lexicon-guidance"&gt;this blog post&lt;/a&gt; by Paul Frazee.)&lt;/p&gt;

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

&lt;h2&gt;Identity&lt;/h2&gt;

&lt;p&gt;Each user is uniquely identified in the network with their &lt;strong&gt;Decentralized Identifier (DID)&lt;/strong&gt;. DIDs are a &lt;a href="https://www.w3.org/TR/did-1.0/"&gt;W3C standard&lt;/a&gt;, but (as I&amp;nbsp;understand) this standard mostly just defines a framework, and there can be many different “methods” of storing and resolving the identifiers, and each system that uses it can pick or create different types of those DIDs.&lt;/p&gt;

&lt;p&gt;The format of a DID is: &lt;code&gt;did:&amp;lt;type&amp;gt;:&amp;lt;…&amp;gt;&lt;/code&gt;, where the last part depends on the method. ATProto supports two types of DIDs, but in practice, almost everyone uses one of them, the “plc”. Each DID has a “&lt;strong&gt;DID document&lt;/strong&gt;”, a JSON file (&lt;a href="https://plc.directory/did:plc:oio4hkxaop4ao4wz2pp3f4cr"&gt;see mine&lt;/a&gt;) which describes the account – in ATProto at least, the document includes things such as: the assigned handles, the PDS server hosting the account, and some cryptographic keys.&lt;/p&gt;

&lt;p&gt;An important thing to note is that &lt;strong&gt;DIDs are permanent&lt;/strong&gt;; it’s the only thing that is permanent about your account, because something has to be. There needs to be some unique ID that all databases everywhere can use to identify you, which doesn’t change, and the DID is that ID. This means that you can’t change a DID of one type into another type later.&lt;/p&gt;

&lt;p&gt;The main DID method is &lt;code&gt;did:plc&lt;/code&gt;, where IIRC “plc” originally stood for “placeholder” (I&amp;nbsp;think it was meant to be temporary until something better is designed), and was later kind of retconned to mean “&lt;a href="https://github.com/did-method-plc/did-method-plc"&gt;Public Ledger of Credentials&lt;/a&gt;” 🙃 The DIDs of this type are identified by a random string of characters, which looks like this: &lt;code&gt;did:plc:vc7f4oafdgxsihk4cry2xpze&lt;/code&gt;. The DID documents of each DID are stored in a centralized service hosted at &lt;a href="https://plc.directory"&gt;plc.directory&lt;/a&gt;, which basically keeps a key-value store mapping a DID to a JSON file. It also keeps an “&lt;a href="https://plc.directory/did:plc:oio4hkxaop4ao4wz2pp3f4cr/log/audit"&gt;audit log&lt;/a&gt;” of the previous versions of the document (this means that, for example, the whole history of your old handles is available and you can’t erase it!). There’s also some cryptographic stuff there which, as I&amp;nbsp;understand it, lets anyone verify that everything in the database checks out (don’t ask me how).&lt;/p&gt;

&lt;p&gt;The PLC directory is probably the single most centralized and not easily replaceable element of the protocol, which has drawn some fair criticism, but the Bluesky team has some ideas for how to make it less of a problem, which they&amp;rsquo;ve been working on for some time now. In September 2025, they announced they were working on &lt;a href="https://docs.bsky.app/blog/plc-directory-org"&gt;transferring the control of the directory&lt;/a&gt; to an independent non-profit organization located in Switzerland that would not be controlled by them; in March 2026 at the &lt;a href="https://atmosphereconf.org"&gt;AtmosphereConf&lt;/a&gt; they announced the PLC organization was successfully founded and had selected initial board members. They&amp;rsquo;ve also shared some tooling for &lt;a href="https://atproto.com/blog/plc-replicas"&gt;making independent replicas&lt;/a&gt; of the directory.&lt;/p&gt;

&lt;p&gt;The other, rarely used DID method is &lt;code&gt;did:web&lt;/code&gt;. Those DIDs look like this: &lt;code&gt;did:web:witchcraft.systems&lt;/code&gt;, and the DID document is stored in a specific &lt;code&gt;.well-known&lt;/code&gt; path on the given hostname, in this case &lt;a href="https://witchcraft.systems/.well-known/did.json"&gt;witchcraft.systems&lt;/a&gt; (yes, that’s an actual TLD&amp;nbsp;;). It does not store an audit log/history like &lt;code&gt;plc&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;The reason why it’s rarely used and not recommended, is because, first, it&amp;rsquo;s more complicated to create one (though that’s a solvable problem of course, see e.g. &lt;a href="https://whtwnd.com/bnewbold.net/3mdc7fpbxhk26"&gt;this guide&lt;/a&gt;); but second and more importantly, since DIDs are permanent, this means that your account is permanently bound to that domain. You need to keep it accessible and not let it expire, or you lose the account – you can’t migrate it to &lt;code&gt;did:web:another.site&lt;/code&gt; at some point later. It gives you more independence, but at the cost of being tied to that domain you have, and this isn’t a tradeoff that most people are likely to want, and definitely not people who don’t understand what they’re getting into.&lt;/p&gt;

&lt;p&gt;If you’re fine with that choice, you can create a &lt;code&gt;did:web&lt;/code&gt; account and almost everything in Bluesky and ATProto should work exactly the same. &amp;ldquo;Almost&amp;rdquo;, because some services forget to implement that second code path, since it’s so rarely used 😉 but in that case, politely nudging the developer to fix the issue should help in most cases&amp;nbsp;:&gt;&lt;/p&gt;

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

&lt;h4&gt;Handles&lt;/h4&gt;

&lt;p&gt;What DIDs enable is that since they act as the unique identifier, your handle doesn’t have to, like it does on Mastodon. I&amp;nbsp;can be &lt;code&gt;@mackuba.bsky.social&lt;/code&gt; one day, &lt;code&gt;@mackuba.eu&lt;/code&gt; the next day, and &lt;code&gt;@mackuba.martianbase.net&lt;/code&gt; the week after. All existing connections – follows &amp;amp; followers, my posts, likes, blocks, lists I’m on, mentions in posts, etc. all work as before, because they all reference the DID, not the handle. With mentions specifically it works kinda funny, because they use what’s called a “facets” system (see &lt;a href="#facets"&gt;later section&lt;/a&gt;), where the link target is specified separately from the displayed text. So you can have an old post saying “hey @mackuba.bsky.social”, where the handle in it links to my profile which is now named &amp;ldquo;@mackuba.eu&amp;rdquo;. The link still works, because it really links to the DID behind the scenes.&lt;/p&gt;

&lt;p&gt;Unlike on Mastodon, the format of handles is just a hostname, not username + hostname. You assign a whole hostname to a specific account, and if you own any domain name, that can be your username (and if you own a well known domain name, it’s strongly recommended that you do, as a form of self-verification!).&lt;/p&gt;

&lt;p&gt;The handle to DID assignment is a two-way link – a DID needs to claim a given handle, and the owner of the domain needs to verify that they own that DID. On the DID side, this happens in the &lt;code&gt;alsoKnownAs&lt;/code&gt; field of the DID document (&lt;a href="https://plc.directory/did:plc:oio4hkxaop4ao4wz2pp3f4cr"&gt;see here in mine&lt;/a&gt;). On the domain side, there are two ways of verifying a handle, depending on what’s more convenient to you: either a DNS TXT entry, or a file on a &lt;code&gt;.well-known&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;You might be wondering how handles like &lt;code&gt;*.bsky.social&lt;/code&gt; work – in this case, each such handle is its own domain name, and you can actually enter a domain like &lt;a href="https://aoc.bsky.social"&gt;aoc.bsky.social&lt;/a&gt; into a browser and it will redirect to a Bluesky profile on &lt;a href="https://bsky.app"&gt;bsky.app&lt;/a&gt;. Behind the scenes, this is normally handled by having a wildcard domain pointing to one service, which responds to HTTP requests on that &lt;code&gt;.well-known&lt;/code&gt; path by returning different DIDs, depending on the domain. That’s not only a &lt;code&gt;bsky.social&lt;/code&gt; thing – e.g. there’s now an open Blacksky PDS server which hands out &lt;code&gt;blacksky.social&lt;/code&gt; handles, and there are even “handle services” which &lt;em&gt;only&lt;/em&gt; give out handles – e.g. you can be &lt;a href="https://swifties.social"&gt;yourname.swifties.social&lt;/a&gt; if you want&amp;nbsp;;)&lt;/p&gt;

&lt;p&gt;One place where handle changes break things is (some) post URLs on &lt;a href="https://bsky.app"&gt;bsky.app&lt;/a&gt;. The official web client uses handles by default in permalinks, which means that if you link to a Bluesky post e.g. from a blog post and you change your handle later, that link will no longer work. You can however replace the handle after &lt;code&gt;/profile/&lt;/code&gt; with the user’s DID, and the router accepts such links just fine, they just aren’t used by default. So the form you’d want to use when putting links in a blog post or article (like the one you&amp;rsquo;re reading) would be something like: &lt;a href="https://bsky.app/profile/did:plc:ragtjsm2j2vknwkz3zp4oxrd/post/3llwrsdcdvc2s"&gt;https://bsky.app/profile/did:plc:ragtjsm2j2vknwkz3zp4oxrd/post/3llwrsdcdvc2s&lt;/a&gt;.&lt;/p&gt;

&lt;p id="at-uris"&gt;&lt;/p&gt;

&lt;h2&gt;AT URIs&lt;/h2&gt;

&lt;p&gt;Each record can be uniquely addressed with a specific &lt;strong&gt;URI&lt;/strong&gt; with the at:// scheme. The format of the URI&amp;nbsp;is:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;at://&amp;lt;user_DID&amp;gt;/&amp;lt;lexicon_NSID&amp;gt;/&amp;lt;rkey&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Rkey&lt;/strong&gt; (record key) is an identifier of a specific record instance – a usually short alphanumeric string, e.g. Bluesky post rkeys look something like &lt;code&gt;3larljiybf22v&lt;/code&gt;. So a complete post URI&amp;nbsp;might look like this: &lt;code&gt;at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3larljiybf22v&lt;/code&gt;. You can look up at:// URIs in some record browser tools, e.g. &lt;a href="https://pdsls.dev/at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3larljiybf22v"&gt;PDSls&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;AT URIs are used for all references between records – quotes, replies, likes, mute list entries, and so on. If you look at &lt;a href="https://morel.us-east.host.bsky.network/xrpc/com.atproto.repo.getRecord?repo=did:plc:l3rouwludahu3ui3bt66mfvj&amp;amp;collection=app.bsky.feed.like&amp;amp;rkey=3lwctqgpttm2a"&gt;this like record&lt;/a&gt;, for example, its &lt;code&gt;subject.uri&lt;/code&gt; points to &lt;code&gt;at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv2b3f5nys2n&lt;/code&gt;, which is the URI&amp;nbsp;of a post record you can see &lt;a href="https://pdsls.dev/at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv2b3f5nys2n"&gt;here&lt;/a&gt;. Since the URIs use DIDs in the first part, handle changes don’t affect such links.&lt;/p&gt;

&lt;p&gt;There is a great guide to AT URIs and DIDs and how to handle them written by Dan Abramov, you may want to check it out &lt;a href="https://overreacted.io/where-its-at/"&gt;here&lt;/a&gt;.&lt;/p&gt;

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

&lt;h2&gt;User repositories&lt;/h2&gt;

&lt;p&gt;All user data (records and blobs) is stored in a &lt;strong&gt;repository&lt;/strong&gt; (or “repo”). The repository is identified by user’s DID, and stores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;records, grouped by lexicon into so-called &lt;strong&gt;collections&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;blobs (stored separately from records)&lt;/li&gt;
&lt;li&gt;authentication data like access tokens, signing keys, hashed passwords etc.&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Internally, an important part of how the repo stores user records is a data structure called “&lt;a href="https://en.wikipedia.org/wiki/Merkle_tree"&gt;Merkle Search Tree&lt;/a&gt;” – but this isn’t something that you need to understand when using the protocol, unless you’re working on a PDS/relay implementation (I&amp;nbsp;haven’t needed to get into it so far).&lt;/p&gt;

&lt;p&gt;You can download the records part of your (or anyone else’s!) repo as a bundle called a &lt;a href="https://bsky.app/profile/did:plc:fkasq7xtzrmlvz46c5trkrn3/post/3lkedsoq4vs2d"&gt;CAR file&lt;/a&gt;, a &lt;a href="https://ipld.io/specs/transport/car/"&gt;Content Addressed Archive&lt;/a&gt; (fun fact: the icon for the button in the Bluesky app which downloads a repo backup is the shape of a car 🚘).&lt;/p&gt;

&lt;p&gt;The cool part is that a repository stores all data of the given user, from *all* lexicons. Including third party developer lexicons. This means that if someone has their account hosted on Bluesky servers, but uses third party ATProto apps like Tangled or Grain, Bluesky lets them store these apps’ records like Grain photos or Tangled pull requests on the same server where it keeps their Bluesky posts. (And yes, of course someone made a &lt;a href="https://github.com/ziodotsh/atfile"&gt;lexicon/tool for storing arbitrary files&lt;/a&gt; on your Bluesky PDS… and did it in Bash, because why not 🙃)&lt;/p&gt;

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

&lt;h2&gt;XRPC&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;XRPC&lt;/strong&gt; is the convention used for APIs in the ATProto network. The API&amp;nbsp;endpoints use the same naming convention as lexicon NSIDs, and they have URLs with paths in the format of &lt;code&gt;/xrpc/&amp;lt;nsid&amp;gt;&lt;/code&gt;, e.g. &lt;code&gt;/xrpc/app.bsky.feed.getPosts&lt;/code&gt;. There are similar &lt;a href="https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/feed/getPostThread.json"&gt;lexicon definition files&lt;/a&gt; which specify what parameters are accepted/required by an endpoint and what types of data are returned in the JSON response. PDSes, AppViews, labellers and feed generators all implement the same kind of API, although with different subsets of specific endpoints. Third party apps don’t &lt;em&gt;have&lt;/em&gt; to use the same convention, but it’s generally a good idea, since it integrates better with the rest of the ecosystem.&lt;/p&gt;

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

&lt;h2&gt;Rich text / facets&lt;/h2&gt;

&lt;p&gt;This one is kinda Bluesky-specific, but it’s pretty important to understand, and I&amp;nbsp;think you can reuse it for non-Bluesky apps too.&lt;/p&gt;

&lt;p&gt;The “&lt;strong&gt;facets&lt;/strong&gt;” system is something used for links and possibly rich text in future in Bluesky posts. It’s perhaps a little bit unintuitive at first, but it’s pretty neat and allows for a lot of flexibility.&lt;/p&gt;

&lt;p&gt;The way you handle links, mentions, or hashtags, is that they aren’t highlighted automatically, but you need to specifically mark some range of text as a link using the facets. A facet is a marking of some range of the post text (from-to) with a specific kind of link. If you look e.g. at &lt;a href="https://pdsls.dev/at://did:plc:257wekqxg4hyapkq6k47igmp/app.bsky.feed.post/3lnkwu24v5k2j"&gt;this post here&lt;/a&gt;, you can see that it has a facet marking the byte range 60-67 of the post text as a hashtag “ahoy25”. If there was no facet there, it would just render as normal unlinked text &amp;ldquo;#ahoy25&amp;rdquo; in the post (when you see that, it’s an easy tell that a post was made using some custom tool that’s in early stages of development). It works the same way for mention links and normal URL links.&lt;/p&gt;

&lt;p&gt;(If you’re curious why they implemented it this way, check out &lt;a href="https://www.pfrazee.com/blog/why-facets"&gt;this blog post&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Facets are also used for URL shortening – if you just put a long URL in the text of a post made through the API, it will be neither shortened nor highlighted. You need to manually mark it with a facet, and manually shorten the displayed part to whatever length you want.&lt;/p&gt;

&lt;p&gt;Note that the displayed text in the marked fragment doesn’t have to match what the facet links to; this means that you can have links that just use some shorter text for the link instead of a part of the URL, in order to fit more text in one post (although in the official app, clicking such link triggers a warning popup first). E.g. some Hacker News bots commonly use this format, see &lt;a href="https://bsky.app/profile/did:plc:7dh44snmqoa4gyzv3652gm3j/post/3lmhylb375m2a"&gt;this post&lt;/a&gt;. The Bsky app doesn&amp;rsquo;t let you create such posts directly, but some other clients like &lt;a href="https://skeetdeck.pages.dev"&gt;Skeetdeck&lt;/a&gt; do.&lt;/p&gt;

&lt;p&gt;Likely the most tricky part is that the from-to index numbers you need to use for the ranges are counted on a UTF-8 representation of the text string, but they’re counted in bytes and not in unicode scalars, which is kinda annoying in languages like Ruby or Python which index strings by unicode scalars. This is somewhat of an unfortunate tech debt thing as I&amp;nbsp;understand, and it was made this way mostly because of JavaScript, which doesn’t operate on UTF-8 natively, only in the form of byte arrays; so this means you need to be extra careful with the indexes.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;Ok, now that we got through the basic pieces, let’s talk about servers:&lt;/p&gt;

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

&lt;h2&gt;PDS&lt;/h2&gt;

&lt;p&gt;The original copy of all user data is stored on a server called &lt;strong&gt;PDS&lt;/strong&gt;, Personal Data Server. This is the “source of truth”. A PDS stores one or more user accounts and repos, handles user authentication, and often serves as an “entry point” to the network when connecting from client apps. At least in the current implementation of Bluesky, most network requests from the client are sent to your PDS, and then some of them are handled directly by the PDS, and others are proxied e.g. to the AppView server.&lt;/p&gt;

&lt;p&gt;Each PDS has an XRPC API&amp;nbsp;with some number of endpoints for things like listing repositories, listing contents of each, looking up a specific record or blob, account authentication and management, and so on. It also has a websocket API&amp;nbsp;called a “&lt;strong&gt;firehose&lt;/strong&gt;” (the &lt;a href="https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/sync/subscribeRepos.json"&gt;subscribeRepos endpoint&lt;/a&gt;). The firehose streams all changes happening on a given PDS (from all repos) as a stream of “events”, where each event is an addition, edit, or deletion of a record in one of the repos, or some change related to an account, like handle change or deactivation.&lt;/p&gt;

&lt;p&gt;One of the most important features of ATProto is that &lt;strong&gt;an account is not permanently assigned to a PDS&lt;/strong&gt;. Unlike in ActivityPub, where if you sign up on &lt;code&gt;mastodon.social&lt;/code&gt;, your identifier is bound to that hostname forever and it can never change, because everything uses it as the unique ID, here the unique ID is the DID. The PDS host is assigned to a user in the DID document JSON (e.g. on &lt;a href="https://plc.directory"&gt;plc.directory&lt;/a&gt;), but you can migrate to a different PDS at any point, and at the moment there are even some fairly user-friendly tools available for doing that, like &lt;a href="https://pdsmoover.com"&gt;PDS MOOver&lt;/a&gt; or &lt;a href="https://move.eurosky.tech"&gt;EU-Haul&lt;/a&gt;. In theory, you should even be able to migrate to a different PDS if your old PDS is dead or goes rogue, if you have prepared in advance (&lt;a href="https://www.da.vidbuchanan.co.uk/blog/adversarial-pds-migration.html"&gt;this is a bit more technical&lt;/a&gt;). If everything goes well, nobody even notices that anything has changed (on &lt;a href="https://bsky.app"&gt;bsky.app&lt;/a&gt; you can’t even easily see what PDS someone is on, although there are external tools for that, like &lt;a href="https://internect.info"&gt;internect.info&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Initially, during the limited beta in 2023, Bluesky only had one PDS, &lt;code&gt;bsky.social&lt;/code&gt;. In November 2023, several additional PDSes were created (also under Bluesky PBC control), and existing users were quietly all spread to a random one of those. At that point, the network was already “technically federated”, operating in the target architecture, although with access restricted to only Bluesky-run servers. This restriction was lifted in February 2024 with the &lt;a href="https://docs.bsky.app/blog/self-host-federation"&gt;public federation launch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Since then, ATProto enthusiasts started setting setting up PDS servers for themselves, either creating alt/test accounts there, or moving their main accounts. As of April 2026, there around 2800 &lt;del&gt;third party&lt;/del&gt; independent PDS servers, although most of them are very small – usually hosting one person’s main and/or test accounts, and maybe those of a couple of their friends. I&amp;nbsp;have &lt;a href="https://blue.mackuba.eu/directory/pdses"&gt;a list of them&lt;/a&gt; on my website, and there’s also a more complete list &lt;a href="https://github.com/mary-ext/atproto-scraping"&gt;here&lt;/a&gt; (mine excludes inactive PDSes and empty accounts). There are around 70k active accounts on them in total at the moment (depending on how you count), and around 10k of them are posting at least once every week (25k including Bridgy). This is of course a tiny fraction of the total Bluesky userbase, but the fraction is &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/post/3mgq7pdndtc2l"&gt;steadily growing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;As you can see on my list, there&amp;rsquo;s one big PDS for &lt;a href="https://fed.brid.gy"&gt;Bridgy Fed&lt;/a&gt;, the Bluesky-Mastodon bridge service, hosting around 40k bridged accounts from the Fediverse, Threads, Nostr, Flipboard, or the web (blogs); then some number of medium-sized community PDSes that have sprung up over the course of 2025/26 (&lt;a href="https://eurosky.tech"&gt;Eurosky&lt;/a&gt;, &lt;a href="https://blackskyweb.xyz"&gt;Blacksky&lt;/a&gt;, &lt;code&gt;selfhosted.social&lt;/code&gt;), and &amp;ldquo;companion&amp;rdquo; PDSes run by various ATProto services, meant to make it easier to get started if you don&amp;rsquo;t have a Bluesky/ATProto account yet (&lt;a href="https://tangled.org"&gt;Tangled&amp;rsquo;s&lt;/a&gt; &lt;code&gt;tngl.sh&lt;/code&gt;, &lt;a href="https://sprk.so"&gt;Spark&amp;rsquo;s&lt;/a&gt; &lt;code&gt;pds.sprk.so&lt;/code&gt;, or &lt;a href="https://npmx.dev"&gt;npmx&amp;rsquo;s&lt;/a&gt; &lt;code&gt;npmx.social&lt;/code&gt;); and a very long tail of servers with single-digit number of accounts. Things are changing pretty quickly on this front, so this might look different again by the end of 2026.&lt;/p&gt;

&lt;p&gt;The vast majority of PDSes at the moment use the &lt;a href="https://github.com/bluesky-social/pds"&gt;reference implementation from Bluesky&lt;/a&gt; (written in TypeScript), but there are a few alternative implementations at various levels of maturity (&lt;a href="https://tangled.org/tranquil.farm/tranquil-pds"&gt;Tranquil&lt;/a&gt; written in Rust, &lt;a href="https://github.com/haileyok/cocoon"&gt;cocoon&lt;/a&gt; in Go, &lt;a href="https://tangled.org/futur.blue/pegasus/"&gt;Pegasus&lt;/a&gt; in OCaml, or &lt;a href="https://github.com/DavidBuchanan314/millipds"&gt;millipds&lt;/a&gt; in Python, and &lt;a href="https://github.com/threddyrex/atproto-links?tab=readme-ov-file#pds-implementations"&gt;many others&lt;/a&gt;), with at least a few of these being used in production with real accounts. The Bluesky&amp;rsquo;s version is very easy to set up and very cheap to run – it’s bundled in Docker, and there’s basically one script you need to run and answer a few questions.&lt;/p&gt;

&lt;p&gt;As for the Bluesky-hosted PDSes, there&amp;rsquo;s currently around 90 of them, and each of them hosts a few hundred thousands of accounts (!). And what’s more, they keep the record data in SQLite databases, one per account. And it works really well, go figure. The Bluesky PDSes are all given names of different kinds of mushrooms (like &lt;em&gt;amanita&lt;/em&gt;, &lt;em&gt;boletus&lt;/em&gt; or &lt;em&gt;shiitake&lt;/em&gt;), hence they are often called “mushroom servers”; you can see the full list e.g. &lt;a href="https://status.bsky.app"&gt;here&lt;/a&gt;. &lt;code&gt;bsky.social&lt;/code&gt; was left as a so-called “&lt;strong&gt;entryway server&lt;/strong&gt;”, which handles shared authentication for all Bluesky-hosted PDSes (it’s a private piece of Bluesky PBC infrastructure that’s not open source and not needed for independent PDS hosters). No accounts are actually hosted on a PDS &lt;code&gt;bsky.social&lt;/code&gt; anymore – don&amp;rsquo;t let the handles confuse you.&lt;/p&gt;

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

&lt;h2&gt;Relay&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;relay&lt;/strong&gt; is probably the piece of the ATProto architecture that’s most commonly misunderstood by people familiar with other networks like the Fediverse. It doesn’t help that both the Fediverse and Nostr also include servers called “relays”, but they serve a different purpose in each of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a relay in Nostr is a core piece of the architecture: your posts are uploaded to one or more relays that you have configured and are hosted there, where other users can fetch them from&lt;/li&gt;
&lt;li&gt;a relay in the Fediverse is an optional helper service that redistributes posts from some number of instances who have opted in to others, in order to make content more discoverable e.g. on hashtag feeds&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;In ATProto, a relay is a server which combines the firehose streams from all PDSes it knows about into one massive stream that includes every change happening anywhere on the network. Such full-network firehose is then used as the input for many other services, like AppViews, labellers, or feed generators. It serves as a convenient streaming API&amp;nbsp;to get e.g. all posts on the network to process them somehow, or all changes to accounts, or all content in general, from a single place.&lt;/p&gt;

&lt;p&gt;Initially, the relay also had to keep a complete archive of all the data on the network, from all repos, from the beginning of time. This requirement was later removed in the updates in late 2024, at least partially triggered by the drastic increase in traffic in November 2024, which overwhelmed Bluesky’s and third party servers for at least a few days. Currently, Bluesky’s and other relays are generally “non-archival”, meaning that they live-stream current events (+ a buffer of e.g. last 24 or 36 hours), but they don’t keep a full archive of all repos (this change has &lt;a href="https://whtwnd.com/bnewbold.net/3lo7a2a4qxg2l"&gt;massively lowered the resource requirements&lt;/a&gt; / cost of running a relay, making it much more accessible). There are no currently operating archival relays AFAIK, but one can always be set up – Bluesky has recently &lt;a href="https://atproto.com/blog/introducing-hubble-a-public-mirror-for-the-whole-atmosphere"&gt;given a grant&lt;/a&gt; to one member of the community to let them build one.&lt;/p&gt;

&lt;p&gt;Bluesky operates one main relay at &lt;a href="https://bsky.network"&gt;bsky.network&lt;/a&gt;, which is used as a data source for their AppView and most other services in the ATProto ecosystem at the moment. The relay code is &lt;a href="https://github.com/bluesky-social/indigo/"&gt;implemented in Go&lt;/a&gt;, and isn’t very hard to get up and running (especially the recent 1.1 update improved things quite a lot). Some people from the dev community have been running alternative relay services for some time, some even using custom implementations in &lt;a href="https://github.com/blacksky-algorithms/rsky/tree/main/rsky-relay"&gt;Rust&lt;/a&gt; or &lt;a href="https://tangled.org/zzstoatzz.io/zlay"&gt;Zig&lt;/a&gt; – I&amp;rsquo;m tracking a list of known public ones with some statistics on a website called &lt;a href="https://pulsar.feeds.blue"&gt;pulsar.feeds.blue&lt;/a&gt;. I’m also running &lt;a href="https://relay.feeds.blue"&gt;my own small relay&lt;/a&gt; myself, which is feeding content only from independent, non-Bluesky PDSes (so a very small part of the network traffic).&lt;/p&gt;

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

&lt;h4&gt;Jetstream&lt;/h4&gt;

&lt;p&gt;There is also a variant of a relay called &lt;a href="https://github.com/bluesky-social/jetstream"&gt;Jetstream&lt;/a&gt; – it&amp;rsquo;s a service that reads from a real CBOR relay and outputs a stream that&amp;rsquo;s JSON based, better organized, and much more lightweight (the full relay includes a lot of additional data that&amp;rsquo;s mostly used for cryptographic operations and other low-level stuff). For many simpler tools and services, it might make more sense to stream data from that one instead, if only to save bandwidth. Bluesky runs a couple of instances listed there in the readme, but you can also run your own, and I&amp;rsquo;m also tracking community instances on &lt;a href="https://pulsar.feeds.blue"&gt;Pulsar&lt;/a&gt;.&lt;/p&gt;

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

&lt;h2&gt;AppView (app server)&lt;/h2&gt;

&lt;p&gt;The terribly named &lt;strong&gt;AppView&lt;/strong&gt; is the second most important piece of the network after the PDS. (There&amp;rsquo;s been some talk about &lt;a href="https://pfrazee.leaflet.pub/3lyucxaykg22w"&gt;renaming it&lt;/a&gt;, but nothing really conclusive, and so far most people refer to it by that name.)&lt;/p&gt;

&lt;p&gt;The AppView is basically an API&amp;nbsp;server that serves processed data to client apps. It’s an equivalent of an API&amp;nbsp;backend (with the databases behind it) that you’d find on a classic social media site like Twitter. AppView streams all new data written on the network from the relay, and saves a copy of it locally in a processed, aggregated and optimized form. For example, an AppView backed by an SQL database could have a &lt;code&gt;posts&lt;/code&gt; table with a &lt;code&gt;text&lt;/code&gt; column, a &lt;code&gt;likes&lt;/code&gt; table storing all likes with a foreign key &lt;code&gt;post_id&lt;/code&gt;, probably also an integer &lt;code&gt;likes_count&lt;/code&gt; column in &lt;code&gt;posts&lt;/code&gt; for optimization, and so on.&lt;/p&gt;

&lt;p&gt;The AppView is designed to be able to easily give information such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the latest posts from this user&lt;/li&gt;
&lt;li&gt;all the replies in a given thread organized in a tree&lt;/li&gt;
&lt;li&gt;most recent posts on the network with the hashtag #rubylang or mentioning “iOS 26”&lt;/li&gt;
&lt;li&gt;how many likes/reposts has a given post received, and who made them&lt;/li&gt;
&lt;li&gt;how many follows/followers does a given user have, and who are they&lt;/li&gt;
&lt;li&gt;is user A allowed to view or reply to a post from user B&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;All this data originates from users’ PDSes and has its original copy stored there, but the “raw” record don’t always allow you to access all information easily. For example, to find out how many likes a post has, you need to know all &lt;code&gt;app.bsky.feed.like&lt;/code&gt; records referencing it from other users, and each of those like records is stored in the liking user’s repo on that user’s PDS. Same with followers, as I&amp;nbsp;mentioned earlier in the section on records, or with building threads (again, different replies in one thread are hosted in different repos), or for basically any kind of search. So having this kind of API&amp;nbsp;with processed data from the entire network is essential for client apps and various tools and services built around Bluesky by other people.&lt;/p&gt;

&lt;p&gt;AppView also applies some additional rules to the data, sometimes overriding what people post into their PDSes, since anyone can technically post anything into their PDS. For example, the AppView prevents you from looking at the profiles of people who have blocked you, at least when you&amp;rsquo;re logged in. It also hides them from your followers list, even if they have a &lt;code&gt;follow&lt;/code&gt; record referencing you, making it seem like they don&amp;rsquo;t; and if they try to make an &lt;code&gt;app.bsky.feed.post&lt;/code&gt; replying to you (they &lt;em&gt;can&lt;/em&gt; create such record on their PDS!), it excludes such reply from feeds and threads, as if it never happened. Same goes for &amp;ldquo;thread gates&amp;rdquo; which lock access to threads, and so on.&lt;/p&gt;

&lt;p&gt;The AppView is one of the few components which &lt;em&gt;aren’t&lt;/em&gt; completely open source. Initially, the AppView used Postgres as its data store; &lt;em&gt;that&lt;/em&gt; version is still in the public repository. In late 2023, Bluesky has migrated to a “v2” version, which uses the NoSQL database &lt;a href="https://www.scylladb.com"&gt;ScyllaDB&lt;/a&gt; instead, to be able to handle the massive read traffic from many millions of concurrent users. The upper layer with the “business logic” is kept in the &lt;a href="https://github.com/bluesky-social/atproto/tree/main/packages/bsky"&gt;public repository&lt;/a&gt;, while the so called “dataplane” layer that interacts directly with Scylla is not. The reason is mostly that it’s built for a specific hardware setup they have and wouldn’t be directly usable by others, while it would add some unnecesary work for the team to publish it. It’s still possible to run the AppView with the &lt;a href="https://github.com/bluesky-social/atproto/tree/main/packages/bsky/src/data-plane"&gt;old Postgres-based data layer&lt;/a&gt; (and I&amp;nbsp;think the team uses that internally for development), it just can’t handle as much traffic as the current live version.&lt;/p&gt;

&lt;p&gt;This is the piece that’s hardest to run yourself, and one that requires the most resources. That said, a private AppView should be possible to run right now for &lt;a href="https://whtwnd.com/futur.blue/3ls7sbvpsqc2w"&gt;under $200/month&lt;/a&gt; – the biggest requirement is the disk space, something on the order of 20 TB at the moment. The truly costly part is not collecting and storing all this data, but serving it to a huge number of users who would use it as a backend for the client app in daily use. An alternative full-network Bluesky AppView that is used by a few thousands of users would be fairly doable and not prohibitevely costly, but to be able to serve millions, you’ll need a lot of hardware and something more custom than the Postgres-based version.&lt;/p&gt;

&lt;p&gt;At the moment, there is one public independent full-network Bluesky AppView, run by the &lt;a href="https://api.blacksky.community"&gt;Blacksky community&lt;/a&gt; (launched in early 2026). It uses the Bluesky AppView implementation, with some custom indexing code, and Blacksky&amp;rsquo;s fork of the Bluesky webapp at &lt;a href="https://blacksky.community"&gt;blacksky.community&lt;/a&gt; is configured to use their AppView as the data source. Other teams are reportedly working on their own AppView instances too. There have also been some attempts at alternative implementations, e.g. &lt;a href="https://tangled.org/parakeet.at/parakeet/"&gt;Parakeet&lt;/a&gt; written in Rust, or &lt;a href="https://github.com/alnkesq/AppViewLite"&gt;AppViewLite&lt;/a&gt; built in C#, which goes to great lengths to minimize the resource use.&lt;/p&gt;

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

&lt;h4&gt;CDN&lt;/h4&gt;

&lt;p&gt;A part of the AppView (at least the Bluesky one) is also a CDN for serving images &amp;amp; videos. The API&amp;nbsp;responses from e.g. &lt;code&gt;getTimeline&lt;/code&gt; or &lt;code&gt;getPostThread&lt;/code&gt; generally include links to any media on the Bluesky CDN hostname, not directly on the PDS, even though you &lt;em&gt;can&lt;/em&gt; fetch every blob from the PDS, since that&amp;rsquo;s the &amp;ldquo;source of truth&amp;rdquo; (although IIRC the Bluesky PDS implementation doesn&amp;rsquo;t set the CORS headers there). It&amp;rsquo;s recommended to access any media this way in order to not use too much bandwidth from the PDS.&lt;/p&gt;

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

&lt;h2&gt;Labellers&lt;/h2&gt;

&lt;p&gt;(Or “labelers” officially, but I&amp;nbsp;like the British spelling more here, sue me ¯\_(ツ)_/¯)&lt;/p&gt;

&lt;p&gt;We’re now getting to more Bluesky specific things (i.e. specific for the Bluesky-service, although some parts of it are ATProto-general and mentioned on the &lt;a href="https://atproto.com/specs/label"&gt;atproto.com site&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;labeller&lt;/strong&gt; is a moderation service for Bluesky (or other ATProto app), which can be run by third parties. Labellers emit labels, which are assigned to an account or a record (like a post). Each labeller defines its own set of labels, depending on what it’s focusing on; then, users can “subscribe” to a labeller and choose how they want to handle the labels it assigns: you can hide the labelled posts/users, mark them with a warning badge, or ignore given label.&lt;/p&gt;

&lt;p&gt;Labellers were initially designed to just do community moderation of unwanted content, e.g. you can have a service focused on fighting racism, transphobia, or right-wing extremism, and that service helps protect its users from some kinds of bad actors; or you can have one marking e.g. posts with political content, users who follow 20k accounts, or who post way too many hashtags. In practice, many &lt;a href="https://blue.mackuba.eu/labellers/"&gt;existing labellers&lt;/a&gt; are meant for self-labelling instead, letting you assign e.g. a country flag or some fun things like a D&amp;amp;D character class to yourself.&lt;/p&gt;

&lt;p&gt;The way it works technically is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a labeller either runs a firehose client pulling posts from the relay, or relies on reports from users and/or its operating team (usually using the &lt;a href="https://github.com/bluesky-social/ozone/"&gt;Ozone tool&lt;/a&gt; for that)&lt;/li&gt;
&lt;li&gt;labels, which are lightweight objects (&lt;em&gt;not&lt;/em&gt; ATProto records) are emitted from labeller’s special firehose stream (the &lt;a href="https://github.com/bluesky-social/atproto/blob/main/lexicons/com/atproto/label/subscribeLabels.json"&gt;subscribeLabels endpoint&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;the AppView listens to the label firehoses of all labellers it knows about, in addition to the relay stream, and records all received labels in its database&lt;/li&gt;
&lt;li&gt;when a logged in user pulls data like threads or timelines from the AppView, it adds relevant label info to the responses depending on which labellers the user follows&lt;/li&gt;
&lt;li&gt;the specific list of labellers whose labels should be applied is passed explicitly in API&amp;nbsp;requests in the &lt;code&gt;atproto-accept-labelers&lt;/code&gt; header (there is a “soft” limit of 20 labellers you can pass at a time, which is why the official app won’t let you subscribe to more)&lt;/li&gt;
&lt;li&gt;in the official app, Bluesky’s official moderation service (which is “just” another labeller) is hardcoded as one of those 20 and you can’t turn it off; when connecting from your own app or tool, you’re free to ignore it if you want&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;(Read more about labellers &lt;a href="https://mackuba.eu/2024/02/21/bluesky-guide/#labellers"&gt;here&lt;/a&gt;.)&lt;/p&gt;

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

&lt;h2&gt;Feed generators&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mackuba.eu/2024/02/21/bluesky-guide/#feeds"&gt;Custom feeds&lt;/a&gt; are one of the coolest features of Bluesky. They let you create any kind of feed using any algorithm and let everyone on the platform use it (even as the default feed, if they want to).&lt;/p&gt;

&lt;p&gt;The way this system works is that you need to run a “&lt;strong&gt;feed generator&lt;/strong&gt;” service on your server. In that service, you expose an API&amp;nbsp;that the AppView can call, which returns a list of post at:// URIs selected by you however you want in response to a given request.&lt;/p&gt;

&lt;p&gt;A minimal feed service can be pretty simple – the API&amp;nbsp;is just three endpoints, two of which are static, and the third returns the post URIs. One &amp;ldquo;small&amp;rdquo; problem is that in order to return the post URIs, you need to have some info about posts stored up front, which in practice means that you almost always need to connect to a relay’s firehose stream and store some post data (of selected or all posts, depending on your use case).&lt;/p&gt;

&lt;p&gt;The flow is like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a feed record is uploaded to your repo, including metadata and location of the feed generator service, which lets other users find your feed&lt;/li&gt;
&lt;li&gt;when the user opens that feed in the app, the AppView makes a request to your service on their behalf&lt;/li&gt;
&lt;li&gt;your service looks at the request params and headers, and returns a list of posts it selected in the form of at:// URIs&lt;/li&gt;
&lt;li&gt;the AppView takes those URIs and maps them to full posts (so-called “hydration”), which it returns to the user&amp;rsquo;s app&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;How exactly those posts are selected to be returned in the given request is completely up to you, the only requirement is that these are posts that the AppView will have in its database, since you only send URIs, not actual post data. In most cases, feeds use some kind of keyword/regexp matching and chronological ordering, but you can even build very complex, AI-driven algorithmic “For You” style personalized feeds.&lt;/p&gt;

&lt;p&gt;You don&amp;rsquo;t necessarily have to code a feed service yourself and host it in order to have a custom feed – there are a few feed hosting services that don&amp;rsquo;t require technical knowledge to use, like &lt;a href="https://skyfeed.app"&gt;SkyFeed&lt;/a&gt; or &lt;a href="https://www.graze.social"&gt;Graze&lt;/a&gt;.&lt;/p&gt;

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

&lt;h2&gt;Client apps&lt;/h2&gt;

&lt;p&gt;Ok, that’s technically not a server, but stay with me…&lt;/p&gt;

&lt;p&gt;The final piece that you need to fully enjoy Bluesky is the client app – a mobile/desktop one or a web frontend. Unlike on Fedi, where an instance software like Mastodon usually includes a built-in web frontend that is your main interface for accessing the service, the PDS doesn’t include anything like that, just a database and an API&amp;nbsp;(which also means it’s much more lightweight and needs less resources). All browsing is done through a separate client, and the client always does everything through the public API&amp;nbsp;– kind of like when you run a custom web client for Mastodon like &lt;a href="https://elk.zone"&gt;Elk&lt;/a&gt; or &lt;a href="https://phanpy.social"&gt;Phanpy&lt;/a&gt;, you connect it to your instance, and you view your timeline on &lt;a href="https://elk.zone"&gt;elk.zone&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So when you go to &lt;a href="https://bsky.app"&gt;bsky.app&lt;/a&gt;, that’s what you’re seeing – a web client that connects to your PDS (Bluesky-hosted or self-hosted) through the public API, no more, no less. The official app is built for both mobile platforms and for the web from a single React Native codebase (apparently React Native on the web and normal web React is not the same thing 🧐). This has allowed the still very small frontend team (and IIRC at first it was literally just Paul) to build the app for three platforms in any reasonable amount of time and maintain it going forward. The downside is that it’s kinda neither a great webapp nor a great mobile app… But the team is doing what they can to improve it, and it’s already much better than it used to be, and tbh more than good enough for me.&lt;/p&gt;

&lt;p&gt;There aren’t nearly as many alternative clients as there are for Mastodon, but there are several options available now – see the &lt;a href="https://mackuba.eu/2024/02/21/bluesky-guide/#apps"&gt;apps part of my Bluesky Guide&lt;/a&gt; blog post for links, and there is another &lt;a href="https://greengale.app/youronly.one/3mby6pzj7442e"&gt;more complete list maintained here&lt;/a&gt;. Apart from fully independent client implementations, there are also several forks of the Bluesky webapp project aka &lt;a href="https://github.com/bluesky-social/social-app"&gt;social-app&lt;/a&gt;, adding or changing various features in it, the most popular ones being &lt;a href="https://blacksky.community"&gt;blacksky.community&lt;/a&gt; and &lt;a href="https://witchsky.app"&gt;witchsky.app&lt;/a&gt;.&lt;/p&gt;

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

&lt;h2&gt;DMs&lt;/h2&gt;

&lt;p&gt;Notice that I&amp;nbsp;haven’t mentioned DMs anywhere – that’s because they aren’t a part of the protocol at the moment. The Bluesky team wants to eventually add some properly implemented, end-to-end encrypted, secure DMs using some open standard, but they won’t be able to finish that in the short term, and a lot of people were asking for at least some simple version of DMs in the app. So they’ve decided as an interim solution to implement them as a fully centralized, closed source service. It is accessible to third-party Bluesky clients through the API&amp;nbsp;(the &lt;code&gt;chat.bsky.*&lt;/code&gt; namespace), but it’s not something you can run yourself. The team is &lt;a href="https://bsky.app/profile/did:plc:44ybard66vv44zksje25o7dz/post/3lacrutxhio2h"&gt;very open&lt;/a&gt; about the fact that it’s not a proper replacement for something like Signal, and that for sensitive communication, you should ideally just use it for swapping contacts on Signal on iMessage and move the conversation there. They also kinda don’t want to spend too much time adding features there, because it’s considered a temporary solution, so it’s pretty basic in terms of available features.&lt;/p&gt;

&lt;p&gt;There are also a few other closed-source helper services, like the “cardyb” they use for generating link card details, or the video service for preprocessing videos, but they’re all specific to some Bluesky use cases only and not strictly necessary to use.&lt;/p&gt;

&lt;hr /&gt;

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

&lt;h2&gt;How it all fits together&lt;/h2&gt;

&lt;p&gt;So the flow and hierarchy is like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;client app&lt;/strong&gt; you use creates new records as a result of actions you take (new posts, likes, follows), and saves them into your PDS&lt;/li&gt;
&lt;li&gt;your &lt;strong&gt;PDS&lt;/strong&gt; emits events on its firehose with the record details&lt;/li&gt;
&lt;li&gt;Bluesky &lt;strong&gt;relay&lt;/strong&gt; and other relays are connected to the firehoses of each PDS they know about, and they pass those events to their output firehose (the PDS has a setting &lt;code&gt;PDS_CRAWLERS&lt;/code&gt; that tells it which relays to ask to connect to it, but since that&amp;rsquo;s generally set to just &lt;code&gt;bsky.network&lt;/code&gt;, relays can and do also connect by themselves to PDSes or other relays)&lt;/li&gt;
&lt;li&gt;the Bluesky &lt;strong&gt;AppView&lt;/strong&gt; (and other AppViews) listen to the firehose of their selected relay; it could also be multiple relays, or it could even just stream directly from PDSes, but in practice this will normally be one trusted relay&lt;/li&gt;
&lt;li&gt;the AppView receives events that include your records, and if they are relevant, saves the data to its internal database in some appropriate representations&lt;/li&gt;
&lt;li&gt;when other users browse Bluesky in their client apps, they load timelines, feeds and threads from the AppView, which returns info about your post from that database it saved it to; the choice of which AppView that is depends on both the PDS and the client app – the client should send an &lt;code&gt;atproto-proxy&lt;/code&gt; header specifying that, but the PDS also has a hardcoded default in the config file&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Additionally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;feed generators&lt;/strong&gt; run by third party feed operators also stream data from Bluesky&amp;rsquo;s or some other relay and save it locally, so they can respond to feed requests from the AppView&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;labellers&lt;/strong&gt; also stream data from Bluesky&amp;rsquo;s or some other relay, and emit labels on their firehoses, which get sent to the AppView (note: there is no official &amp;ldquo;labeller relay&amp;rdquo; sitting between labellers and the AppView, although one third party dev &lt;a href="https://bsky.app/profile/did:plc:w4xbfzo7kqfes5zb7r6qv3rw/post/3lrgs3itqyc2q"&gt;wrote one&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PDSes &lt;strong&gt;do not connect to each other directly&lt;/strong&gt;, and they don&amp;rsquo;t store posts of users from other PDSes, only their own&lt;/li&gt;
&lt;li&gt;although right now the vast majority of users use the Bluesky relay and AppView, anyone &lt;em&gt;can&lt;/em&gt; set up their own alternative relays and AppViews, which feed from all or any subset of known PDSes&lt;/li&gt;
&lt;li&gt;it&amp;rsquo;s absolutely possible and expected that two users using different PDSes, which use separate AppViews feeding from separate relays will be able to talk to each other and see each other&amp;rsquo;s responses on their own AppView, as long as the users aren&amp;rsquo;t banned on the other user&amp;rsquo;s infrastructure; the metaphor that&amp;rsquo;s often used to describe these relationships is that PDSes are like websites which publish some blog posts, and relays &amp;amp; AppViews are like search engines which crawl and index the web and then let you look up results in them – in most cases, a website should be indexed and visible in all/most available search engines&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

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

&lt;h2&gt;Non-Bluesky Atmosphere apps&lt;/h2&gt;

&lt;p&gt;Most of this blog post was naturally focused mainly on Bluesky the microblogging service, since it&amp;rsquo;s kind of the supermassive &lt;del&gt;black&lt;/del&gt; blue hole at the center of the ATProto galaxy. But the long-term plans for the AT Protocol are much bigger than just a decentralized Twitter clone – the Atmosphere is meant to be a network of many different apps operating on many different kinds of content, interoperating in various messy and creative ways. And there is a very enthusiastic, friendly, and crazy (complimentary) community of developers and builders, who are working on building that network of apps at this very moment.&lt;/p&gt;

&lt;p&gt;Some popular apps like this include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;blogging platforms like &lt;a href="https://leaflet.pub"&gt;Leaflet&lt;/a&gt; or &lt;a href="https://pckt.blog"&gt;Pckt&lt;/a&gt; (don&amp;rsquo;t try to pronounce it &amp;ldquo;packet&amp;rdquo;)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stream.place"&gt;stream.place&lt;/a&gt;, a streaming video site&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tangled.org"&gt;Tangled&lt;/a&gt;, a GitHub alternative&lt;/li&gt;
&lt;li&gt;&lt;a href="https://grain.social"&gt;Grain&lt;/a&gt;, a photo sharing site&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;So how does all of the above apply to apps like these?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;each such app (if it&amp;rsquo;s not only operating on Bluesky records) needs to have some number of custom lexicons defining its records – e.g. here&amp;rsquo;s a &lt;a href="https://pdsls.dev/at://did:plc:e7rftrdyz5e2rw4y6ocszew2/com.atproto.lexicon.schema/social.grain.photo"&gt;lexicon for a Grain photo&lt;/a&gt;; some apps are also cooperating on creating shared lexicons reused between apps, e.g. a few of the ATProto blogging platforms have agreed on a shared blog post lexicon called &lt;a href="https://standard.site"&gt;standard.site&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;the app lets users log in using their &amp;ldquo;Atmosphere account&amp;rdquo;; these days it&amp;rsquo;s expected for new apps to authenticate using OAuth with proper granular permissions, requiring the user to only enter their handle and looking up the assigned PDS automatically (with support for both non-Bluesky PDSes and did:web), and as a nice touch, a lot of apps autocomplete the handle from the AppView as you&amp;rsquo;re typing, showing you suggestions with Bluesky avatars&lt;/li&gt;
&lt;li&gt;in the app, users create records of a given custom lexicon, which are saved to their own PDS, whatever that is – e.g. here&amp;rsquo;s the raw record of one of my &lt;a href="https://pdsls.dev/at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/site.standard.document/3mbrsgtf7m22o"&gt;blog posts on Leaflet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;an Atmosphere app is expected to work with any PDS, whether it&amp;rsquo;s Bluesky-run or self-hosted, so the app doesn&amp;rsquo;t technically need to have its own, but at least some apps choose to also run a PDS as part of their service, so when someone comes to their website or downloads their app who doesn&amp;rsquo;t yet know what &amp;ldquo;ATProto&amp;rdquo; or &amp;ldquo;Atmosphere&amp;rdquo; is and doesn&amp;rsquo;t even have a Bluesky account, they can click a &amp;ldquo;sign up&amp;rdquo; button and create an account on that PDS, without having to know what a PDS is; apps like Tangled, Spark, Pckt or npmx have their own PDSes like this. Of course, you can host &lt;em&gt;any&lt;/em&gt; ATProto records on these PDSes, including Bluesky posts and content from other apps.&lt;/li&gt;
&lt;li&gt;Bluesky&amp;rsquo;s PDSes are totally fine with hosting records &amp;amp; blobs coming from these independent apps (subject to some rate limits and abuse policy), even if the Bluesky AppView doesn&amp;rsquo;t understand them; similarly, Bluesky&amp;rsquo;s relay and other relays pass through the events about these records just fine, as long as the events are properly formatted. If you&amp;rsquo;re reading data from such relay e.g. for the purposes of a feed service, you will also receive all of these records too.&lt;/li&gt;
&lt;li&gt;on the other hand, the Bluesky AppView (and other Bluesky-compatible AppViews, like the Blacksky one) will completely ignore records of unknown types – remember, an AppView is basically a service with an SQL database with tables like &amp;ldquo;posts&amp;rdquo; and &amp;ldquo;likes&amp;rdquo;; it would have no place to store a &amp;ldquo;Leaflet blog post&amp;rdquo; or a &amp;ldquo;Tangled pull request&amp;rdquo;, since these are not Bluesky post-shaped. So an app with its own custom lexicon needs to have &lt;em&gt;its own custom AppView&lt;/em&gt; that&amp;rsquo;s built to index that kind of content.&lt;/li&gt;
&lt;li&gt;at least at this stage, these apps tend to not have full XRPC APIs exposed and multiple client app options using those APIs, but rather have the client and the AppView fused together as a website with custom backend; you log in to the website using OAuth, the backend stores an access token to your account, and then the &lt;em&gt;backend&lt;/em&gt; creates records and sends them to your PDS, while also saving them to its local database (and ideally also listening on the relay for any possible records that could have been created in some other way)&lt;/li&gt;
&lt;li&gt;labellers and labels could be used to some degree on these apps, but in practice I&amp;nbsp;haven&amp;rsquo;t really seen that happen yet&lt;/li&gt;
&lt;li&gt;feed generators are rather Bluesky-specific at the moment&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;This part of the Atmosphere is much more fresh than the Bluesky-focused one, but it&amp;rsquo;s growing rapidly, so much so that&amp;rsquo;s it hard to keep up with everything!&lt;/p&gt;

&lt;hr /&gt;

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

&lt;h2&gt;Where to go next&lt;/h2&gt;

&lt;p&gt;And that’s about it – I&amp;nbsp;think with the above, you should have a pretty good grasp of the big picture of ATProto architecture and all the specific parts of it. Now, if you want to start playing with the protocol and building some things on it, a lot will depend on what specifically you want to build and using what languages/technologies:&lt;/p&gt;

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

&lt;h4&gt;SDKs:&lt;/h4&gt;

&lt;p&gt;Two languages are officially supported by Bluesky:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JavaScript/TypeScript, in which most of their code is written (see the &lt;a href="https://github.com/bluesky-social/atproto/tree/main/packages"&gt;packages folder&lt;/a&gt; in the &lt;code&gt;atproto&lt;/code&gt; repo)&lt;/li&gt;
&lt;li&gt;Go, which is used in some backend pieces like the relay, or the &lt;a href="https://github.com/bluesky-social/goat"&gt;goat&lt;/a&gt; command line tool used e.g. for PDS migrations (see the &lt;a href="https://github.com/bluesky-social/indigo"&gt;&lt;code&gt;indigo&lt;/code&gt; repo&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;For other languages, some community-run projects are listed on the &lt;a href="https://atproto.com/sdks"&gt;SDKs page on atproto.com&lt;/a&gt;, and I&amp;nbsp;have a website called &lt;a href="https://sdk.blue"&gt;sdk.blue&lt;/a&gt;, which lists all libraries and SDKs I&amp;nbsp;know about, grouped by language. As you can see, there is something there for most major languages; there are pretty solid options for e.g. Python, Rust, Swift, C#, or Dart, and I&amp;rsquo;ve built and maintain a group of Ruby gems, which you can see on &lt;a href="https://ruby.sdk.blue"&gt;ruby.sdk.blue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to use a language that doesn’t have any libraries yet, it’s really not that hard to make one from scratch – for most things you just need an HTTP client and a JSON parser, and maybe a websocket client (implementing OAuth from scratch will definitely be a hard part though, if you need that).&lt;/p&gt;

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

&lt;h4&gt;Docs:&lt;/h4&gt;

&lt;p&gt;There is quite a lot of official documentation, although it’s a bit spread out and sometimes not easy to find.&lt;/p&gt;

&lt;p&gt;The places to look in are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://atproto.com"&gt;atproto.com&lt;/a&gt; – the official AT Protocol website; a bit more formal documentation about the elements of the protocol, kind of like what I&amp;nbsp;did here, but with much more info and detailed specifications of each thing. Recently (early 2026) fully redesigned, with some more practical content now too.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/docs/get-started"&gt;docs.bsky.app&lt;/a&gt; – more practical documentation with guides and examples of specific use cases in TS &amp;amp; Python (roll down the sections in the sidebar); it shows examples of how to make a post, upload a video, how to connect to the firehose, how to make a custom feed, etc.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://atproto.com/blog"&gt;atproto.com/blog&lt;/a&gt; – developer blog with updates about protocol changes&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bsky.app/docs/category/http-reference"&gt;HTTP reference&lt;/a&gt; – a reference of all the API&amp;nbsp;endpoints&lt;/li&gt;
&lt;li&gt;something that I&amp;nbsp;also find useful is to have the &lt;a href="https://github.com/bluesky-social/atproto"&gt;atproto repo&lt;/a&gt; checked out locally and opened in the editor, and look things up in the JSON files from the &lt;a href="https://github.com/bluesky-social/atproto/tree/main/lexicons"&gt;/lexicons folder&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;And a few other articles that might work better for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“&lt;a href="https://atproto.com/articles/atproto-for-distsys-engineers"&gt;ATProto for distributed systems engineers&lt;/a&gt;”, Bluesky’s technical overview of the server and data flow architecture&lt;/li&gt;
&lt;li&gt;“&lt;a href="https://atproto.com/articles/atproto-ethos"&gt;ATProto Ethos&lt;/a&gt;”, also on the Bluesky blog, based on a conference talk&lt;/li&gt;
&lt;li&gt;&amp;ldquo;&lt;a href="https://marvins-guide.leaflet.pub/3lyqxqbbqkc2p"&gt;What the hell is the atmosphere anyway&lt;/a&gt;: A slightly less technical intro to the technical side of Bluesky&amp;rdquo;, by Bailey Townsend (Sep 2025)&lt;/li&gt;
&lt;li&gt;&amp;ldquo;&lt;a href="https://overreacted.io/open-social/"&gt;Open Social&lt;/a&gt;&amp;rdquo; – a high-level overview of how ATProto brings back the openness of the original pre Web 2.0 web, by Dan Abramov (Sep 2025)&lt;/li&gt;
&lt;li&gt;&amp;ldquo;&lt;a href="https://overreacted.io/where-its-at/"&gt;Where It&amp;rsquo;s at://&lt;/a&gt;&amp;rdquo; – a well written guide to AT URIs and DIDs, by Dan Abramov (Oct 2025)&lt;/li&gt;
&lt;li&gt;the &amp;ldquo;&lt;a href="https://atproto.com/guides/applications"&gt;Statusphere&lt;/a&gt;&amp;rdquo; app example on atproto.com&lt;/li&gt;
&lt;/ul&gt;


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

&lt;h4&gt;Community:&lt;/h4&gt;

&lt;p&gt;Someone said recently that “&lt;em&gt;bsky replies are the only real documentation for ATProto&lt;/em&gt;”, and honestly, they’re not wrong. We have a great community of third party developers now, building their own tools, apps, libraries, services, even organizing &lt;a href="https://atmosphereconf.org"&gt;conferences&lt;/a&gt;. If you’re starting out and you have any questions, just ask and someone will probably help, and some of the Bluesky team developers are also very active in Bluesky threads, answering questions and clarifying things. So a lot of such knowledge that&amp;rsquo;s not necessarily found in the official docs can be found somewhere on Bluesky (even if you first need to find the head it&amp;rsquo;s stored in).&lt;/p&gt;

&lt;p&gt;The two places I&amp;nbsp;recommend looking at are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &amp;ldquo;ATProto Touchers&amp;rdquo; Discord chat – ping me or some other developer for an invite&amp;nbsp;:)&lt;/li&gt;
&lt;li&gt;my &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/atproto"&gt;ATProto feed&lt;/a&gt; on Bluesky, which tries to catch any ATProto development discussions – it should include posts with any mention of “ATProto” or things like “AppView” or various API&amp;nbsp;names and technical terms, or you can use &lt;code&gt;#atproto&lt;/code&gt; or &lt;code&gt;#atdev&lt;/code&gt; hashtag to be sure&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;Also, there’s a fantastic newsletter called &lt;a href="https://connectedplaces.online"&gt;Connected Places&lt;/a&gt; (formerly Fediverse Report) by Laurens Hof, who chronicles and comments on the events happening in the Bluesky/ATProto world and on the Fediverse (and *a lot* of things are happening).&lt;/p&gt;

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

&lt;h4&gt;Ideas:&lt;/h4&gt;

&lt;p&gt;Some easy ways to start tinkering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use one of the &lt;a href="https://sdk.blue"&gt;existing libraries for your favorite language&lt;/a&gt; and make a website or command-line tool which loads some data from the AppView or PDS: load and print timelines, calculate statistics, browse contents of PDSes and repos, etc.&lt;/li&gt;
&lt;li&gt;make a bot that posts something (not spammy!)&lt;/li&gt;
&lt;li&gt;make a simple custom feed service using &lt;a href="https://docs.bsky.app/docs/starter-templates/custom-feeds"&gt;one of the available templates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;connect to the relay firehose and print or record some specific types of data&lt;/li&gt;
&lt;/ul&gt;


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

&lt;h4&gt;Tools:&lt;/h4&gt;

&lt;p&gt;And a couple of tools which will certainly be useful in development:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://internect.info"&gt;internect.info&lt;/a&gt; – look up an account by handle/DID and see details like assigned PDS or handle history&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pdsls.dev"&gt;PDSls&lt;/a&gt; – PDS and repository browser, lets you look up repos by account DID or records by at:// URI&amp;nbsp;(there are a few others, but this one is most popular)&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

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

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

&lt;p&gt;&lt;strong&gt;22 Apr 2026&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rewritten/reorganized the initial &amp;ldquo;What is Bluesky&amp;rdquo; section&lt;/li&gt;
&lt;li&gt;added link to a conference talk about using Bluesky lexicons&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;strong&gt;20 Apr 2026&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mentioned some new Atmosphere apps in several places, and tools like EU-Haul&lt;/li&gt;
&lt;li&gt;updated section about independent PDSes with more up to date info about new communities (e.g. Eurosky, selfhosted.social, npmx) and new independent PDS implementations&lt;/li&gt;
&lt;li&gt;updated section about relays with info about community relays, independent implementations, and the Pulsar site which tracks them&lt;/li&gt;
&lt;li&gt;updated section about AppViews with info about the Blacksky AppView&lt;/li&gt;
&lt;li&gt;added info about the independent PLC organization&lt;/li&gt;
&lt;li&gt;added link to the blog post tracking a list of client apps, and links to Blacksky webapp and Witchsky&lt;/li&gt;
&lt;li&gt;rearranged some things in the &amp;ldquo;How it all fits together&amp;rdquo; section&lt;/li&gt;
&lt;li&gt;added a whole new section &amp;ldquo;Non-Bluesky Atmosphere apps&amp;rdquo;&lt;/li&gt;
&lt;li&gt;updated section about available libraries and SDKs&lt;/li&gt;
&lt;li&gt;a lot of small tweaks and corrections everywhere&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;strong&gt;15 Oct 2025&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;added link to Dan&amp;rsquo;s AT URI&amp;nbsp;guide&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;strong&gt;26 Sep 2025&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you can now migrate back to Bluesky&amp;rsquo;s PDSes&lt;/li&gt;
&lt;li&gt;added links to Bailey Townsend&amp;rsquo;s and Dan Abramov&amp;rsquo;s blog posts&lt;/li&gt;
&lt;li&gt;some small corrections&lt;/li&gt;
&lt;/ul&gt;

</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2025/06/30/socials-2025/</id>
    <title>Social media update 2025</title>
    <published>2025-06-30T14:43:43Z</published>
    <updated>2025-06-30T14:43:43Z</updated>
    <link href="https://mackuba.eu/2025/06/30/socials-2025/"/>
    <content type="html">

&lt;div class="hide-in-intro"&gt;
  &lt;p class="image noborder"&gt;&lt;img src="https://mackuba.eu/images/posts/bernie.jpg?1777132788" width="360" alt="Bernie asking: I&amp;nbsp;am once again asking if you could just turn on Bridgy"&gt;&lt;/p&gt;
&lt;/div&gt;


&lt;p&gt;So here we are, halfway through 2025, a bit over 2.5 years after the Eloncalypse… For better or worse, the Twitter as we knew it in the 2010s and the communities we had there are mostly gone. But it doesn’t feel like we’ve all settled on anything comparable.&lt;/p&gt;

&lt;p&gt;If you’re a software developer who was active on Twitter before, by now you’ve almost certainly tried at least one of the alternatives – Mastodon, Bluesky, and Threads, and you’re probably posting actively on at least one of these, but probably not on all of them. The problem is that nobody has enough mental space to be active on 3-4 similar social networks, so we’ve split into different camps which only partially overlap. You’re probably still missing some friends from Twitter and some interesting content. It’s all a bit in flux and a bit of a mess.&lt;/p&gt;

&lt;p&gt;Myself, I’ve basically left Twitter; I&amp;nbsp;haven’t spent much time on Threads (among other reasons, it was unavailable in Europe for a long time); and I’m mostly hanging out on Bluesky and somewhat on Mastodon.&lt;/p&gt;

&lt;p&gt;So where do we go from here?&lt;/p&gt;
&lt;p&gt;Obviously everyone has their own take on that, this is just mine. But I&amp;nbsp;really think we should all try to make an effort to focus on the widely understood “open social”, or what Laurens Hof from Fediverse Report now calls “&lt;a href="https://connectedplaces.online/connected-places-intro/"&gt;Connected places&lt;/a&gt;”. That means &lt;strong&gt;Bluesky and Mastodon/Fediverse&lt;/strong&gt; (with emphasis on &lt;em&gt;&amp;ldquo;and&amp;rdquo;&lt;/em&gt;), and to some degree maybe also Threads, although that depends on how their integration with ActivityPub progresses (and it’s looking more and more like they &lt;a href="https://bsky.app/profile/did:plc:esmiuxk53vmsllayghrq676w/post/3lseuteuuf22j"&gt;aren’t very serious about it&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My advice:&lt;/p&gt;

&lt;p&gt;→ If you’re currently cross-posting or bridging between &lt;strong&gt;Mastodon and Bluesky&lt;/strong&gt;: awesome! ❤️&lt;/p&gt;

&lt;p&gt;→ If you’re active on &lt;strong&gt;Mastodon&lt;/strong&gt;, but currently ignoring or forgot about Bluesky: &lt;strong&gt;please&lt;/strong&gt; reconsider it. I&amp;nbsp;know that these two communities have a lot of differences between them, and we love to hate each other (I&amp;nbsp;fully admit I&amp;rsquo;m guilty of that myself). It’s likely you prefer one or the other of these for various reasons, and you might not be a fan of the other one. But I&amp;nbsp;think it’s clear at this point that none of them will disappear in the near-term at least or replace the other for everyone.&lt;/p&gt;

&lt;p&gt;It would be great if we all made some effort to connect to the other side, for those who like it more there. What are the options? Depending on what’s more convenient to you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;there are some native apps which let you post to two or more services in parallel, e.g. &lt;a href="https://croissantapp.com"&gt;Croissant&lt;/a&gt;, &lt;a href="https://openvibe.social"&gt;Openvibe&lt;/a&gt; or &lt;a href="https://mszpro.com/sorasns"&gt;SoraSNS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;most social media management services like &lt;a href="https://buffer.com"&gt;Buffer&lt;/a&gt; now support both Fediverse and Bluesky, so you can use that to post to both, including scheduling etc. There are several others like this, and they usually have some free plans.

&lt;ul&gt;
&lt;li&gt;e.g. &lt;a href="https://fedica.com"&gt;Fedica&lt;/a&gt; was one of the ones that had support for Bluesky from very early on&lt;/li&gt;
&lt;li&gt;also my friend from my first job, &lt;a href="https://solnic.dev"&gt;Peter Solnica&lt;/a&gt; (known from some Ruby libraries like DataMapper/ROM, dry-rb, Hanami, and now some Elixir libs too) is building his own called &lt;a href="https://justcrosspost.app"&gt;JustCrossPost&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;I&amp;nbsp;use a little tool in Ruby I&amp;nbsp;wrote for myself named &lt;a href="https://github.com/mackuba/tootify"&gt;tootify&lt;/a&gt;, which lets me selectively cross-post relevant posts to Mastodon, almost effortlessly: I&amp;nbsp;post on Bluesky and then “like” my own post if I&amp;nbsp;want it to be copied to Mastodon (which clears the like). So this way I&amp;nbsp;can cross-post e.g. cat photos or iOS related posts, but skip Bluesky-specific content.&lt;br/&gt;
There are probably similar tools going in the other direction, although it’s a bit more complicated this way because of the post length limits (usually 500 on Mastodon vs. 300 on Bluesky).&lt;/li&gt;
&lt;li&gt;I&amp;nbsp;know some people have also written some iOS Shortcuts, scripts, browser extensions etc. (I&amp;nbsp;don&amp;rsquo;t have any links at hand)&lt;/li&gt;
&lt;li&gt;and last but not least, there’s &lt;a href="https://fed.brid.gy"&gt;Bridgy Fed&lt;/a&gt;: basically enable it once by following the bridge account, and you can forget about it. It creates a &amp;ldquo;mirror&amp;rdquo; account of yours on the other side, but when people interact with it there, the likes/reposts/comments go back to you (as long as that other person also has the bridge enabled).&lt;/li&gt;
&lt;li&gt;there are probably other options too, let me know in the comments&amp;nbsp;:)&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;→ If you’re active on &lt;strong&gt;Bluesky&lt;/strong&gt;: wonderful! ☺️ But again, all of the above applies. Don’t forget about the friends and strangers who prefer the elephant site 🦣. At the very least, enable the Bridgy bridge.&lt;/p&gt;

&lt;p&gt;→ If you do have both Mastodon and Bluesky accounts, but you&amp;rsquo;ve decided that you want to use them for &lt;strong&gt;different kind of content&lt;/strong&gt; (e.g. tech vs. non-tech)… please reconsider, in light of what I&amp;nbsp;wrote above. Most of the people who know you online would probably prefer to follow you in one or the other place they like more, not to have to follow you &lt;em&gt;everywhere&lt;/em&gt; in parallel. (Though nobody says you can&amp;rsquo;t have e.g. two different accounts which are both bridged.)&lt;/p&gt;

&lt;p&gt;→ If you had a bridged account, but turned off the bridge because you prefer to be posting there directly, but you don’t really post there directly (you know who you are 😛) – I&amp;nbsp;am begging you, please either figure out some cross-posting solution (see above), or enable the bridge 🙏🏻&lt;/p&gt;

&lt;p&gt;→ If you’re mostly active on &lt;strong&gt;Threads&lt;/strong&gt; (is that a thing?…) – turn on the &lt;a href="https://help.instagram.com/760878905943039"&gt;fediverse sharing option&lt;/a&gt; (it might not be available in the EU though, according to the support article?), and follow the Bridgy account to enable bridging to Bluesky (there aren’t many accounts connected like this, but it &lt;a href="https://bsky.app/profile/shnarfed.threads.net.ap.brid.gy"&gt;&lt;em&gt;should&lt;/em&gt; work&lt;/a&gt;, let me know if you have any problems).&lt;/p&gt;

&lt;p&gt;→ If you’re still mostly active on &lt;strong&gt;Twitter&lt;/strong&gt; (I&amp;nbsp;see you 👀 and I&amp;nbsp;am kinda judging you)… please rethink it. I&amp;nbsp;mean… you really don’t mind helping Elon and his buddies? I&amp;nbsp;know, there’s more content there, more engagement (&lt;a href="https://bsky.social/about/blog/11-29-2024-engagement"&gt;maybe&lt;/a&gt;), big accounts are still there, news is still there, more people talking there about startups, AI, indie dev or whatever. But wouldn’t you want to change that? Wouldn’t you prefer to use and build on a network with an open API&amp;nbsp;that isn’t controlled by one rich American far-right guy&lt;a href="#footnote1"&gt;1)&lt;/a&gt; who thinks he’s the president of the world?… Where you don’t see a full screen ad every few posts, and don&amp;rsquo;t need to pay a fuckton of dollars to build some fun tool on it? We need to make an effort to move away from there. Someone’s gotta start, and then maybe others will come.&lt;/p&gt;

&lt;p&gt;→ If you&amp;rsquo;re posting &lt;em&gt;both&lt;/em&gt; on Twitter and on Mastodon/Bluesky, then, well… it&amp;rsquo;s ok I&amp;nbsp;guess 🙃 I&amp;nbsp;think the most important thing is to let the people who do want to move away from Twitter completely, have the content they want to follow in the new place. That&amp;rsquo;s the first step. A natural second step is then to start decreasing the amount of content that there is on Twitter, so people have more incentive to look for it elsewhere, but I&amp;nbsp;understand if not everyone is ready for that step yet.&lt;/p&gt;

&lt;p&gt;→ If you tried Mastodon and/or Bluesky before, but you felt like it was &lt;strong&gt;too empty there&lt;/strong&gt;, you didn&amp;rsquo;t have enough content to read or you felt like you were shouting into the void – please give it another try, and please give it some time. Twitter also wasn&amp;rsquo;t immediately the place you remember from the first day you signed up, was it? You need to put in some effort, like you did everywhere else. Some tips:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;on Bluesky: find some good &lt;a href="/2024/02/21/bluesky-guide/#feeds"&gt;custom feeds&lt;/a&gt; and/or &lt;a href="https://blueskydirectory.com/starter-packs"&gt;starter packs&lt;/a&gt; to follow&lt;/li&gt;
&lt;li&gt;on Mastodon: follow some hashtags, and use hashtags when posting to get more reach (but within reason!). On a private instance, this gets a bit more tricky due to the ActivityPub architecture, so it might make sense to start on a larger one at first.&lt;/li&gt;
&lt;li&gt;on both: try to find some people you recognize from Twitter, and then look who they follow or repost, and recursively look through their profiles too. Also, interact with people, give likes/faves, good replies, and so&amp;nbsp;on.&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;→ If you&amp;rsquo;re on &lt;strong&gt;Nostr&lt;/strong&gt;, there are some kind of Nostr ↔ Fedi bridges or gateways, and you can use those + Bridgy to reach Bluesky too (e.g. here&amp;rsquo;s a &lt;a href="https://bsky.app/profile/npub1wmr34t36fy03m8hvgl96zl3znndyzyaqhwmwdtshwmtkg03fetaqhjg240.momostr.pink.ap.brid.gy"&gt;Nostr account&lt;/a&gt; &amp;ldquo;double-bridged&amp;rdquo; to Bluesky).&lt;/p&gt;

&lt;p&gt;→ If you’ve moved to something like &lt;a href="https://micro.blog"&gt;Micro.blog&lt;/a&gt;, or just blogging – that’s also cool! But I&amp;nbsp;hope you’re somehow posting the links to Bluesky &amp;amp; Fedi too&amp;nbsp;:)&lt;/p&gt;

&lt;p&gt;→ If you’ve just given up on microblogging or social media in general… I&amp;nbsp;understand. It’s probably not a bad choice in the current times. I&amp;nbsp;hope we somehow meet again someday, digitally or in person 🩵&lt;/p&gt;

&lt;p&gt;So let’s connect, let’s build bridges. But also, let’s finally help the X-shaped zombie die 🧟‍♂️&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;(You can find me on Bluesky at &lt;a href="https://bsky.app/profile/mackuba.eu"&gt;@mackuba.eu&lt;/a&gt; (I&amp;nbsp;have my private PDS at &lt;a href="https://lab.martianbase.net"&gt;lab.martianbase.net&lt;/a&gt;), which is my main account these days, and on Mastodon at &lt;a href="https://martianbase.net/@mackuba"&gt;mackuba@martianbase.net&lt;/a&gt; (also a private instance), where I’m cross-posting a good chunk of the posts from Bluesky using Tootify. If you want to see everything including some shitposting, politics and ATProto-specific posts, the Bluesky account is also bridged as &lt;code&gt;mackuba.eu@bsky.brid.gy&lt;/code&gt;. Twitter account &lt;a href="https://twitter.com/kuba_suder"&gt;@kuba_suder&lt;/a&gt; is left as an archive, because I&amp;nbsp;don’t like deleting useful content from the Internet, but I&amp;nbsp;don’t use it anymore. Lately I&amp;rsquo;ve been also blogging somewhat more regularly than here on my new &lt;a href="https://journal.mackuba.eu"&gt;&amp;ldquo;journal&amp;rdquo; Micro.blog&lt;/a&gt;.)&lt;/p&gt;

&lt;p class="footnote" id="footnote1"&gt;1) If you now want to write a comment mentioning someone with the initials &amp;ldquo;J.D.&amp;rdquo;, don&amp;rsquo;t even think about it! 🫠&lt;/p&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/2025/02/04/micro-blog-journal/</id>
    <title>Micro.blog journal</title>
    <published>2025-02-04T16:00:05Z</published>
    <updated>2025-02-04T16:00:05Z</updated>
    <link href="https://mackuba.eu/2025/02/04/micro-blog-journal/"/>
    <content type="html">&lt;p&gt;&lt;strong&gt;Update 17.11.2025&lt;/strong&gt;: I&amp;rsquo;ve migrated this journal blog now from Micro.blog to &lt;a href="https://leaflet.pub"&gt;Leaflet&lt;/a&gt;, a new blogging service built on top of Bluesky&amp;rsquo;s ATProto. I&amp;nbsp;wrote about this &lt;a href="https://lab.mackuba.eu/3m5tyjv3ssc2p"&gt;here&lt;/a&gt;, the new URL is &lt;a href="https://lab.mackuba.eu"&gt;https://lab.mackuba.eu&lt;/a&gt; (I&amp;rsquo;ll add a redirect from &amp;lsquo;journal&amp;rsquo; later).&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;Just a quick update, if you&amp;rsquo;re following this blog via RSS: I&amp;rsquo;ve started a separate &amp;ldquo;journal&amp;rdquo; blog on micro.blog: &lt;a href="https://journal.mackuba.eu"&gt;journal.mackuba.eu&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://micro.blog"&gt;Micro.blog&lt;/a&gt; is an interesting service: it&amp;rsquo;s a one-man indie business that&amp;rsquo;s sort of a hybrid between a blogging platform and a microblogging social network. You can write anything between full-size blog posts and tweet-sized single messages, and you can cross-post them to Bluesky, Mastodon etc. You can also follow people from the community that&amp;rsquo;s formed there and reply to them, all in the form of those mini-blogposts (there are no likes or retweets though). The idea, as I&amp;nbsp;understand, is to use a network of blogs to build a social network that uses the web itself as the foundation.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m not really planning to use it in this social network mode, since I&amp;rsquo;m pretty happy now posting on Bluesky and to limited degree on Mastodon (I&amp;rsquo;ve completely stopped posting on Twitter at this point, since last autumn, when Elon started openly supporting Trump). I&amp;rsquo;m also not completely sold on this &amp;ldquo;web as a social network&amp;rdquo; idea. And I&amp;nbsp;don&amp;rsquo;t intend it to replace this blog here either – I&amp;nbsp;will still be (very) occasionally posting those super long articles here like &lt;a href="/2014/10/06/a-guide-to-nsbutton-styles/"&gt;the one about NSButtons&lt;/a&gt; or &lt;a href="/2024/02/21/bluesky-guide/"&gt;the guide to Bluesky&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But I&amp;rsquo;ve felt the need for a while to have a place to post something in between those – not full blog, and not a micro blog, but a &amp;ldquo;mediumblog&amp;rdquo; so to say (not to be confused with a Medium blog) – like this post, for example. Something where I&amp;nbsp;can sometimes post my thoughts more easily, when I&amp;nbsp;want to write something that doesn&amp;rsquo;t really fit in a few skeets/toots, with less effort required to start and finish it. This seems like it could work for that.&lt;/p&gt;

&lt;p&gt;It&amp;rsquo;s also nice that it&amp;rsquo;s supposed to sync replies from Bluesky/Mastodon under the posted link back to the blog page as comments below (I&amp;rsquo;ll try to implement the same thing here). I&amp;nbsp;also have it configured with my own domain, so I&amp;nbsp;can possibly migrate it to something self-hosted like Jekyll or Hugo at some point, keeping all the links and content.&lt;/p&gt;

&lt;p&gt;For now, I&amp;rsquo;ve posted two updates about what I&amp;rsquo;ve been working on recently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;about &lt;a href="https://journal.mackuba.eu/2025/01/16/postgress-progress/"&gt;tuning a Postgres database&lt;/a&gt; to which I&amp;rsquo;m trying to migrate my Bluesky feeds service&lt;/li&gt;
&lt;li&gt;and a &lt;a href="https://journal.mackuba.eu/2025/02/10/year-review/"&gt;review of all I&amp;rsquo;ve done in 2024&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;I&amp;nbsp;don&amp;rsquo;t know how often I&amp;nbsp;will end up posting there, I&amp;nbsp;don&amp;rsquo;t want to pressure myself, just to have a place to post when I&amp;nbsp;have a need. So if you&amp;rsquo;re curious, follow me there &lt;a href="https://journal.mackuba.eu/feed.xml"&gt;via RSS&lt;/a&gt; (or on &lt;a href="https://bsky.app/profile/mackuba.eu"&gt;Bluesky&lt;/a&gt; or &lt;a href="https://martianbase.net/@mackuba"&gt;Mastodon&lt;/a&gt;).&lt;/p&gt;
</content>
  </entry>
  <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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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;18 Nov 2025&lt;/a&gt;.)&lt;/i&gt;&lt;/p&gt;
&lt;/div&gt;


&lt;p&gt;For the past year and a half, 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;invite them there, 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://apps.apple.com/us/app/skeets-for-bluesky/id6466340923"&gt;Skeets&lt;/a&gt; (iOS), &lt;a href="https://play.google.com/store/apps/details?id=com.gmail.mfnboer.skywalker"&gt;Skywalker&lt;/a&gt; (Android), or &lt;a href="https://deck.blue"&gt;deck.blue&lt;/a&gt; (multi-column webapp)&lt;/li&gt;
&lt;li&gt;tweak the algorithm in your &amp;ldquo;Discover&amp;rdquo; feed by opening the &amp;ldquo;…&amp;rdquo; context menu on a post in that feed and selecting &amp;ldquo;Show more like this&amp;rdquo; or &amp;ldquo;Show less like this&amp;rdquo;; or, if you prefer a classic chronological feed, you can set the Following feed as your main feed and remove Discover&lt;/li&gt;
&lt;li&gt;if your timeline feels empty, you can find some people to follow through &lt;a href="https://blueskydirectory.com/starter-packs"&gt;&amp;ldquo;starter packs&amp;rdquo;&lt;/a&gt;, or you can go to the “Feeds” tab, scroll down to 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 in Settings&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;bookmarks, trends, thread composer, pinned posts, videos, GIFs and DMs (simple version) are now available&lt;/li&gt;
&lt;li&gt;2FA and more DM stuff are coming, post editing is planned; some version of private posts shared to limited audience is probably coming at some point, but not in near future&lt;/li&gt;
&lt;li&gt;Jack Dorsey has nothing to do with Bluesky anymore&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="#apps"&gt;Apps&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 of your data&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="#terms"&gt;How are things called here?&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="#hashtags"&gt;Hashtags&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#video"&gt;Video &amp;amp; GIFs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#starter-packs"&gt;Starter packs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#trends"&gt;Trending topics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#bookmarks"&gt;Bookmarks&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 unofficial ways to connect the two worlds.)&lt;/p&gt;

&lt;p&gt;After an initial 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 February 2024 &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;h3&gt;The Bluesky company&lt;/h3&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;Bluesky isn&amp;rsquo;t really making any money at the moment (other than a small &lt;a href="https://bsky.social/about/blog/7-05-2023-namecheap"&gt;domain reselling service&lt;/a&gt;). The general plan is that, rather than the standard route of eventually putting ads everywhere and selling user data, they will resist &lt;a href="https://www.wired.com/story/bluesky-ceo-jay-graber-wont-enshittify-ads/"&gt;“enshittifying” the platform&lt;/a&gt; and will instead add some optional &lt;a href="https://bsky.social/about/blog/10-24-2024-series-a"&gt;premium plans and services&lt;/a&gt;, but in such a way that wouldn&amp;rsquo;t change the social dynamics of the platform:&lt;/p&gt;

&lt;blockquote&gt;&lt;p&gt;Bluesky will always be free to use — we believe that information and conversation should be easily accessible, not locked down. We won’t uprank accounts simply because they’re subscribing to a paid tier.&lt;/p&gt;&lt;/blockquote&gt;

&lt;p&gt;The first planned step is a &amp;ldquo;Discord-like&amp;rdquo; subscription, which was planned for the end of 2024 / beginning of 2025, but was delayed because of the huge influx of new users that happened in November. More long term, they were also talking about coming up with some ways of letting creators and devs on the platform earn some money from their followers, with Bluesky taking some commision from the payments.&lt;/p&gt;

&lt;p&gt;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/did:plc:ragtjsm2j2vknwkz3zp4oxrd/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 “&lt;strong&gt;The company is a future adversary&lt;/strong&gt;” 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 the whole thing, but he isn’t running the company – Jay Graber is. Jack 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&amp;rsquo;s been replaced by &lt;a href="https://bsky.social/about/blog/08-06-2024-board"&gt;Mike Masnick&lt;/a&gt; later. Dorsey also &lt;a href="https://bsky.app/profile/did:plc:oky5czdrnfjpqslsw2a5iclo/post/3krxdfy6koc22"&gt;doesn&amp;rsquo;t own any shares of Bluesky&lt;/a&gt;.&lt;/p&gt;

&lt;hr /&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 made later, 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 (also works on the iPad). At the moment it’s mostly meant to be used while logged in, although some pages like posts/threads and profile views can be viewed unauthenticated (the search doesn&amp;rsquo;t currently work when logged out for performance reasons).&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, &lt;a href="https://sdk.blue"&gt;libraries to access the API&lt;/a&gt; in various languages, and they often manage to build various new features in their apps before the Bluesky 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, first full-featured third party app, although its author was hired by Bluesky later, so it hasn&amp;rsquo;t been updated much lately&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 (pending rewrite in React)&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://tokimeki.blue"&gt;Tokimeki&lt;/a&gt; &amp;ndash; a web app (+ PWA) with a column UI&amp;nbsp;from a Japanese developer&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://bsky.app/profile/did:plc:xg4h4n7w5mh3m4uoi27me4hh"&gt;Catbird&lt;/a&gt; &amp;ndash; new iOS client in beta&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://apps.apple.com/us/app/sorasns-for-mastodon-bluesky/id6754866904"&gt;SoraSNS&lt;/a&gt; &amp;ndash; a multi-network client for iOS&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openvibe.social"&gt;Openvibe&lt;/a&gt; &amp;ndash; another multi-network client with support for cross-posting and a unified timeline&lt;/li&gt;
&lt;li&gt;&lt;a href="https://suvam.io/dhaaga"&gt;Dhaaga&lt;/a&gt; &amp;ndash; a multi-network client for Android&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;The Ivory team (Tapbots) is also working on their own client called &lt;a href="https://tapbots.com/phoenix/"&gt;Phoenix&lt;/a&gt;, but at the moment it&amp;rsquo;s still work in progress.&lt;/p&gt;

&lt;p&gt;More recently, a new wave of client apps and services built on Bluesky/ATProto have also started appearing that are focused on either photos or videos, hoping to create a more open alternative for Instagram and TikTok:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:yj3p6w4e3dsyuxeyzv3xqut5"&gt;Bluescreen&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:4adlzwqtkv4dirxjwq4c3tlm"&gt;Skylight&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:cveom2iroj3mt747sd4qqnr2"&gt;Spark&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:ampm6lbmujbvkv62dvihv5xy"&gt;Skyswipe&lt;/a&gt;, and &lt;a href="https://bsky.app/profile/did:plc:gxrjh3ztx5zoseyvihuadx5c"&gt;Twilight&lt;/a&gt; for video&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:bokpqlfmibo7e6borxfbfice"&gt;Pinksky&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:24kqkpfy6z7avtgu3qg57vvl"&gt;Flashes&lt;/a&gt;, and &lt;a href="https://bsky.app/profile/did:plc:e7rftrdyz5e2rw4y6ocszew2"&gt;Grain&lt;/a&gt; for photos&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&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;When you sign up, you start with two default feeds. “Discover” 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 (note: if you tried it before and didn&amp;rsquo;t see any effect from these hints, there have been some fixes there very recently, June/July 2025, so try again). The devs are constantly tweaking it and asking for feedback, so it should be getting better over time.&lt;/p&gt;

&lt;p&gt;The second one, “Following”, is a classic chronological feed. Initially it had a sort of unique twist, in that it could show you replies from the people you follow made to anyone, regardless if you follow that other person, but this option was removed in &lt;a href="https://bsky.app/profile/bsky.app/post/3l2s5t6op2w2x"&gt;August 2025&lt;/a&gt;. If you miss that old mode, which was more noisy, but allowed you to meet friends of friends more easily, I&amp;nbsp;made two custom feeds that let you peek at those replies: &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/follows-replies"&gt;Follows &amp;amp; Replies&lt;/a&gt; and &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/replies"&gt;Only Replies&lt;/a&gt;.&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 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 main feed, 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 those of 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;Some people have also built web-based user friendly UIs for building feeds, which let you &lt;a href="https://docs.bsky.app/blog/feature-skyfeed"&gt;build feeds using a web form&lt;/a&gt; and have them hosted for you, without having to write code or host it yourself, which allowed a lot of people without programming knowledge to build their own feeds. The first such tool was &lt;a href="https://skyfeed.app"&gt;SkyFeed&lt;/a&gt;, built by one German developer, which at some point ran the vast majority of all feeds on Bluesky; another newer one is &lt;a href="https://www.graze.social"&gt;Graze&lt;/a&gt;, a larger service with a bit friendlier interface, which recently &lt;a href="https://graze.leaflet.pub/3m4yjmbnyec2c"&gt;partnered with Bluesky&lt;/a&gt; to run a trending feed for the New York mayor election night.&lt;/p&gt;

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

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

&lt;h3&gt;Useful feeds&lt;/h3&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;a href="https://goodfeeds.co"&gt;goodfeeds&lt;/a&gt; and &lt;a href="https://stats.skyfeed.me"&gt;SkyFeed Builder Feed Stats&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Some general feeds that you might find useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:q6gjnaw2blty4crticxkmujt/feed/whats-hot"&gt;What&amp;rsquo;s Hot&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:tenurhgjptubkk5zf5qhi3og/feed/catch-up"&gt;Catch Up&lt;/a&gt; and &lt;a href="https://bsky.app/profile/did:plc:tenurhgjptubkk5zf5qhi3og/feed/catch-up-weekly"&gt;Week Peak Feed&lt;/a&gt; – general feeds with most popular posts&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:vpkhqolt662uhesyj6nxm7ys/feed/infreq"&gt;Quiet Posters&lt;/a&gt; – posts from people you follow who post less than others&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:vpkhqolt662uhesyj6nxm7ys/feed/bestoffollows"&gt;Latest From Follows&lt;/a&gt; or &lt;a href="https://bsky.app/profile/did:plc:vpkhqolt662uhesyj6nxm7ys/feed/latestmutuals"&gt;Latest From Mutuals&lt;/a&gt; – just one most recent post from each person you&amp;rsquo;re following&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/feed/with-friends"&gt;Popular With Friends&lt;/a&gt; – recent posts liked by the people you follow&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/follows-replies"&gt;Follows &amp;amp; Replies&lt;/a&gt; and &lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/replies"&gt;Only Replies&lt;/a&gt; – my feeds that include all replies from your follows to anyone&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr/feed/only-posts"&gt;Only Posts&lt;/a&gt; – picks only top-level posts from Following without replies or reposts&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:vpkhqolt662uhesyj6nxm7ys/feed/followpics"&gt;The &amp;lsquo;Gram&lt;/a&gt; – only posts with images from the people you follow&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;And some &amp;ldquo;utility feeds&amp;rdquo;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:q6gjnaw2blty4crticxkmujt/feed/at-bangers"&gt;All-Time Bangers&lt;/a&gt; – posts with the highest number of likes on the whole platform&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:q6gjnaw2blty4crticxkmujt/feed/bangers"&gt;My Bangers&lt;/a&gt; – your own posts with the most likes&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:wzsilnxf24ehtmmc3gssy5bu/feed/quotes"&gt;Quotes&lt;/a&gt; – all quotes of your posts&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:wzsilnxf24ehtmmc3gssy5bu/feed/mentions"&gt;Mentions&lt;/a&gt; – all replies to you or mentions of you&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:q6gjnaw2blty4crticxkmujt/feed/my-pins"&gt;My Pins&lt;/a&gt; – your &amp;ldquo;bookmarks&amp;rdquo; made by commenting with the 📌 emoji&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&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. It&amp;rsquo;s also possible to mute words, phrases or hashtags. You can now also mute words for a specific period of time (there&amp;rsquo;s no way to mute an &lt;em&gt;account&lt;/em&gt; for a specific time yet).&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;There&amp;rsquo;s also a number of features for controlling interactions with your posts or threads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you can lock replies in a thread to only your followers, only the people you follow yourself, or some other combination, or disable them completely (also some time later after replies appear)&lt;/li&gt;
&lt;li&gt;you can choose to &amp;ldquo;hide&amp;rdquo; some specific negative replies, like on Twitter (they&amp;rsquo;re moved to a &amp;ldquo;hidden replies&amp;rdquo; section at the bottom of the thread)&lt;/li&gt;
&lt;li&gt;you can disable the option of quoting your post&lt;/li&gt;
&lt;li&gt;you can &amp;ldquo;detach&amp;rdquo; existing quote posts of your post – the quoting post stays visible, but without the embed showing your post&lt;/li&gt;
&lt;li&gt;and finally, you can temporarily &amp;ldquo;deactivate&amp;rdquo; your whole account – this makes it appear deleted to others so people won&amp;rsquo;t bother you if you need to take a break from social media&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;You can also create “moderation lists” for muting or blocking some groups of people all 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 in advance before they come across them.&lt;/p&gt;

&lt;p&gt;If you get added to a user list (the kind meant for following) or a starter pack that you don&amp;rsquo;t want to be on, and the author refuses to take you off it despite your request, you can solve the problem by blocking the author – you will &amp;ldquo;disappear&amp;rdquo; from their list then. For obvious reasons, this doesn&amp;rsquo;t apply to mute/block lists, since usually no one wants to be on those, but you can now report lists that are clearly hateful or made in bad faith, and &lt;a href="https://bsky.app/profile/safety.bsky.app/post/3lmwf453gbs2s"&gt;they are taking those down&lt;/a&gt;. If you don&amp;rsquo;t know what lists you&amp;rsquo;re on, you can look that up on &lt;a href="https://clearsky.app"&gt;Clearsky&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Bluesky&amp;rsquo;s own moderation seems to generally work ok, though there are some occasional problems, controversies, and periods when they get a bit overwhelmed with new waves of users; but obvious trolls, spams and scams are usually got rid of pretty quickly. They claim to have around 100 people in the moderation team now, &lt;a href="https://bsky.social/about/blog/01-17-2025-moderation-2024"&gt;as of January 2025&lt;/a&gt;.&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;p id="labellers"&gt;&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 2024, 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 will usually be 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 bigger ones can be managed by a whole group of people that collaborate on processing the reports (Bluesky has built an open source tool for this called &lt;a href="https://github.com/bluesky-social/ozone"&gt;Ozone&lt;/a&gt;). 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). They also have a few country-specific labellers now, so they can comply with government takedown orders by hiding given posts/accounts in a given country, but leaving it online for everyone else.&lt;/p&gt;

&lt;p&gt;You can read more about labellers 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&gt;By the way, it&amp;rsquo;s fascinating how the open architecture of Bluesky allows its features to be used sometimes in completely unexpected ways. Labellers were initially designed to be used mostly for negative, unwanted things, but quite a lot of them ended up being built to let users label &lt;em&gt;themselves&lt;/em&gt; with some badges they want to show to others: there are labellers to let you assign a &lt;a href="https://bsky.app/profile/did:plc:ubt73xes4uesthuuhbqwf37d"&gt;country flag&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:wkoofae5uytcm7bjncmev6n6"&gt;pronouns&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:hysbs7znfgxyb4tsvetzo4sk"&gt;RPG class&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:cdbp64nijvsmhuhodbuoqcwi"&gt;Zodiac sign&lt;/a&gt;, &lt;a href="https://bsky.app/profile/did:plc:yv4nuaj3jshcuh2d2ivykgiz"&gt;Hogwarts house&lt;/a&gt; and other things.&lt;/p&gt;

&lt;p&gt;Some other useful labellers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:newitj5jo3uel7o4mnf3vj2o"&gt;XBlock&lt;/a&gt;, which lets you hide screenshots of posts from other platforms like Twitter&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:i65enriuag7n5fgkopbqtkyk"&gt;Profile Labeller&lt;/a&gt;, which marks e.g. accounts created recently, without an avatar, ones that changed handle recently etc.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:mjyeurqmqjeexbgigk3yytvb"&gt;No GIFs Please&lt;/a&gt;, which is exactly what it sounds like&amp;nbsp;;)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:4ugewi6aca52a62u62jccbl7"&gt;Asuka&amp;rsquo;s Anti-Transphobia Field&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;hr /&gt;

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

&lt;h2&gt;Privacy of your data&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/did:plc:jlqiqmhalnu5af3pf56jryei/post/3kdojng5j7q2i"&gt;random photo of a goose&lt;/a&gt;, implement &lt;a href="https://bsky.app/profile/did:plc:mdpndtkinvfaxtf64ubgftzs/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/did:plc:q6gjnaw2blty4crticxkmujt/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 &amp;ldquo;saved posts&amp;rdquo; / bookmarks (the new built-in ones, not the 📌 style)&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 accounts you&amp;rsquo;re subscribed to for post update notifications&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 currently &lt;strong&gt;not end-to-end encrypted&lt;/strong&gt;, so the Bluesky team can theoretically access them, and will access them if ordered to. A fully encrypted version will come some time later. For sensitive conversations, Bluesky devs recommend that you use the DMs to just &lt;a href="https://bsky.app/profile/did:plc:44ybard66vv44zksje25o7dz/post/3lacrutxhio2h"&gt;exchange e.g. Signal usernames&lt;/a&gt; and then continue there.&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; there aren&amp;rsquo;t any secret clauses in the ToS that let Bluesky sell your data to AI&amp;nbsp;companies)&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, since you could always open a post in an “incognito” window or use an alt account)&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 (most services do delete their copies when you delete a post on Bluesky, but you can&amp;rsquo;t prove it or enforce it)&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;hr /&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, Bluesky has initially added a temporary system of &amp;ldquo;app passwords&amp;rdquo;. Recently, they&amp;rsquo;ve also implemented more convenient OAuth authorization, where you authorize a service to use your account like on Twitter and other sites. However, it&amp;rsquo;s pretty complex to implement, so not all third party apps and tools support it yet, and you might still need to use the app passwords for some of those.&lt;/p&gt;

&lt;p&gt;An app password is a special one-time password that you can generate in the official app (Settings / App Passwords), which 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. Such 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;For apps and tools that use OAuth, it works just like when authorizing apps to use your Twitter or Facebook account – you get redirected to your Bluesky server, you log in to your account, grant access for the app, and are redirected back there. You can see and manage your list of currently authorized apps on the &lt;a href="https://bsky.social/account/"&gt;account page&lt;/a&gt; (if your account is &lt;em&gt;not&lt;/em&gt; hosted on a Bluesky server, you need to open that on your server&amp;rsquo;s domain instead).&lt;/p&gt;

&lt;p&gt;For additional security, there is for 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 (note: not currently working if you&amp;rsquo;re on a third-party server). A more complete 2FA system is coming.&lt;/p&gt;

&lt;hr /&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) – an ugly string of random letters (mine is &lt;code&gt;did:plc:oio4hkxaop4ao4wz2pp3f4cr&lt;/code&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://bsky.app/profile/georgetakei.bsky.social"&gt;georgetakei.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 the 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;.)&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 😎 (if you change your handle from *.bsky.social to a custom domain, that old handle is now &lt;a href="https://bsky.app/profile/did:plc:vjug55kidv6sye7ykr5faxxn/post/3ldjbkebpgs2g"&gt;reserved and unavailable for others&lt;/a&gt;, that didn&amp;rsquo;t work like this at first). 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&gt;There&amp;rsquo;s even a number of &amp;ldquo;handle services&amp;rdquo; now that let anyone use a subdomain of their domain as the handle – e.g. go to &lt;a href="https://swifties.social"&gt;swifties.social&lt;/a&gt; if you want your handle to be &amp;ldquo;&lt;em&gt;yourname.swifties.social&lt;/em&gt;&amp;rdquo; 👩🏻‍🎤&lt;/p&gt;

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

&lt;h3&gt;Verification&lt;/h3&gt;

&lt;p&gt;The system of domain names in handles serves as a primary way of verifying accounts on Bluesky. 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/cnn.com"&gt;@cnn.com&lt;/a&gt; is definitely CNN – no one has to manually verify their documents. We also have here e.g. the European Commision &lt;a href="https://bsky.app/profile/ec.europa.eu"&gt;@ec.europa.eu&lt;/a&gt;, government of Brazil &lt;a href="https://bsky.app/profile/brasil.gov.br"&gt;@brasil.gov.br&lt;/a&gt;, Interpol &lt;a href="https://bsky.app/profile/interpol.int"&gt;@interpol.int&lt;/a&gt;, or European Space Agency &lt;a href="https://bsky.app/profile/esa.int"&gt;@esa.int&lt;/a&gt; 🚀&lt;/p&gt;

&lt;p&gt;If you&amp;rsquo;re setting up an account for some well known organization, it&amp;rsquo;s &lt;strong&gt;highly recommended&lt;/strong&gt; 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;p&gt;As an additional verification factor, e.g. for publicly known people who don&amp;rsquo;t have a well known domain or can&amp;rsquo;t use one, Bluesky has now added classic &lt;a href="https://bsky.social/about/blog/04-21-2025-verification"&gt;&amp;ldquo;blue check&amp;rdquo; verification badges&lt;/a&gt;. They&amp;rsquo;re not really meant as a common thing like they are on Twitter now, and they have some important limitations, e.g. they expire if you modify your handle or display name in any way, but they&amp;rsquo;re useful for making sure that, for example, yes, it&amp;rsquo;s the real &lt;a href="https://bsky.app/profile/did:plc:5c6cw3veuqruljoy5ahzerfx"&gt;Barack Obama&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The checkmark verification system is designed in such a way that Bluesky can grant other organizations the right to verify the people they vouch for themselves; so you can see that e.g. a &lt;a href="https://bsky.app/profile/did:plc:mymiyz3x72kdfeb5nfmrk5my"&gt;Financial Times journalist&lt;/a&gt; has a blue checkmark, but if you click on it, you see that he&amp;rsquo;s been verified by Financial Times, not by the Bluesky team. That way, Bluesky folks don&amp;rsquo;t need to verify each account themselves manually.&lt;/p&gt;

&lt;hr /&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/did:plc:oky5czdrnfjpqslsw2a5iclo/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). 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). There is &lt;a href="https://bsky.app/profile/did:plc:e4elbtctnfqocyfcml6h2lf7"&gt;a labeller&lt;/a&gt; that gives you an elder label if you had an account before summer 2023.&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&gt;A &amp;ldquo;nuclear block&amp;rdquo;, alternately &amp;ldquo;apocablock&amp;rdquo;, is a name for the feature where when one user blocks another, it hides all previous replies between the two from everyone – it basically &amp;ldquo;nukes&amp;rdquo; the whole conversation to discourage other people from joining in (though you can still find the posts though if you&amp;rsquo;re determined, e.g. on the users' profile page feeds or in &lt;a href="https://blue.mackuba.eu/skythread/"&gt;Skythread&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A &amp;ldquo;contraption&amp;rdquo; was an informal name for set of popular mutelists/blocklists maintained by a user named Kairi in 2023 (which were later turned into a widely used labeller called Aegis, which eventually shut down a few months later) – the trolls and haters who have crossed a line were told &amp;ldquo;okay, you&amp;rsquo;re going into the contraption&amp;rdquo;. The term is still being used as a generic name for any set of blocklists.&lt;/p&gt;

&lt;hr /&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 gradually changing. They&amp;rsquo;ve taken the first big step towards federation in February 2024, 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 is still a technically complex process, and there aren&amp;rsquo;t really any public non-Bluesky servers which allow open signup, but there are currently a &lt;a href="https://blue.mackuba.eu/directory/pdses"&gt;couple thousands&lt;/a&gt; of mostly personal &amp;ldquo;PDS&amp;rdquo; servers, where people keep their accounts and data. I&amp;rsquo;m currently posting from my own PDS named &lt;a href="https://internect.info/did/did:plc:oio4hkxaop4ao4wz2pp3f4cr"&gt;lab.martianbase.net&lt;/a&gt;.&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 Bluesky-managed accounts have actually already been spread out internally on a number of separate servers &amp;ndash; they’ve been migrated sometime in November 2023, and few people have noticed, because everything just kept working. Now, you have an option to move your account to a server controlled by someone else, if you want to &amp;ndash; but you can just ignore the whole thing if you don’t care about it. You can&amp;rsquo;t even easily see who is on which server, unless you check it with a third party tool like &lt;a href="https://internect.info"&gt;internect.info&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;(Fun fact: the Bluesky PDS servers are all named after &lt;a href="https://bsky-debug.app"&gt;different kinds of mushrooms&lt;/a&gt;, e.g. my original PDS was &amp;ldquo;&lt;a href="https://en.wikipedia.org/wiki/Amanita"&gt;amanita&lt;/a&gt;&amp;rdquo; 🍄 – so if you see someone talking about &amp;ldquo;mushroom servers&amp;rdquo;, they&amp;rsquo;re probably talking about those&amp;nbsp;:)&lt;/p&gt;

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

&lt;h3&gt;Bridgy Fed&lt;/h3&gt;

&lt;p&gt;Bluesky opening to federation does not mean that it will connect with Mastodon servers though, since they use different, incompatible protocols. There is however a &lt;a href="https://snarfed.org/2024-05-04_52915"&gt;&amp;ldquo;bridge&amp;rdquo; service called Bridgy&lt;/a&gt;, built and operated by a third party developer, but supported by the Bluesky team.&lt;/p&gt;

&lt;p&gt;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. See e.g. here the bridged profile of &lt;a href="https://bsky.app/profile/Gargron.mastodon.social.ap.brid.gy"&gt;Eugen Roshko (Gargron)&lt;/a&gt;, creator of Mastodon, as seen on Bluesky.&lt;/p&gt;

&lt;p&gt;If you want your Bluesky profile to be visible on the Fediverse, you need to &amp;ldquo;opt in&amp;rdquo; by following the official Bridgy account on Bluesky, &lt;a href="https://bsky.app/profile/ap.brid.gy"&gt;@ap.brid.gy&lt;/a&gt;. Your profile will be seen on the Fedi side as &lt;code&gt;@your.handle@atproto.brid.gy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To follow Mastodon accounts that are already bridged (like Gargron&amp;rsquo;s) from the Bluesky side, just look them up in the search and follow them as normally. If someone you want to follow is not bridged yet, you need to either ask them yourself to enable Bridgy (by following the Mastodon equivalent of the Bridgy account, &lt;code&gt;@atproto.brid.gy@atproto.brid.gy&lt;/code&gt;), or you can DM the &lt;a href="https://bsky.app/profile/ap.brid.gy"&gt;@ap.brid.gy&lt;/a&gt; account on Bluesky and tell the bot the Mastodon handle of the user you want to follow, and it will ask them on your behalf.&lt;/p&gt;

&lt;p&gt;Note that there are some obvious limitations because of the differences in supported features, e.g.:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;posts from Mastodon longer than 300 characters are truncated, and a link is added to the full original post&lt;/li&gt;
&lt;li&gt;Bluesky doesn&amp;rsquo;t support post editing yet, so edits made after you create a post aren&amp;rsquo;t visible on Bluesky&lt;/li&gt;
&lt;/ul&gt;


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

&lt;h3&gt;The Atmosphere&lt;/h3&gt;

&lt;p&gt;The &amp;ldquo;Atmosphere&amp;rdquo;, also often spelled &amp;ldquo;ATmosphere&amp;rdquo;, is a name given to the wider network of apps and services that are being built by third party developers on the Bluesky platform and the AT Protocol. It includes not only Bluesky-specific clients and tools, but also somewhat separate apps/sites like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;blogging platforms, e.g. &lt;a href="https://whtwnd.com"&gt;WhiteWind&lt;/a&gt; or &lt;a href="https://about.leaflet.pub"&gt;Leaflet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;photo sharing sites like &lt;a href="https://grain.social"&gt;Grain&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tangled.org"&gt;Tangled&lt;/a&gt;, a GitHub-like code hosting &amp;amp; collaboration site&lt;/li&gt;
&lt;li&gt;the &lt;a href="#apps"&gt;video apps&lt;/a&gt; mentioned earlier&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;These are not directly a part of Bluesky, aren&amp;rsquo;t run by Bluesky PBC and aren&amp;rsquo;t always closely integrated with Bluesky,  but they are built on the same system, and depending on the specific tool, they can potentially integrate and share data with Bluesky and with each other. In each of those, you can usually sign in using your existing Bluesky account and use the same handle/avatar/name that you have configured on Bluesky.&lt;/p&gt;

&lt;hr /&gt;

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

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

&lt;p&gt;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 everything 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;p id="hashtags"&gt;&lt;/p&gt;

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

&lt;p&gt;Support for hashtags was added &lt;a href="https://bsky.app/profile/bsky.app/post/3kmjbfqrrbu2t"&gt;in February 2024&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;Note that for various reasons, hashtags aren&amp;rsquo;t as integral part of the platform and aren&amp;rsquo;t as commonly used here as on the Fediverse; people also are wary of using them because they have a bit of a bad reputation from Twitter or Instagram, where spammers often abuse them. Try not to overdo hashtags, e.g. don&amp;rsquo;t use more than 1-2 in a post – ideally just take note of how other people are using them.&lt;/p&gt;

&lt;p&gt;One thing that&amp;rsquo;s defined in the protocol but not implemented in the official app yet is that hashtags were designed to have two forms: inline tags like on Twitter, and external (outline) tags which are shown below the post, which I&amp;nbsp;think is the way they work on Tumblr (Mastodon now also shows trailing hashtags at the end of a post in a similar way). 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. These tags are currently supported in some third party apps like Skeetdeck.&lt;/p&gt;

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

&lt;h2&gt;Video &amp;amp; GIFs&lt;/h2&gt;

&lt;p&gt;Videos are available on Bluesky since &lt;a href="https://bsky.social/about/blog/09-11-2024-video"&gt;September 2024&lt;/a&gt;. They&amp;rsquo;re currently limited to 3 minutes.&lt;/p&gt;

&lt;p&gt;For GIFs, there is a built-in support for adding Tenor GIFs in the post compose dialog, by pressing the GIF button and picking something from the search results, and you can also upload custom GIFs – now also on mobile.&lt;/p&gt;

&lt;p&gt;You can also embed e.g. YouTube videos in posts, which will play inline inside a post when clicked. (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 id="starter-packs"&gt;&lt;/p&gt;

&lt;h2&gt;Starter packs&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://bsky.social/about/blog/06-26-2024-starter-packs"&gt;Starter packs&lt;/a&gt; are a unique Bluesky feature added in June &amp;lsquo;24, which lets you create a list of accounts and feeds that you want to recommend to others. You can make e.g. a list of &lt;a href="https://bsky.app/starter-pack/did:plc:oio4hkxaop4ao4wz2pp3f4cr/3kvucqy663j2l"&gt;AT Protocol developers&lt;/a&gt;, &lt;a href="https://bsky.app/starter-pack/did:plc:77lswp42lgjyw36ozuo7kt7e/3l22w7vfug32w"&gt;climate scientists and reporters&lt;/a&gt;, or &lt;a href="https://bsky.app/starter-pack/did:plc:jcoy7v3a2t4rcfdh6i4kza25/3kvvsi4qacz2p"&gt;astronomers&lt;/a&gt;, and then people can use it to find some interesting new accounts to follow, or just follow them all at once with one click.&lt;/p&gt;

&lt;p&gt;You can also share a link to a starter pack with people who don&amp;rsquo;t have a Bluesky account yet, and they can use it to sign up with to have some initial set of accounts to follow.&lt;/p&gt;

&lt;p&gt;There&amp;rsquo;s no list/search of starter packs built-in in the app, but you can browse and search for them e.g. on the third party &lt;a href="https://blueskydirectory.com"&gt;Bluesky Directory&lt;/a&gt; website.&lt;/p&gt;

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

&lt;h2&gt;Trending topics&lt;/h2&gt;

&lt;p&gt;There is now a simple &amp;ldquo;trending&amp;rdquo; section that shows up in the search section and in the right sidebar on the web. Every entry leads to a kind of custom feed about that topic. You can easily disable this section if you&amp;rsquo;d prefer to not be distracted by current events, and if you&amp;rsquo;d like to bring it back, you can turn it on or off in the settings (&amp;ldquo;Content &amp;amp; Media&amp;rdquo; section).&lt;/p&gt;

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

&lt;h2&gt;Bookmarks&lt;/h2&gt;

&lt;p&gt;Native bookmarks (called &amp;ldquo;saved posts&amp;rdquo;) were added in &lt;a href="https://bsky.app/profile/bsky.app/post/3lydt7uwac22f"&gt;early September&lt;/a&gt;. You can save a post using the &amp;ldquo;bookmark&amp;rdquo; icon below it, and you can access saved posts through the &amp;ldquo;Saved&amp;rdquo; section in the sidebar. Bookmarks are private – a counter of number of saves is shown below a post, but you can&amp;rsquo;t see who saved it.&lt;/p&gt;

&lt;p&gt;An earlier, popular workaround was that you could reply to posts with a comment &amp;ldquo;📌&amp;rdquo;, and there&amp;rsquo;s a special &lt;a href="https://bsky.app/profile/did:plc:q6gjnaw2blty4crticxkmujt/feed/my-pins"&gt;bookmark feed&lt;/a&gt; which shows you all your comments with that emoji. This option is still available, although since these bookmarks are essentially normal comments, they are all public.&lt;/p&gt;

&lt;p&gt;There is also &lt;a href="https://pin2saved.vercel.app"&gt;a tool available&lt;/a&gt; to import all your previous &amp;ldquo;pins&amp;rdquo; to the new &amp;ldquo;saved posts&amp;rdquo; system.&lt;/p&gt;

&lt;hr /&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 look at in the settings, going from the top:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;u&gt;Switch account&lt;/u&gt; &amp;ndash; you can log in to multiple accounts, and you can switch between them here in the Settings, by clicking your avatar in the top-left corner of the page on the web, or by pressing and holding your avatar in the tab bar on mobile&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Account &amp;raquo; Export my data&lt;/u&gt; &amp;ndash; lets you download a backup of your data like posts and follows (at the moment it doesn&amp;rsquo;t include media like images)&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Account &amp;raquo; Deactivate account&lt;/u&gt; &amp;ndash; lets you temporarily lock your account (it appears deleted to others)&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Privacy and Security&lt;/u&gt; &amp;ndash; enable Two-Factor Authentication and create &amp;ldquo;app passwords&amp;rdquo; for third party apps; you can also specify if you want others to be able to get notifications whenever you make a new post&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Moderation&lt;/u&gt; &amp;ndash; manage muted words &amp;amp; tags, muted and blocked accounts, and your moderation lists; you can also choose to hide &amp;ldquo;blue check&amp;rdquo; badges if they annoy you&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;Notifications&lt;/u&gt; &amp;ndash; since July 2025, you can configure here precisely what kind of notifications you want to get: you can e.g. turn off all notifications about likes, reposts or follows, or leave them on only for the people you follow&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Content &amp;amp; Media &amp;raquo; 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;Content &amp;amp; Media &amp;raquo; Following Feed Preferences&lt;/u&gt; &amp;ndash; choose if you want to see replies, quotes, reposts etc. in the home feed. (Some earlier options from this section were removed last year, see the &lt;a href="#feeds"&gt;Feeds&lt;/a&gt; section for more info &amp;amp; workaround.)&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Content &amp;amp; Media &amp;raquo; Autoplay videos and GIFs&lt;/u&gt; &amp;ndash; turn off to prevent videos and gifs from automatically playing in your feeds; instead you get a play button on each that you have to press first to see it&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Content &amp;amp; Media &amp;raquo; Enable trending topics&lt;/u&gt; &amp;ndash; turn off if doomscrolling isn&amp;rsquo;t your thing&lt;/li&gt;
&lt;li&gt;&lt;u&gt;Appearance&lt;/u&gt; &amp;ndash; you can make the fonts smaller or larger, or switch light/dark mode&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 &lt;a href="https://accessibility.huit.harvard.edu/describe-content-images"&gt;alt text on photos&lt;/a&gt; (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; as a bonus, it also makes your posts easier to find in search and feeds)&lt;/li&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 feeds and search results, except your Following timeline. 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;/ul&gt;


&lt;p&gt;Also in the &amp;ldquo;Chat&amp;rdquo; section of the app, there is a separate &lt;u&gt;Chat Settings&lt;/u&gt; section under the &amp;ldquo;cog wheel&amp;rdquo; icon, where you can specify 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;/p&gt;

&lt;hr /&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;If you&amp;rsquo;re a developer and want to help, the app and most of the related services are &lt;a href="http://github.com/bluesky-social/social-app/"&gt;open source&lt;/a&gt;, and the team is happy to accept outside contributions at least for bug fixes and &lt;a href="https://github.com/bluesky-social/social-app/pull/2504"&gt;smaller tweaks&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 – so they basically work like on Twitter. 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;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 – but they&amp;rsquo;re thinking about it.&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;Two-factor authentication&lt;/h4&gt;

&lt;p&gt;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. 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;Disabling reposts per user&lt;/h4&gt;

&lt;p&gt;It&amp;rsquo;s not a hard technical problem, but it seems to be blocked by some other things right now – but it should be added eventually, since everyone is asking about it.&lt;/p&gt;

&lt;h4&gt;Links on the profile, scheduling, &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. (Scheduling can currently be done using some third party clients like &lt;a href="https://deck.blue"&gt;deck.blue&lt;/a&gt;, or cross-posting services like &lt;a href="https://buffer.com"&gt;Buffer&lt;/a&gt;.)&lt;/p&gt;

&lt;hr /&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, user lists, and starter packs that you’ve been added to&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.app/profile/did:plc:yatb2t26fw7u3c7qcacq7rje"&gt;Listifications&lt;/a&gt; &amp;nbsp; sends notifications when you&amp;rsquo;re added to a list or starter pack, or when someone blocks you&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://cred.blue"&gt;cred.blue&lt;/a&gt; &amp;ndash; calculates your &amp;ldquo;Bluesky score&amp;rdquo;&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://internect.info"&gt;internect.info&lt;/a&gt; &amp;ndash; lets you look up the DID of an account, its server name 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; (total count and daily posters), &lt;a href="https://blue.mackuba.eu/stats/"&gt;my stats&lt;/a&gt; (daily/weekly stats), and &lt;a href="https://bskycharts.edavis.dev/edavis.dev/bskycharts.edavis.dev/index.html"&gt;bskycharts&lt;/a&gt; (charts of firehose activity and daily/monthly active users)&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;li&gt;&lt;a href="https://mary-ext.codeberg.page/bluesky-quiet-posters/"&gt;Bluesky quiet posters&lt;/a&gt; – shows the list of people you follow ordered by how long ago they&amp;rsquo;ve posted anything&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cleanfollow-bsky.pages.dev"&gt;Clean follow&lt;/a&gt; – lets you clean up your follows list of blocked and suspended accounts&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky.cam.fyi/unfollow"&gt;Gentle Unfollow&lt;/a&gt; – lets you browse your follows one by one, showing each person&amp;rsquo;s recent posts, and lets you decide if you want to keep them or unfollow&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bsky-follow-finder.theo.io"&gt;Bluesky network analyzer&lt;/a&gt; – analyzes your 2nd degree connections (follows of your follows), and shows you suggestions of people that a lot of your friends are following&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nws-bot.us/bskyListCombiner.php"&gt;nws-bot.us tools&lt;/a&gt; – a set of tools for managing lists and starter packs&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://docs.google.com/document/d/1HVx7prLajuTZ_naAC3WyqJu4loaf2FRxPZSHSUxKsgk/view"&gt;Bluesky Guide&lt;/a&gt; (Amanda Wyatt Visconti, Aug 2023 + updates)&lt;/li&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://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;li&gt;&lt;a href="https://web.archive.org/web/20250321051549/https://goodfeeds.co/the-guide"&gt;The Guide to Feeds&lt;/a&gt; (Jerry Chen – archived)&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://atpota.to/guides/bluesky-for-brands"&gt;How to use Bluesky to grow your brand&lt;/a&gt; (Dame, Jun 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marvins-guide.leaflet.pub/3lyqxqbbqkc2p"&gt;What the hell is the atmosphere anyway: A slightly less technical intro to the technical side of Bluesky&lt;/a&gt; (Bailey Townsend, Sep 2025)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://overreacted.io/open-social/"&gt;Open Social&lt;/a&gt; – a high-level overview of how the AT Protocol brings back the openness of the original pre Web 2.0 web (Dan Abramov, Sep 2025)&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;/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;18 Nov 2025&lt;/strong&gt;: added links to Tokimeki, Catbird, Dhaaga, and Graze, removed link to Skychat&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;26 Sep 2025&lt;/strong&gt;: added links to Bailey Townsend&amp;rsquo;s and Dan Abramov&amp;rsquo;s blog posts&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8 Sep 2025&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;added native bookmarks&lt;/li&gt;
&lt;li&gt;custom GIF upload now works on mobile&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;strong&gt;29 Jul 2025&lt;/strong&gt;: added link to guide by Amanda Wyatt Visconti&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9 Jul 2025&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;added new sections about trending topics, verification badges, and the Atmosphere&lt;/li&gt;
&lt;li&gt;videos have a 3-minute limit now&lt;/li&gt;
&lt;li&gt;mentioned several new photo and video apps, and Tapbots' plans for their Bluesky client&lt;/li&gt;
&lt;li&gt;added links to a few new sites and tools, removed US Politics Labeller, Skybridge and PLC Handle Tracker&lt;/li&gt;
&lt;li&gt;reordered &amp;amp; expanded settings page descriptions&lt;/li&gt;
&lt;li&gt;mentioned Bluesky&amp;rsquo;s subscription plans&lt;/li&gt;
&lt;li&gt;mentioned recent fixes in Discover&lt;/li&gt;
&lt;li&gt;mentioned that subscriptions for account new post notifications are private&lt;/li&gt;
&lt;li&gt;mentioned reporting malicious lists&lt;/li&gt;
&lt;li&gt;mentioned Ozone and country-specific moderation&lt;/li&gt;
&lt;li&gt;added info that .bsky.social handles are now kept reserved after change&lt;/li&gt;
&lt;li&gt;added some tips about hashtags use&lt;/li&gt;
&lt;li&gt;added info about OAuth account management page&lt;/li&gt;
&lt;li&gt;added recommendation of Signal for sensitive DM conversations&lt;/li&gt;
&lt;li&gt;added info about GIF uploads&lt;/li&gt;
&lt;/ul&gt;


&lt;p&gt;&lt;strong&gt;12 Nov 2024&lt;/strong&gt;: added info about threads composer and the plans for disabling reposts&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8 Nov 2024&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;added info or mentions of new features: videos, starter packs, pinned posts, listing quotes, timed word muting, new safety features, and OAuth&lt;/li&gt;
&lt;li&gt;added a list of popular labellers&lt;/li&gt;
&lt;li&gt;added a mention of handle services&lt;/li&gt;
&lt;li&gt;expanded the &amp;ldquo;how are things called&amp;rdquo; section a bit&lt;/li&gt;
&lt;li&gt;updated section about feeds, added a list of recommended feeds&lt;/li&gt;
&lt;li&gt;updated section about federation, added info about Bridgy Fed&lt;/li&gt;
&lt;li&gt;updated info about Jack Dorsey&amp;rsquo;s involvement&lt;/li&gt;
&lt;/ul&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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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?1777132788" 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/notes/wwdc22/swiftui-cookbook-for-navigation/</id>
    <title>The SwiftUI cookbook for navigation</title>
    <published>2022-07-03T20:54:35Z</published>
    <updated>2022-07-03T20:54:35Z</updated>
    <link href="https://mackuba.eu/notes/wwdc22/swiftui-cookbook-for-navigation/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;This year SwiftUI adds new APIs for handling navigation which scale well from simple to complex UIs, include support for programmatic navigation and deep linking&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;The existing API&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The existing API is based on links (&lt;code&gt;NavigationLink&lt;/code&gt;) embedded inside &lt;code&gt;NavigationView&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You have a list of navigation link buttons, each specifying the view it links to, and when the button is tapped, the specified view is pushed onto the stack&lt;/p&gt;
&lt;p&gt;This works great for basic navigation, and you can continue using this pattern with the new API&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To programmatically perform navigation, you could use the &lt;code&gt;NavigationView&lt;/code&gt; initializer that binds the state of the navigation link to a variable (using &lt;code&gt;isActive&lt;/code&gt; or &lt;code&gt;selection&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;However, this only worked well in simple cases and didn't let you easily do things like deep-linking deep into the navigation hierarchy straight from the root, or moving back to the root from there&lt;/p&gt;
&lt;p&gt;It also required you to keep separate bindings for each link, or at least each level of the hierarchy&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;The new navigation APIs&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The new API lets you bind the state of the whole navigation stack to a single array managed by the root container&lt;/p&gt;
&lt;p&gt;Navigation links push additional values into that array when they're selected&lt;/p&gt;
&lt;p&gt;This allows you to quickly move through the hierarchy by modifying that array binding, e.g. to deep-link to a specific view by assigning a collection of values to it, or to pop the stack to the root view immediately by removing all values&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Navigation containers:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The new API consists of a couple of new container views and a new version of the navigation link&lt;/p&gt;
&lt;p&gt;The first container is &lt;code&gt;NavigationStack&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationStack(path: $path) {
    RecipeDetail()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NavigationStack&lt;/code&gt; is used for simple push-pop navigation happening in a single container, like in iPhone apps&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The second container is &lt;code&gt;NavigationSplitView&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationSplitView {
    RecipeCategories()
} detail: {
    RecipeGrid()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NavigationSplitView&lt;/code&gt; is perfect for multi-column apps like Mail or Notes on the Mac or iPad&lt;/p&gt;
&lt;p&gt;It automatically adapts to a single-column view in contexts like iPhone, Apple TV or slide-over on the iPad&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To create a three-column layout, use another version of the initializer:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationSplitView {
    RecipeCategories()
} content: {
    RecipeList()
} detail: {
    RecipeDetail()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NavigationSplitView&lt;/code&gt; has plenty of options that let you customize things like column widths or sidebar presentation, or even programmatically show or hide columns&lt;/p&gt;
&lt;p&gt;You can hear more about configuring a layout like this in the talk &lt;a href="https://developer.apple.com/videos/play/wwdc2022/10058/"&gt;SwiftUI on iPad: Organize your interface&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Navigation links:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Previously, a navigation link included a title and a destination view to be presented:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationLink("Show detail") {
    DetailView()
}

NavigationLink("Show detail", destination: DetailView())

NavigationLink(destination: DetailView()) {
    Text(title)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The new version of &lt;code&gt;NavigationLink&lt;/code&gt; still includes a title, but instead of a view to present, it provides a value associated with the link:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationLink("Apple Pie", value: applePieRecipe)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Clicking the link pushes that value into its container's path array&lt;/p&gt;
&lt;p&gt;However, the exact view that's pushed when the link is clicked is determined by the navigation stack that the link is contained in&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Navigation patterns&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Let's now look at the ways you can use these views to build navigation in your apps:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;1. Basic stack:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The first pattern is a basic navigation stack like in the Settings app on the iPhone or Apple Watch&lt;/p&gt;
&lt;p&gt;The main view presents a list of items, and when an item is selected, it pushes a detail view on the stack which fills the whole screen&lt;/p&gt;
&lt;p&gt;This approach works on all platforms, but it's best suited for iPhone, Apple TV and Apple Watch&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In the basic form, you can just replace the &lt;code&gt;NavigationView&lt;/code&gt; with &lt;code&gt;NavigationStack&lt;/code&gt; and use &lt;code&gt;NavigationLinks&lt;/code&gt; like before, with the destination view inline:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var body: some View {
    NavigationStack {
        List(Category.allCases) { category in
            Section(category.localizedName) {
                ForEach(dataModel.recipes(in: category)) { recipe in
                    NavigationLink(recipe.name) {
                        RecipeDetail(recipe: recipe)
                    }
                }
            }
        }
        .navigationTitle("Categories")
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To be able to perform navigation programmatically, assign a value to the navigation link instead:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ForEach(dataModel.recipes(in: category)) { recipe in
    NavigationLink(recipe.name, value: recipe)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;And the destination view definition is then pulled out of the link block and moved to a new &lt;code&gt;.navigationDestination&lt;/code&gt; modifier defined on the &lt;code&gt;NavigationStack&lt;/code&gt; container:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.navigationDestination(for: Recipe.self) { recipe in
    RecipeDetail(recipe: recipe)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The modifier describes what view should be pushed onto the stack when a given value is pushed into container's path by any of the navigation links anywhere in the view hierarchy (or programmatically), for all values of the given type&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The navigation stack's path starts out initially as an empty array &lt;code&gt;[]&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;When the navigation link is clicked:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;1. It appends the assigned value of &lt;code&gt;Recipe&lt;/code&gt; type into the path&lt;/li&gt;
&lt;li&gt;2. The navigation stack then checks the &lt;code&gt;navigationDestination&lt;/code&gt; configuration which stores a mapping of &lt;code&gt;Recipe -&amp;gt; some View&lt;/code&gt; to get an appropriate view to present&lt;/li&gt;
&lt;li&gt;3. The new view is pushed onto the view stack&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;When you press the "Back" button, the last item is removed from the path array and the top view on the stack is popped&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You also need to create a binding of the navigation stack's path to a local state variable:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@State private var path: [Recipe] = []&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p class="arrow"&gt;→ you can use an array of a specific type if you only use values of one type in the path; if you want to build mixed paths of multiple types, use the new &lt;code&gt;NavigationPath&lt;/code&gt; wrapper type instead&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Pass a binding to &lt;code&gt;NavigationStack&lt;/code&gt;'s initializer:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var body: some View {
    NavigationStack(path: $path) {
        ...
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now modify the view stack by assigning to the state variable:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func showRecipeOfTheDay() {
    path = [dataModel.recipeOfTheDay]
}

func popToRoot() {
    path.removeAll()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can see the &lt;code&gt;NavigationStack&lt;/code&gt; in action in the talk &lt;a href="https://developers.apple.com/videos/play/wwdc2022/10133"&gt;Build a productivity app for Apple Watch&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;2. Multi-column presentation without stacks:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This interface is similar to the Mail app on the Mac and iPad&lt;/p&gt;
&lt;p&gt;The layout consists of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a sidebar, initially hidden, which shows a list of categories&lt;/li&gt;
&lt;li&gt;the second column that shows a list of recipes&lt;/li&gt;
&lt;li&gt;the main area that shows the recipe details&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This interface is great on larger devices like the iPad and Mac&lt;/p&gt;
&lt;p&gt;It allows you to see more information at the same time on the screen&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In the first column (sidebar), we add a list of navigation links for each category of recipes:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@State private var selectedCategory: Category?

var body: some View {
    NavigationSplitView {
        List(Category.allCases, selection: $selectedCategory) { category in
            NavigationLink(category.localizedName, value: category)
        }
        .navigationTitle("Categories")
    } content: {
        // ... second column ...
    } detail: {
        // ... detail view ...
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Instead of binding a property to the navigation view's path like before, here we bind it instead to the selected item in the list, on the root level only&lt;/p&gt;
&lt;p&gt;When a &lt;code&gt;NavigationLink&lt;/code&gt; is contained within a list with the same selection type as the navigation link's value, the link will automatically update the selection of the list when clicked&lt;/p&gt;
&lt;p class="arrow"&gt;→ see more about this in the &lt;a href="https://developer.apple.com/videos/play/wwdc2022/10058/"&gt;SwiftUI on iPad: Organize your interface&lt;/a&gt; talk&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In the second column (content), we show the same kind of list, listing recipes in the selected category, and again, we use &lt;code&gt;NavigationLinks&lt;/code&gt; inside with assigned values of the same type as the list's selection binding&lt;/p&gt;
&lt;p&gt;When a recipe is selected in the second column, the recipe details will be shown in the main area:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?

var body: some View {
    NavigationSplitView {
        // ...
    } content: {
        List(dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in
            NavigationLink(recipe.name, value: recipe)
        }
        .navigationTitle(selectedCategory?.localizedName ?? "Recipes")
    } detail: {
        RecipeDetail(recipe: selectedRecipe)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Note: unlike &lt;code&gt;NavigationStack&lt;/code&gt;, &lt;code&gt;NavigationSplitView&lt;/code&gt; *does not* keep a path property with a list of selected items that you can bind to&amp;nbsp;– the navigation on this level is managed only through the selection bindings of the two lists:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func showRecipeOfTheDay() {
    let recipe = dataModel.recipeOfTheDay

    selectedCategory = recipe.category
    selectedRecipe = recipe
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NavigationSplitView&lt;/code&gt; also does not use the &lt;code&gt;.navigationDestination&lt;/code&gt; modifier&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In environments that don't support multi-pane views (iOS, tvOS and iPad apps in split view or slide-over), &lt;code&gt;NavigationSplitView&lt;/code&gt; renders as a single-pane &lt;code&gt;NavigationStack&lt;/code&gt; which pushes the selected views onto its stack&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;3. Putting the two modes together&amp;nbsp;– a multi-column view with a stack:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Here, we're building a two-column layout like in the Photos app on the iPad and Mac:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the left column (sidebar) shows a list of categories&lt;/li&gt;
&lt;li&gt;the main area will show recipe photos in a grid&lt;/li&gt;
&lt;li&gt;when a recipe is selected, a recipe details view will be pushed on the navigation stack within the detail area&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To build this kind of layout, we use &lt;code&gt;NavigationSplitView&lt;/code&gt; and &lt;code&gt;NavigationStack&lt;/code&gt; together:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;1. The first column displays a list like in the three-pane version&lt;/li&gt;
&lt;li&gt;2. The detail area contains a navigation stack like in the first example&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;We track the state of the navigation using two variables: a list selection binding for the split view's first column, and a path of values for the stack view inside the second column:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@State private var selectedCategory: Category?
@State private var path: [Recipe] = []

struct ContentView: {
    var body: some View {
        NavigationSplitView {
            List(Category.allCases, selection: $selectedCategory) { category in
                NavigationLink(category.localizedName, value: category)
            }
            .navigationTitle("Categories")
        } detail: {
            NavigationStack(path: $path) {
                RecipeGrid(category: selectedCategory)
            }
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The detail view displays a grid of navigation buttons wrapping image tiles; when a tile is pressed, the detail screen for a recipe is pushed onto the stack within the detail pane, which is defined using &lt;code&gt;NavigationLinks&lt;/code&gt; and the &lt;code&gt;.navigationDestination&lt;/code&gt; modifier:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;struct RecipeGrid: View {
    var category: Category?

    var body: some View {
        if let category = category {
            ScrollView {
                LazyVGrid(columns: columns) {
                    ForEach(dataModel.recipes(in: category)) { recipe in
                        NavigationLink(value: recipe) {
                            RecipeTile(recipe: recipe)
                        }
                    }
                }
            }
            .navigationTitle(category.name)
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetail(recipe: recipe)
            }
        } else {
            Text("Select a category")
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To update navigation programmatically, assign the two bound variables to modify selection in the two panes:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func showRecipeOfTheDay() {
    let recipe = dataModel.recipeOfTheDay

    selectedCategory = recipe.category
    path = [recipe]
}&lt;/pre&gt;
&lt;h3&gt;Persisting navigation state&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Navigation state can be persisted between launches with the use of &lt;code&gt;Codable&lt;/code&gt; and &lt;code&gt;@SceneStorage&lt;/code&gt; properties&lt;/p&gt;
&lt;p&gt;We'll wrap the navigation state properties into a single model type, so that they can be persisted together to keep the state consistent&lt;/p&gt;
&lt;p&gt;This model type will conform to &lt;code&gt;Codable&lt;/code&gt; to let us save &amp;amp; restore it&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@SceneStorage&lt;/code&gt; will be used to automatically persist it together with the scene&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Looking at the last example (with both &lt;code&gt;NavigationSplitView&lt;/code&gt; and &lt;code&gt;NavigationStack&lt;/code&gt;), here's how we update it to bind the UI to the properties in the new model type:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@StateObject private var navModel = NavigationModel()

var body: some View {
    NavigationSplitView {
        List(Category.allCases, selection: $navModel.selectedCategory) { category in
            NavigationLink(category.localizedName, value: category)
        }
        .navigationTitle("Categories")
    } detail: {
        NavigationStack(path: $navModel.path) {
            RecipeGrid(category: navModel.selectedCategory)
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;One thing to note about the &lt;code&gt;NavigationModel&lt;/code&gt;: the values used for navigation &amp;amp; list selection will often be your model data types, i.e. whole structs like &lt;code&gt;Recipe&lt;/code&gt; here, but we don't want to store the whole structs with all their properties as a part of the navigation path&amp;nbsp;– it would be redundant, because we probably have all the recipe data stored somewhere else in some kind of database.&lt;/p&gt;
&lt;p&gt;To store the navigation state, we only need some kind of references to them, like their IDs&amp;nbsp;– so we're going to customize the &lt;code&gt;Codable&lt;/code&gt; conformance here to save the recipes in the path as their ID values and restore them back from that on load:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;class NavigationModel: ObservableObject, Codable {
    @Published var selectedCategory: Category?
    @Published var path: [Recipe] = []

    enum CodingKeys: String, CodingKey {
        case selectedCategory
        case recipePathIds
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
        try container.encode(path.map(\.id), forKey: .recipePathIds)
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.selectedCategory = try container.decodeIfPresent(Category.self,
                                        forKey: selectedCategory)

        let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)

        // some recipes might have been deleted in the meantime, so skip those
        self.path = recipePathIds.compactMap { DataModel.shared[$0] }
    }

    // helper to serialize the model to/from JSON Data
    var jsonData: Data? {
        get { ... }
        set { ... }
    }

    // async sequence of updates (from the sample code project)
    var objectWillChangeSequence: AsyncPublisher&amp;lt;Publishers.Buffer&amp;lt;ObservableObjectPublisher&amp;gt;&amp;gt; {
        objectWillChange
            .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest)
            .values
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The one missing piece is persisting the model using &lt;code&gt;@SceneStorage&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Since &lt;code&gt;SceneStorage&lt;/code&gt; can only hold values of simple types like &lt;code&gt;URL&lt;/code&gt;, &lt;code&gt;String&lt;/code&gt; or &lt;code&gt;Data&lt;/code&gt;, and not complex object types, we need to manually restore the model from the persisted form and make sure it's kept in sync after every change; &lt;code&gt;SceneStorage&lt;/code&gt; will handle the persistence of the encoded value for us, but we need to handle the conversion to/from it&lt;/p&gt;
&lt;p&gt;We do this by providing a &lt;code&gt;.task&lt;/code&gt; that will run when the view is displayed, which loads the model from the data&lt;/p&gt;
&lt;p&gt;The task then subscribes to a sequence of changes from the model and encodes the model into storage every time it changes:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?

var body: some View {
    NavigationSplitView {
        // ...
    }
    .task {
        if let data {
            navModel.jsonData = data
        }
        for await _ in navModel.objectWillChangeSequence {
            data = navModel.jsonData
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;For more details and examples about the new APIs, check out the "&lt;a href="https://developer.apple.com/documentation/swiftui/migrating-to-new-navigation-types"&gt;Migrating to new navigation types&lt;/a&gt;" documentation article&lt;/p&gt;
&lt;p&gt;See also "&lt;a href="https://developer.apple.com/videos/play/wwdc2022/10061/"&gt;Bring multiple windows to your SwiftUI app&lt;/a&gt;" for info about opening new windows and scenes&lt;/p&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc22/whats-new-in-appkit/</id>
    <title>What's new in AppKit</title>
    <published>2022-06-08T23:57:53Z</published>
    <updated>2022-06-08T23:57:53Z</updated>
    <link href="https://mackuba.eu/notes/wwdc22/whats-new-in-appkit/"/>
    <content type="html">&lt;h3&gt;Stage Manager&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;New UI workflow that cleans up inactive windows in your workspace, while your active window takes center stage, letting you put the focus on one task at a time&lt;/p&gt;
&lt;p&gt;You can also pull windows into sets that are swapped in and out as a group&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This has an impact on how your app's windows present themselves:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;newly presented windows replace those on the stage to keep the workspace tidy&lt;/li&gt;
&lt;li&gt;auxiliary windows like panels, popovers, settings windows cohabitate with primary windows&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This should mostly work as expected using existing &lt;code&gt;NSWindow&lt;/code&gt; APIs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Stage Manager won't swap out a window if it's a floating panel, a modal window, or a window with &lt;code&gt;toolbarStyle&lt;/code&gt; set to &lt;code&gt;.preference&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;it also obeys the window's &lt;code&gt;collectionBehavior&lt;/code&gt; flags (an old API from OS X 10.5-10.7 which mostly defines how a window behaves within desktop spaces, Exposé and full screen mode): a window will not displace an active window in center stage if it includes the &lt;code&gt;.auxiliary&lt;/code&gt;, &lt;code&gt;.moveToActiveSpace&lt;/code&gt;, &lt;code&gt;.stationary&lt;/code&gt; or &lt;code&gt;.transient&lt;/code&gt; collection behavior flags&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Preferences&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The System Preferences app now has a whole new look, with a different navigation and new design&lt;/p&gt;
&lt;p&gt;To align with Settings apps on other platforms, it's been renamed to System Settings&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you're shipping a custom preference pane bundle, it will continue to work in the new Settings app&lt;/p&gt;
&lt;p&gt;Your custom pane will appear in the Settings app sidebar and will be loaded on first access, like in Monterey and earlier versions&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;In-app settings:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To match the newly renamed System Settings app, your app's Preferences menu entry in the app's main menu is now renamed to "Settings…"&lt;/p&gt;
&lt;p&gt;The menu item's label is automatically updated if you build the app with the latest SDK&lt;/p&gt;
&lt;p&gt;Check your app for any other uses of the word "Preferences" in the UI referring to the preferences window and update them accordingly&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;New form design:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is a new interface style for control-rich forms like the views commonly used in settings windows&lt;/p&gt;
&lt;p&gt;It's designed to present an interface that displays a lot of controls in a clear, organized way&lt;/p&gt;
&lt;p&gt;In this kind of UI, the form provides a lot of visual structure, so many system controls (like popup buttons) automatically adapt to this context by drawing normally with a lower visual weight and only revealing a heavier border or background on hover&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can build a form like this using the SwiftUI &lt;code&gt;Form&lt;/code&gt; element with an &lt;code&gt;.insetGrouped&lt;/code&gt; style:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Form {
    TextField("Computer Name", text: $name)
    Toggle("Screen Sharing", isOn: $screenSharing)
    Toggle("File Sharing", isOn: $fileSharing)
    Picker("AirDrop", selection: $airdrop) {
        ForEach(AirDropVisibility.allCases) {
            Text($0.label).tag($0)
        }
    }
}
.formStyle(.insetGrouped)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This renders &lt;code&gt;Picker&lt;/code&gt; as a borderless popup button, &lt;code&gt;TextField&lt;/code&gt; as a borderless inline text field, and &lt;code&gt;Toggle&lt;/code&gt; as tiny switches&lt;/p&gt;
&lt;p&gt;SwiftUI automatically handles the visual style, layout and the scrolling behavior of such form&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you aren't using SwiftUI yet, then… well… this is a good moment to start 😅&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Control updates&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;NSComboButton:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A combo button is a new control&amp;nbsp;– a button which provides an immediate action and a menu for additional options&lt;/p&gt;
&lt;p&gt;It combines a standard button and a pull-down menu into a single control&lt;/p&gt;
&lt;p&gt;This design is commonly used for use cases like a "move to folder" button in Mail, which moves an email to the pre-selected folder if you click the main left part of the button (the primary action), but also lets you pick an alternative location from the menu if you click the arrow on the right side&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are two styles of the combo button which change its appearance and behavior:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;split style (the default), with a separator between the main button part and the arrow part&lt;/li&gt;
&lt;li&gt;unified style&amp;nbsp;– looks like a normal button, performs the primary action on click, and only presents the menu if you click and hold&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;NSColorWell:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NSColorWell&lt;/code&gt; has a new default design, with a white border and rounded corners&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are also two optional new styles you can pick:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;1) A "minimal" style:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;no borders, filled completely with color&lt;/li&gt;
&lt;li&gt;a disclosure arrow appears on hover&lt;/li&gt;
&lt;li&gt;it shows a color selection UI in a popover&amp;nbsp;– by default a standard system grid of colors, but you can customize what's shown there&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;2) An "expanded" style:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;used in iWork apps previously&lt;/li&gt;
&lt;li&gt;combines both interaction models: a borderless minimal style on the left, with a disclosure arrow and a popover, and a button part which shows the full color picker on the right&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Select the style using the &lt;code&gt;colorWellStyle&lt;/code&gt; property&lt;/p&gt;
&lt;p&gt;The behavior of the popover in "minimal" and "expanded" modes is configured through a new target-action pair: &lt;code&gt;pulldownTarget&lt;/code&gt; + &lt;code&gt;pulldownAction&lt;/code&gt; (if nil, then a standard system popover is used)&lt;/p&gt;
&lt;p class="arrow"&gt;→ you can use this to present a custom popover, or a completely different UI like a menu&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Toolbars:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are two new delegate methods of &lt;code&gt;NSToolbar&lt;/code&gt; that let you customize the toolbar behavior:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;1) &lt;code&gt;toolbarImmovableItemIdentifiers(_:)&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;defines a list of toolbar items that the user shouldn't be able to move or remove (they also don't animate when you enter the toolbar editing view)&lt;/li&gt;
&lt;li&gt;used e.g. in Mail app for a filter button that should always appear above the messages list&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;2) &lt;code&gt;toolbar(_:, itemIdentifier:, canBeInsertedAt:)&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;allows you to manually approve any reordering, insertion or removal of a toolbar item&lt;/li&gt;
&lt;li&gt;use this to implement a custom set of toolbar customization rules, e.g. an item that has to stay within one toolbar section&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Centered toolbar section:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a new property &lt;code&gt;centeredItemIdentifiers&lt;/code&gt; allows you to specify multiple centered items, which have to stay within the toolbar's center section&lt;/li&gt;
&lt;li&gt;previously there could be only one centered item, specified using &lt;code&gt;centeredItemIdentifier&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Preventing item resizing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;if a toolbar item changes its label depending on the state (e.g. "Mute / Unmute" in Mail app), it may have to change its size and cause other nearby buttons to shift back and forth when switching&lt;/li&gt;
&lt;li&gt;to prevent this, you can now list all possible labels in the &lt;code&gt;possibleLabels&lt;/code&gt; property of an &lt;code&gt;NSToolbarItem&lt;/code&gt;; the toolbar will automatically position items according to their longest possible label so that they don't have to be resized&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Alerts design:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is a slight update to the design of alerts&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Alerts in macOS Big Sur and above have a new, compact layout with centered text, which is optimized for small amounts of text accompanied by a few clear choices&lt;/p&gt;
&lt;p&gt;In general, this is how an alert should look: alerts work best with shorter text, which your users are more likely to read before dismissing the dialog&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;However, sometimes you really can't make the description shorter, especially when the alert is about something very important like deleting a disk volume&lt;/p&gt;
&lt;p&gt;For these cases, there is now a new expanded look of alerts which is more optimal for longer text&lt;/p&gt;
&lt;p&gt;The expanded style uses a wider window, horizontally arranged buttons, and left-aligned text&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is no option to enable this&amp;nbsp;– expanded style is used automatically when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the description text is too long to fit comfortably in the compact size&lt;/li&gt;
&lt;li&gt;or if the alert has an accessory view that's too large to fit in a compact alert&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Note: the layout variant is chosen at the time when the alert is presented, so the alert is not resized if the text is changed after it's displayed&lt;/p&gt;
&lt;p&gt;You should still aim to reduce the length of the alert text whenever possible, but this should improve the UI for those cases when you can't do that&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;NSTableView performance updates:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NSTableView&lt;/code&gt; is designed to efficiently handle a very large number of rows&lt;/p&gt;
&lt;p&gt;It does this by lazily populating and reusing views as the table is scrolled&lt;/p&gt;
&lt;p&gt;This becomes a challenge when the views have different heights, because it needs to calculate the total height of the table and the position of each row&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Previously, &lt;code&gt;NSTableView&lt;/code&gt; did this be pre-calculating the size of each row in the table, which impacts the initial load time&lt;/p&gt;
&lt;p&gt;In macOS Ventura &lt;code&gt;NSTableView&lt;/code&gt; now calculates the row heights lazily:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;row heights are calculated for rows which are in or near the scrolling viewport&lt;/li&gt;
&lt;li&gt;for the rest of the rows, &lt;code&gt;NSTableView&lt;/code&gt; estimates the height based on the row heights that it has already measured&lt;/li&gt;
&lt;li&gt;as the table is scrolled, the table view requests more row heights as needed and replaces the estimates with real measurements&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This optimization significantly improves load times for very large tables&lt;/p&gt;
&lt;p&gt;It's automatically used for all apps on macOS Ventura and doesn't require any changes&lt;/p&gt;
&lt;p&gt;Note that this might affect the timing of delegate callbacks like &lt;code&gt;tableView(_:, heightOfRow:)&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;SF Symbols&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;SF Symbols 4 adds more than 450 new symbols, including things like new currency symbols, household items or sports objects&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;SF Symbols can be used with one of 4 styles: monochrome, hierarchical, palette and multicolor&lt;/p&gt;
&lt;p&gt;In macOS Ventura, symbols may now specify a "preferred rendering mode"&amp;nbsp;– one of these styles that is preferred for this specific symbol and is used automatically if not configured otherwise&lt;/p&gt;
&lt;p&gt;You can still use &lt;code&gt;NSImageSymbolConfiguration&lt;/code&gt; to override the preferred style&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Variable symbols:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is a new type of symbol which represents some value or quantity, like WiFi signal strength or volume&lt;/p&gt;
&lt;p&gt;These symbols have several versions depending on the value, with a smaller or larger part of the symbol filled or colored&lt;/p&gt;
&lt;p&gt;You configure these symbols by providing a floating point value between 0 and 1:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;class NSImage {
    public init?(symbolName: String,
                 variableValue: Double,
                 accessibilityDescription: String?)

    public init?(systemSymbolName: String,
                 variableValue: Double,
                 accessibilityDescription: String?)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;If the symbol doesn't define any thresholds depending on the value, the value is ignored&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Sharing&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;macOS Ventura includes a new redesigned share sheet, which now looks similar to the one on iOS&lt;/p&gt;
&lt;p&gt;It includes a list of suggested people to share with, like the iOS one&lt;/p&gt;
&lt;p&gt;It also adds new APIs which apps can use to let the user invite their contacts to collaborate on a document&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The share sheet is accessed using the existing API &lt;code&gt;NSSharingServicePicker&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You can still filter the list of services and add custom ones, use existing delegate callbacks etc.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you share a file URL, the sheet will automatically display the file name, type, size and icon in the header&lt;/p&gt;
&lt;p&gt;If you're sharing a custom item, you can manually provide details to be displayed in the header using a new protocol &lt;code&gt;NSPreviewRepresentableActivityItem&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;protocol NSPreviewRepresentableActivityItem: AnyObject {
    /* The item to be shared */
    var item: Any { get }

    /* A localized string representing the item's name or title */
    optional var title: String? { get }

    /* A provider for a full-sized image that represents the item */
    optional var imageProvider: NSItemProvider? { get }

    /* A provider for a thumbnail-sized icon that represents the item */
    optional var iconProvider: NSItemProvider? { get }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;or the helper class &lt;code&gt;NSPreviewRepresentingActivityItem&lt;/code&gt; which implements the protocol:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;class NSPreviewRepresentingActivityItem: NSPreviewRepresentableActivityItem {
    init(item: Any, title: String?, image: NSImage?, icon: NSImage?)
    init(item: Any, title: String?, imageProvider: NSItemProvider?, iconProvider: NSItemProvider?)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Use the preview class if you already have all the data you need, and the protocol with &lt;code&gt;NSItemProviders&lt;/code&gt; if it's too performance-intensive to generate it up front&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you want to launch the share sheet from a menu, like the main menu bar or a context menu when clicking a collection view item, the share sheet API now provides a standard "Share…" menu item that you can put in any menu to display the sheet:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;class NSSharingServicePicker {
    var standardShareMenuItem: NSMenuItem
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In case of context menus, the displayed popover will be anchored to the same view on which the context menu was shown&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There's a lot more new features for managing collaboration&lt;/p&gt;
&lt;p&gt;See more in separate talks: "&lt;a href="https://developer.apple.com/videos/play/wwdc2022/10095/"&gt;Enhance collaboration experiences with Messages&lt;/a&gt;" and "&lt;a href="https://developer.apple.com/videos/play/wwdc2022/10093/"&gt;Integrate your custom collaboration app with Messages&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/add-rich-graphics-to-swiftui-app/</id>
    <title>Add rich graphics to your SwiftUI app</title>
    <published>2022-06-04T22:50:55Z</published>
    <updated>2022-06-04T22:50:55Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/add-rich-graphics-to-swiftui-app/"/>
    <content type="html">&lt;h3&gt;Safe area&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;By default SwiftUI positions your content within the safe area, so that it doesn't get obscured by things at the top and the bottom of the screen, like the navigation bar and the home bar on Face ID phones&lt;/p&gt;
&lt;p&gt;If you do want the view to extend to the whole screen, edge to edge, below the top/bottom bars (e.g. on screens that show some kind of graphics), you can opt out of the safe area using &lt;code&gt;.ignoresSafeArea&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ContentView()
    .ignoresSafeArea()&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also choose on which sides you want to extend the view into the safe area:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ContentView()
    .ignoresSafeArea(edges: .bottom)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You don't need to do this on most screens, since most content should be kept within the safe area&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The keyboard, if visible, is also outside the safe area&amp;nbsp;– it actually has its own inner safe area that's positioned inside the main "container safe area"&lt;/p&gt;
&lt;p&gt;By default your view is positioned within the keyboard's safe area, so that it doesn't get covered by the keyboard&amp;nbsp;– to opt out just of the keyboard safe area (but stay within container safe area), add the additional &lt;code&gt;.keyboard&lt;/code&gt; parameter:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ContentView()
    .ignoresSafeArea(.keyboard)&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Backgrounds&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are now new versions of the &lt;code&gt;.background&lt;/code&gt; modifier where if you don't pass any specific color or style, it uses the default screen background (white or black, depending on light/dark mode):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;HStack {
    ...
}
.background()&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;These backgrounds (and backgrounds like &lt;code&gt;.background(Color.green)&lt;/code&gt; now automatically extend below the safe area, unless you customize it&lt;/p&gt;
&lt;p&gt;You can also specify that the background should only be used within a defined shape around the view, like a rounded rectangle:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;HStack {
    ...
}
.background(.cyan, in: RoundedRectangle(cornerRadius: 12))&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In this case, the view and its entire background area both stay within the safe area&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Materials and vibrancy&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Materials are a new kind of backgrounds&amp;nbsp;– the kind of translucent blur background used in various system areas:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.background(.regularMaterial)
.background(.thickMaterial, in: RoundedRectangle(cornerRadius: 12))&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Materials are great if you want to show some of the colorful content positioned below some kind of panel bleeding into its background&lt;/p&gt;
&lt;p&gt;There's a set of materials: &lt;code&gt;.ultraThin&lt;/code&gt;, &lt;code&gt;.thin&lt;/code&gt;, &lt;code&gt;.regular&lt;/code&gt;, &lt;code&gt;.thick&lt;/code&gt;, &lt;code&gt;.ultraThick&lt;/code&gt;&amp;nbsp;– the "thinner" the material, the more of the color from behind comes through&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Some of the content displayed over a material background (using foreground colors of "secondary" and below) uses an effect called &lt;em&gt;"vibrancy"&lt;/em&gt;&amp;nbsp;– it blends the color of the text with the material background behind it in a specific way that makes it more readable&lt;/p&gt;
&lt;p&gt;To apply the vibrancy effect, use the new &lt;code&gt;.foregroundStyle&lt;/code&gt; modifier instead of &lt;code&gt;.foregroundColor&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;HStack {
    Text("\(stops.count) colors")
        .foregroundStyle(.secondary)
}
.background(.regularMaterial)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can use &lt;code&gt;.foregroundStyle&lt;/code&gt; with hierarchical styles like &lt;code&gt;.secondary&lt;/code&gt; and with actual colors (or even gradients), and you can even use both together, in which case SwiftUI applies some alpha effect to the selected color:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;VStack {
    Text("Primary").foregroundStyle(.primary)
    Text("Secondary").foregroundStyle(.secondary)
    Text("Tertiary").foregroundStyle(.tertiary)
    Text("Quaternary").foregroundStyle(.quaternary)
}
.foregroundStyle(.purple)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Use &lt;code&gt;.foregroundColor&lt;/code&gt; if you want to opt out of vibrancy for a specific view and use an unmodified color&lt;/p&gt;
&lt;p&gt;What's more, a &lt;code&gt;Text&lt;/code&gt; can even present an attributed string that has multiple different colors applied to different ranges and use a vibrancy effect for the parts that don't have a specific color set (it can only have one single &lt;code&gt;foregroundStyle&lt;/code&gt; though)&lt;/p&gt;
&lt;p&gt;You can embed one &lt;code&gt;Text&lt;/code&gt; inside another to construct a &lt;code&gt;Text&lt;/code&gt; with multiple colors in different ranges:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Text("\(stops.count) \(Text("colors").foregroundColor(.red))")
    .foregroundStyle(.secondary)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;It's ok to use &lt;code&gt;.foregroundStyle&lt;/code&gt; on non-material backgrounds&amp;nbsp;– the vibrancy effect is automatically disabled unless there is an appropriate background behind the text&lt;/p&gt;
&lt;p&gt;Text views also automatically disable vibrancy for any embedded emoji&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can read more about vibrancy and materials in my old blog post about the introduction of dark mode in macOS Mojave&amp;nbsp;– "&lt;a href="/2018/07/04/dark-side-mac-1/"&gt;Dark Side of the Mac: Appearance &amp;amp; Materials&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Safe area inset&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is a new view modifier &lt;code&gt;.safeAreaInset&lt;/code&gt; that allows you to position a view on top of a &lt;code&gt;ScrollView&lt;/code&gt; in a way that adjusts the scroll view's top/bottom content insets so that all the content can be reached and the beginning/end isn't obscured by the overlay&lt;/p&gt;
&lt;p&gt;This is especially useful for views using the blur material background, since you want them to be z-stacked with some content on a layer below to show a hint of the colors behind it, but you want all the content in the view below to be accessible by scrolling it above the overlay&lt;/p&gt;
&lt;p&gt;To position a view this way, put the whole overlay view inside the &lt;code&gt;.safeAreaInset&lt;/code&gt; closure:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;List {
    ...
}
.safeAreaInset(edge: .bottom) {
    HStack {
        Text("New gradient")
        Spacer()
        Text("\(stops.count) colors")
            .foregroundStyle(.secondary)    
    }
    .padding()
    .background(.thinMaterial)
}&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Canvas&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;When drawing a large number of graphical elements in a shared area, we can use a &lt;code&gt;ZStack&lt;/code&gt; and the &lt;code&gt;.drawingGroup&lt;/code&gt; modifier:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var body: some View {
    GeometryReader { proxy in
        ZStack {
            ForEach(state.entries.indices, id: \.self) { index in
                let entry = state.entries[index]
                entry.symbol
                    .scaleEffect(...)
                    .position(...)
                    .onTapGesture {
                        withAnimation { ... }
                    }
            }
        }
    }
    .drawingGroup()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;A &lt;code&gt;.drawingGroup&lt;/code&gt; tells SwiftUI to combine all the views it contains on a single layer to improve performance&lt;/p&gt;
&lt;p&gt;This can be used for graphical elements like images, but not for UI controls like text fields or lists&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Using &lt;code&gt;.drawingGroup&lt;/code&gt; still allows you to do things like setting accessibility properties or applying gestures to each element individually&lt;/p&gt;
&lt;p&gt;However, keeping each view's separate identity adds a bit of overhead&lt;/p&gt;
&lt;p&gt;When you draw a really large amount of elements and you need all the performance you can get, you can now use the new &lt;code&gt;Canvas&lt;/code&gt; element&lt;/p&gt;
&lt;p class="arrow"&gt;→ the tradeoff is that you can't attach gestures to individual drawings anymore&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A &lt;code&gt;Canvas&lt;/code&gt; is given a closure that's run every time the canvas is redrawn&lt;/p&gt;
&lt;p&gt;This is a normal, imperative code closure, not a view builder&lt;/p&gt;
&lt;p&gt;It works similarly to &lt;code&gt;drawRect&lt;/code&gt; in AppKit or UIKit&amp;nbsp;– the canvas gives you a context object that you can run some draw commands on:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Canvas { context, size in
    let image = Image(systemName: "sparkle")

    for i in 0..&amp;lt;10 {
        context.draw(image, at: CGPoint(
            x: 0.5 * size.width + Double(i) * 10,
            y: 0.5 * size.height
        ))
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;When drawing an image, the context needs to "resolve" it to get the actual data it can draw, so if you use the same image multiple times in the canvas, you can resolve it manually up front just once&lt;/p&gt;
&lt;p&gt;This also lets you access information like the image size:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Canvas { context, size in
    let image = context.resolve(Image(systemName: "sparkle"))
    let imageSize = image.size

    for i in 0..&amp;lt;10 {
        context.draw(image, at: CGPoint(
            x: 0.5 * size.width + Double(i) * imageSize.width,
            y: 0.5 * size.height
        ))
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To draw shapes in the canvas, use &lt;code&gt;context.fill()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You can draw e.g. bezier curves or paths derived from standard SwiftUI shapes&lt;/p&gt;
&lt;p&gt;You can also use standard SwiftUI color objects:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Canvas { context, size in
    let image = context.resolve(Image(systemName: "sparkle"))
    let imageSize = image.size

    for i in 0..&amp;lt;10 {
        let frame = CGRect(
            x: 0.5 * size.width + Double(i) * imageSize.width,
            y: 0.5 * size.height,
            width: imageSize.width,
            height: imageSize.height
        )

        context.fill(
            Ellipse().path(in: frame),
            with: .color(.cyan)
        )
        context.draw(image, in: frame)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To change some properties globally on the context and revert them later, you can create a copy of the context on which you modify the settings, draw some elements using the new context, and the original context will not be affected:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Canvas { context, size in
    let image = context.resolve(Image(systemName: "sparkle"))
    let imageSize = image.size

    for i in 0..&amp;lt;10 {
        let frame = ...

        var innerContext = context
        innerContext.opacity = 0.5
        innerContext.fill(Ellipse().path(in: frame), with: .color(.cyan))

        // drawn with the original opacity
        context.draw(image, in: frame)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can set the color with which a symbol is drawn by setting the &lt;code&gt;shading&lt;/code&gt; on the resolved image:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let image = context.resolve(Image(systemName: "sparkle"))
image.shading = .color(.blue)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;Canvas&lt;/code&gt; is supported on all platforms&amp;nbsp;– it works the same way on iOS, watchOS, tvOS and macOS&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Timeline view&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Timeline view is a new tool that allows you to create animating views by describing exactly how the view should look at a given point in time&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The timeline view requires a &lt;em&gt;"schedule"&lt;/em&gt;&amp;nbsp;– a description of how often it should change&lt;/p&gt;
&lt;p&gt;For drawing an animated canvas, we'll use the &lt;code&gt;.animation&lt;/code&gt; schedule, which redraws the view as often as possible&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;TimelineView&lt;/code&gt; gives you a timeline object from which you can read the current time using the &lt;code&gt;date&lt;/code&gt; property:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;TimelineView(.animation) { timeline in
    Canvas { context, size in
        let now = timeline.date.timeIntervalSinceReferenceDate

        let angle = Angle.degrees(now.remainder(dividingBy: 3) * 120)
        let x = cos(angle.radians)

        var image = context.resolve(Image(systemName: "sparkle"))
        let imageSize = image.size

        for i in 0..&amp;lt;count {
            let frame = CGRect(
                x: 0.5 * size.width
                    + Double(i) * imageSize.width * x,
                y: 0.5 * size.height,
                width: imageSize.width,
                height: imageSize.height
            )

            context.draw(image, in: frame)
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Since there aren't any individual SwiftUI elements inside the &lt;code&gt;Canvas&lt;/code&gt;, you can't specify accessibility information for each element, only for the whole canvas&lt;/p&gt;
&lt;p&gt;If you want to include a more detailed description with separate elements, you can use the new &lt;code&gt;.accessibilityChildren&lt;/code&gt; modifier to provide a completely separate SwiftUI view hierarchy used only for accessibility purposes&lt;/p&gt;
&lt;p&gt;See more in "&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10119/"&gt;SwiftUI accessibility: Beyond the basics&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/whats-new-in-foundation/</id>
    <title>What's new in Foundation</title>
    <published>2022-06-04T21:54:43Z</published>
    <updated>2022-06-04T21:54:43Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/whats-new-in-foundation/"/>
    <content type="html">&lt;h3&gt;Attributed strings&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;An attributed string allows you to associate attributes in the form of key-value pairs with specific ranges of the string, in order to make a part of the text use a different style&lt;/p&gt;
&lt;p&gt;A part of a string can have multiple attributes applied to it and the attribute ranges can overlap&lt;/p&gt;
&lt;p&gt;Attributed strings are usually used with APIs that support rich text, like &lt;code&gt;UITextView/NSTextView&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;String attributes are defined in the SDK, but you can also add your own&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Attributed strings in Foundation previously used an ObjC-based reference type called &lt;code&gt;NSAttributedString&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This year, there is a completely new Swift-only struct type called &lt;code&gt;AttributedString&lt;/code&gt;, which takes full advantage of Swift features&lt;/p&gt;
&lt;p&gt;The Swift &lt;code&gt;AttributedString&lt;/code&gt; is a value type, compatible with &lt;code&gt;String&lt;/code&gt;, fully localizable and with a more type-safe API&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p class="help"&gt;ℹ️ I've replaced some of the code samples in this section to better demonstrate the use of the API in this form&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Assigning attributes to strings:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To assign attributes to the whole string, simply assign an appropriate value (which is type-checked at compile time) to a property like &lt;code&gt;font&lt;/code&gt; or &lt;code&gt;foregroundColor&lt;/code&gt; on the attributed string object:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var thanks = AttributedString("Thank you!")
thanks.font = .body.bold()

var website = AttributedString("Please visit our website.")
website.font = .body.italic()
website.link = URL(string: "http://www.example.com")&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To assign attributes to a range, use slicing:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let msg = AttributedString()
let start = msg.startIndex
let after5 = msg.index(start, offsetByCharacters: 5)

msg[start ..&amp;lt; after5].foregroundColor = .blue&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also find a range by looking for a substring in the text:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var website = AttributedString("Please visit our website.")

if let range = message.range(of: "visit") {
    message[range].font = .body.italic().bold()
    message.characters.replaceSubrange(range, with: "surf")
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;It may also be useful to hold a set of attributes separately from the string itself and then apply it to one or more strings at some point&amp;nbsp;– for that you can use an &lt;code&gt;AttributeContainer&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var container = AttributeContainer()
container.foregroundColor = .red
container.underlineColor = .primary

website.mergeAttributes(container)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Iterating over an attributed string:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To iterate over the string's contents, you can use one of the provided "views" into the string:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;characters, which provides a collection of separate characters&lt;/li&gt;
&lt;li&gt;runs, which lists ranges of text, each having a single uniform set of attributes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These views are Swift collections, so you can use any standard collection methods on them&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Here, we iterate over the collection of characters in a string, modifying the attributes of some of them:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let characters = message.characters

for i in characters.indices where characters[i].isPunctuation {
    message[i ..&amp;lt; characters.index(after: i)].foregroundColor = .orange
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Now, assuming we have an attributed string like this:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var line1 = AttributedString("Thank you! ")
line1.font = .boldSystemFont(ofSize: 10)

var line2 = AttributedString("Please visit our website")

// make the word "website" a link
let range = line2.range(of: "website")!
line2[range].link = URL(string: "http://example.com")

let message = line1 + line2&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;If we iterate over the string's runs collection like this, we get 3 items, because there are three distinct sections in the string:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let parts = message.runs.map { run in
    String(message.characters[run.range])
}

// =&amp;gt; ["Thank you! ", "Please visit our ", "website"]&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;However, we can also divide the string into runs only by looking at one specific attribute, e.g. &lt;code&gt;.link&lt;/code&gt;&amp;nbsp;– in this case, there will be only two separate runs:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let parts = message.runs[\.link].map { (value, range) in
    String(message.characters[range])
}

// =&amp;gt; ["Thank you! Please visit our ", "website"]&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The value provided for each one will be of &lt;code&gt;URL?&lt;/code&gt; type in this case&amp;nbsp;– the first run will have a value of &lt;code&gt;nil&lt;/code&gt;, and the second one will have the URL we've assigned earlier:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;for (value, range) in message.runs[\.link] {
    if let url = value {
        print(url.host!)    // =&amp;gt; "example.com"
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Attributed string localization:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Attributed strings are fully localizable&lt;/p&gt;
&lt;p&gt;The old ObjC-based &lt;code&gt;NSAttributedString&lt;/code&gt; now also has localization support added&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Attributed string localizations are listed in &lt;code&gt;.strings&lt;/code&gt; files, just like for normal strings&lt;/p&gt;
&lt;p&gt;Both plain strings and attributed strings can now be localized in Swift code using String interpolation, in a similar way to how it's done in SwiftUI:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func prompt(for document: String) -&amp;gt; AttributedString {
    AttributedString(localized: "Would you like to save the document '\(document)'?")
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Xcode can now collect localizable strings for the &lt;code&gt;.strings&lt;/code&gt; files from such initializers in your code using the Swift compiler&lt;/p&gt;
&lt;p&gt;You can turn this on with the build setting: "Localization &amp;gt; Use Compiler to Extract Swift Strings"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Markdown support:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Attributed strings now automatically parse Markdown content and generate attributes from tags like &lt;code&gt;**bold**&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;struct ReceiptView: View {
    var body: some View {
        VStack {
            Text("**Thank you!**")
            Text("_Please visit [our website](https://example.com)._")
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Conversion and archiving:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The new struct-based attributed strings can be easily converted to and from the old ObjC-based reference types:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let nsString = NSAttributedString(message)
let swiftString = AttributedString(nsString)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;AttributedString&lt;/code&gt; conforms to &lt;code&gt;Codable&lt;/code&gt;, so you can encode a string with all its attributes into whatever form that &lt;code&gt;Codable&lt;/code&gt; supports&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Custom attributes:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Apart from the built-in string attributes provided by AppKit, UIKit, SwiftUI and other system frameworks, you can also define your own attributes&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A string attribute consists of a key and a value&lt;/p&gt;
&lt;p&gt;The key is a type the conforms to the &lt;code&gt;AttributedStringKey&lt;/code&gt; protocol&lt;/p&gt;
&lt;p&gt;It defines the type used for the value (through the associated type &lt;code&gt;Value&lt;/code&gt;) and the name used for archiving (&lt;code&gt;static var name&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;The key can also customize encoding and decoding by conforming to some other protocols&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Here, we define a "rainbow" attribute which colors the range of the text with rainbow colors, whose value is one of the three enum cases that set the intensity:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;enum RainbowAttribute: AttributedStringKey {
    enum Value: String {
        case plain
        case fun
        case extreme
    }

    public static var name = "rainbow"
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;If we want the attribute to be encoded &amp;amp; decoded with the string, it needs to be &lt;code&gt;Codable&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;enum RainbowAttribute: CodableAttributedStringKey {
    enum Value: String, Codable {
        case plain
        case fun
        case extreme
    }

    public static var name = "rainbow"
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also make the custom attribute available to the Markdown parser, using a special custom attribute syntax&lt;/p&gt;
&lt;p&gt;To use an attribute in Markdown, it needs to also conform to &lt;code&gt;MarkdownDecodableAttributedStringKey&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The attribute is added to a range of text using a similar syntax like images and URLs:&lt;/p&gt;
&lt;/div&gt;
&lt;pre&gt;This text contains ^[an attribute](rainbow: 'extreme').

This text contains ^[two attributes](rainbow: 'extreme', otherValue: 42).

This text contains ^[an attribute with 2 properties](someStuff: {key: true, key2: false}).&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The part in the square brackets is the marked text, and the part in parentheses are the attributes&lt;/p&gt;
&lt;p&gt;The attribute values are written as a &lt;a href="https://json5.org"&gt;JSON5&lt;/a&gt; object&lt;/p&gt;
&lt;p&gt;Support for JSON5 parsing was also added to existing JSON parsing APIs like &lt;code&gt;JSONSerialization&lt;/code&gt; and &lt;code&gt;JSONDecoder&lt;/code&gt; (you need to enable a proper option first):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let json5text = "{ foo: 'bar' }"
let json5data = json5text.data(using: .utf8)!

let decoded = JSONSerialization.jsonObject(with: json5data, options: .json5Allowed)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The names of attributes from the attributes JSON object are looked up in &lt;em&gt;"attribute scopes"&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;An attribute scope defines a list of attributes from one domain, e.g. SwiftUI or your app&lt;/p&gt;
&lt;p&gt;When creating an &lt;code&gt;AttributedString&lt;/code&gt; from Markdown, you need to specify one single attribute scope from which attributes will be looked up&lt;/p&gt;
&lt;p&gt;However, attribute scopes can be nested in one another, so you can include e.g. a scope of all SwiftUI attributes inside your scope (which in turn includes Foundation attributes)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;An attribute scope is defined as a type conforming to &lt;code&gt;AttributeScope&lt;/code&gt;, nested inside the &lt;code&gt;AttributeScopes&lt;/code&gt; namespace:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;extension AttributeScopes {

    // attribute scope for our "Caffe" app

    struct CaffeAppAttributes: AttributeScope {
        // this is our "rainbow" attribute
        let rainbow: RainbowAttribute

        // here, we include standard SwiftUI attributes
        let swiftUI: SwiftUIAttributes
    }

    // expose our attribute scope on a keypath in AttributeScopes
    var caffeApp: CaffeAppAttributes.Type { CaffeAppAttributes.self }
}

// now, pass the keypath to our scope when creating a string:

let header = AttributedString(
    localized: "^[Fast &amp; Delicious](rainbow: 'extreme') Food",
    including: \.caffeApp
)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To actually render a text marked with custom attributes in a specific way, you need to create a custom view that takes an &lt;code&gt;AttributedString&lt;/code&gt; with those attributes and displays it&amp;nbsp;– see the example &lt;code&gt;RainbowText&lt;/code&gt; SwiftUI view in the "&lt;a href="https://developer.apple.com/documentation/foundation/data_formatting/building_a_localized_food-ordering_app"&gt;Building a Localized Food-Ordering App&lt;/a&gt;&lt;/code&gt; sample code project&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;New formatter APIs&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Formatters are used for converting values like dates or numbers into localized and user-presentable strings&lt;/p&gt;
&lt;p&gt;Current formatters (&lt;code&gt;NSFormatter&lt;/code&gt;) are quite heavy objects, so it's a common pattern to create them once, cache them and reuse them, often across different parts of the app, which isn't always convenient&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This year, there is a completely new API for formatters in Swift, which is aimed to improve both their usability and efficiency&lt;/p&gt;
&lt;p&gt;The formatting is now done by calling a format method on the formatted value directly&lt;/p&gt;
&lt;p&gt;Instead of formatting a date with a date formatter:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .medium

...

dateLabel.text = dateFormatter.string(from: quakeTime)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;We ask the date for a formatted representation:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;dateLabel.text = quakeTime.formatted(date: .abbreviated, time: .standard)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Formatting floating point numbers, before:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;magnitudeLabel.text = String(format: "%.1f", magnitude)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;after:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;magnitudeLabel.text = magnitude.formatted(.number.precision(.fractionLength(1)))&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This is not only more readable, but also safer&amp;nbsp;– the old version requires using string format specifiers that have to be written in a specific way, aren't checked by the compiler and can't be easily remembered, and if the value is not a floating point number&amp;nbsp;– you will get a wrong output instead of a compiler error&lt;/p&gt;
&lt;p&gt;You also now get autocompletion in Xcode for the formatting options&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are new formatting API equivalents for all existing formatters in Foundation&amp;nbsp;– for lists, date components and intervals, measurements, data sizes etc.&lt;/p&gt;
&lt;p&gt;They're designed to help you avoid some common pitfalls when using string-based format specifiers, like using a wrong date format field that only fails in some specific cases&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Formatting dates:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Simplest version:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let formatted = date.formatted()
// =&amp;gt; "6/7/2021, 9:42 AM"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Configure time and date styles:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let onlyDate = date.formatted(date: .numeric, time: .omitted)
// =&amp;gt; "6/7/2021"

let onlyTime = date.formatted(date: .omitted, time: .shortened)
// =&amp;gt; "9:42 AM"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;List specific fields to include:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let formatted = date.formatted(.dateTime.year().day().month())
// =&amp;gt; "Jun 7, 2021"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Customize field styles:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let formattedWide = date.formatted(.dateTime.year().day().month(.wide))
// =&amp;gt; "June 7, 2021"

let weekday = date.formatted(.dateTime.weekday(.wide))
// =&amp;gt; "Monday"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Standardized formats:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let logFormat = date.formatted(.iso8601)
// =&amp;gt; "20210607T164200Z"

let fileNameFormat =
    date.formatted(.iso8601.year().month().day().dateSeparator(.dash))
// =&amp;gt; "2021-06-07"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Setting a specific locale:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let formatted = date.formatted(.dateTime.locale(myLocale))&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The general pattern is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;start with the value to be formatted&lt;/li&gt;
&lt;li&gt;call one of the &lt;code&gt;formatted&lt;/code&gt; methods&lt;/li&gt;
&lt;li&gt;pass a style in the argument (e.g. for dates it's &lt;code&gt;.dateTime&lt;/code&gt; or &lt;code&gt;.iso8601&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;call methods on the format to customize it&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The order of the fields in the chained call doesn't matter&amp;nbsp;– it just lists the fields that should be included somewhere in the final output, and the formatter decides on the order based on the locale&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Formatting date ranges:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let range = (now..&amp;lt;later).formatted()
// =&amp;gt; "6/7/21, 9:42 - 11:05 AM"

let noDate = (now..&amp;lt;later).formatted(date: .omitted, time: .complete)
// =&amp;gt; "9:42:00 AM PDT - 11:05:20 AM PDT"

let timeDuration = (now..&amp;lt;later).formatted(.timeDuration)
// =&amp;gt; "1:23:20"

let components = (now..&amp;lt;later).formatted(.components(style: .wide))
// =&amp;gt; "1 hour, 23 minutes, 20 seconds"

let relative = later.formatted(.relative(presentation: .named, unitsStyle: .wide))
// =&amp;gt; "in 1 hour"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Formatting values as attributed strings:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can ask a formatter to create an attributed string instead by calling &lt;code&gt;.attributed()&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The generated attributed string does not have any visual styles applied to it, but it marks all ranges of text that contain specific fields like month or year with formatter-specific properties&lt;/p&gt;
&lt;p&gt;You can then analyze the returned &lt;code&gt;AttributedString&lt;/code&gt; and use these format properties to mark different tokens within the generated string with different colors or text styles&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var dateString: AttributedString {
    var str = date.formatted(.dateTime
                .minute()
                .hour()
                .weekday()
                .locale(locale)
                .attributed())

    // alternative way to create an AttributeContainer
    let weekday = AttributeContainer.dateField(.weekday)
    let color = AttributeContainer.foregroundColor(.orange)

    // we can ask an AttributedString to replace the attributes
    // in one container set with those in another container

    // here, the text range marked with a "weekday" attribute
    // will have an orange text color applied to it
    str.replaceAttributes(weekday, with: color)

    return str
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Parsing dates from strings:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To convert the values the other way, you can use a new &lt;code&gt;Date&lt;/code&gt; initializer that takes a "strategy" argument&lt;/p&gt;
&lt;p&gt;The strategy is an object that tells the parser what kind of fields to expect in the input&lt;/p&gt;
&lt;p&gt;You can use one of the format objects shown earlier as a parsing strategy:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let format = Date.FormatStyle().year().day().month()
let formatted = date.formatted(format)
// formatted is "Jun 7, 2021"

if let date = try? Date(formatted, strategy: format) {
  // date is 2021-06-07 07:00:00 +0000
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also use a more specialized strategy object, like this one for parsing dates sent from the server in a specific strict format:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let strategy = Date.ParseStrategy(
    format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits)",
    timeZone: TimeZone.current
)

if let date = Date("2021-06-07", strategy: strategy) {
    // date is 2021-06-07 07:00:00 +0000
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Instead of using magic string format specifiers, &lt;code&gt;ParseStrategy&lt;/code&gt; lets you specify the fields using string interpolation&amp;nbsp;– checking the format at compile time, and helping you in Xcode with autocomplete and inline documentation&lt;/p&gt;
&lt;p&gt;&lt;em&gt;"No more guessing how many Y characters you should use to parse a year"&lt;/em&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Formatting numbers:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let value = 12345

var formatted = value.formatted()
// =&amp;gt; "12,345"

let percent = 25
let percentFormatted = percent.formatted(.percent)
// =&amp;gt; "25%"

let scientific = 42e9
let sciFormatted = scientific.formatted(.number.notation(.scientific))
// =&amp;gt; "4.2E10"

let price = 29
let priceFormatted = price.formatted(.currency(code: "usd"))
// =&amp;gt; "$29.00"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Formatting lists:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To format a list of things, call &lt;code&gt;.formatted()&lt;/code&gt; on an array and specify which of the formats should be used for each member:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let list = [25, 50, 75].formatted(.list(memberStyle: .percent, type: .or))
// =&amp;gt; "25%, 50%, or 75%"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Formatting text field content in SwiftUI:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In SwiftUI, you can assign a format to a text field&lt;/p&gt;
&lt;p&gt;The text field will automatically parse and reformat the value entered by the user, and if a correct value of a given type can be parsed from the input, it will assign it through the binding to the connected state property as e.g. a number or a date value:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;struct ReceiptTipView: View {
    @State var tip = 0.15

    var body: some View {
        HStack {
            Text("Tip")
            Spacer()
            TextField("Amount",
                value: $tip,
                format: .percent)
        }
    }
}&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Automatic grammar agreement&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;In some languages, like in Spanish, there needs to be a grammatical agreement between the words in a sentence, e.g. in a phrase "two small salads" the number, the noun and the adjective need to have not only the same pluralization, but also same grammatical gender&lt;/p&gt;
&lt;p&gt;This makes it often extremely complex to provide full and correct localization for all combinations of UI strings, and either makes the localization process more time-consuming, or makes the translation less correct or less natural&lt;/p&gt;
&lt;p&gt;E.g. instead of just translating "small", "large", "juice" and "salad" you need each combination of "small juice", "large salad" etc. (and then add another dimension for pluralizations)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Foundation in iOS 15 adds a new feature called "automatic grammar agreement" that handles a lot of these problems automatically (available in English and Spanish at the moment)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now write a string like this:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Text("Add ^[\(quantity) \(size) \(food)](inflect: true) to your order")&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The custom attribute Markdown syntax for Attributed Strings is used to mark a part of the text that needs to be automatically inflected with the "inflect" attribute&lt;/p&gt;
&lt;p&gt;This will be exported to an English strings file like this:&lt;/p&gt;
&lt;/div&gt;
&lt;pre&gt;"Add ^[%lld %@ %@](inflect: true) to your order" =
    "Add ^ [%lld %@ %@](inflect: true) to your order";

"Pizza" = "Pizza";
"Juice" = "Juice";
"Salad" = "Salad";

"Small" = "Small";
"Large" = "Large";&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;And to a Latin American Spanish strings file like this:&lt;/p&gt;
&lt;/div&gt;
&lt;pre&gt;/* in Spanish the order of the noun and adjective is reversed */

"Add ^[%lld %@ %@](inflect: true) to your order" =
    "Añadir ^[%1$lld %3$@ %2$@](inflect: true) a tu pedido";

"Pizza" = "Pizza";
"Juice" = "Jugo";
"Salad" = "Ensalada";

"Small" = "Pequeño";
"Large" = "Grande";&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The key change is that you don't need to provide separate translations for e.g. "pequeño" (masculine) and "pequeña" (feminine)&amp;nbsp;– the grammar agreement engine handles this automatically&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;User's term of address:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In some languages some of the localized text also needs to change depending on the person reading it, because it needs to be adjusted for the person's term of address (depending on the gender)&lt;/p&gt;
&lt;p&gt;Now, users using supported languages (currently Spanish) can specify their term of address in the iOS Settings app, under "Language &amp;amp; Region", and can choose to share it with apps&lt;/p&gt;
&lt;p&gt;This is done the same way as in the previous example:&lt;/p&gt;
&lt;/div&gt;
&lt;pre&gt;^[Bienvenido](inflect: true) a Notas&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Such text can be displayed as "Bienvenido…" or "Bienvenida…" depending on the user's gender&lt;/p&gt;
&lt;p&gt;You can also provide a default text to be used if this information is not available:&lt;/p&gt;
&lt;/div&gt;
&lt;pre&gt;^[Bienvenido](inflect: true, inflectionAlternative: "Te damos la bienvenida") a Notas&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/discover-concurrency-in-swiftui/</id>
    <title>Discover concurrency in SwiftUI</title>
    <published>2022-06-01T21:24:26Z</published>
    <updated>2022-06-01T21:24:26Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/discover-concurrency-in-swiftui/"/>
    <content type="html">&lt;h3&gt;The SwiftUI run loop and Observable Objects&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The SwiftUI engine runs a loop that receives some events, lets you update your models, checks them for changes, recreates necessary view objects and re-renders the parts of the UI that should be updated&lt;/p&gt;
&lt;p&gt;The run loop runs in "ticks" happening in regular intervals, and all of it executes on the main thread / main actor&lt;/p&gt;
&lt;p&gt;The events sent from &lt;code&gt;ObservableObject&lt;/code&gt;'s &lt;code&gt;@Published&lt;/code&gt; also need to be sent on the main actor for this to work correctly&lt;/p&gt;
&lt;p&gt;Let's see exactly why:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;With such update method:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;class Photos: ObservableObject {
    @Published private(set) var items: [SpacePhoto] = []

    func updateItems() {
        let fetched = fetchPhotos()
        items = fetched
    }

    func fetchPhotos() -&amp;gt; [SpacePhoto] { ... }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;the update goes like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;when we assign the &lt;code&gt;items&lt;/code&gt; property, which is &lt;code&gt;@Published&lt;/code&gt;, the &lt;code&gt;Published&lt;/code&gt; wrapper automatically sends an &lt;code&gt;objectWillChange&lt;/code&gt; event that is observed by SwiftUI, before the changes happen&lt;/li&gt;
&lt;li&gt;at that point SwiftUI records a snapshot of the data before it changes&lt;/li&gt;
&lt;li&gt;then, the new value is written to the property&lt;/li&gt;
&lt;li&gt;at the next "tick" of the run loop&amp;nbsp;– which might happen immediately afterwards, or a bit later&amp;nbsp;– SwiftUI compares the current value of the property to the saved snapshot, and this tells it what exactly was changed&lt;/li&gt;
&lt;li&gt;since some of the data was changed, the appropriate parts of the UI are re-rendered&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If all of the above happens on the main thread, these steps are guaranteed to happen in order&lt;/p&gt;
&lt;p&gt;However, if the code takes some time to run, this may cause the run loop to miss the next tick, because the main thread will be busy calculating the data&amp;nbsp;– which will result in dropped frames and a possibly degraded user experience&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;So you may want to run the calculations on a background queue:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func updateItems() {
    DispatchQueue.global().async {
        let fetched = fetchPhotos()
        self.items = fetched
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This way, the main thread isn't blocked, the run loop is free to run the next ticks there until the data is ready&lt;/p&gt;
&lt;p&gt;However, if the assignment (and the resulting notification) happens outside of the main thread, this could mess up the order of operations and could result in the view not being updated&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the long-running operation finishes running on the background thread&lt;/li&gt;
&lt;li&gt;it sends the &lt;code&gt;objectWillChange&lt;/code&gt; event&lt;/li&gt;
&lt;li&gt;SwiftUI takes a snapshot of the data&lt;/li&gt;
&lt;li&gt;*but* because this is all happening on the background thread, the run loop is free to perform another tick on the main thread in the meantime, which it might possibly do at this very moment, right after &lt;code&gt;objectWillChange&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;in this case, SwiftUI compares the snapshot to the current data, but the data hasn't changed yet, so there's nothing to update&lt;/li&gt;
&lt;li&gt;now, the value of the property is updated on the background thread&lt;/li&gt;
&lt;li&gt;but SwiftUI has already compared the snapshots, so it has forgotten about the previous state and will not compare the data again…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To make sure the order of operations is always right:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;1. &lt;code&gt;objectWillChange&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;2. the state changes,&lt;/li&gt;
&lt;li&gt;3. and the run loop runs a tick,&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;all of these need to be done on the same thread&amp;nbsp;– the main thread&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Previously, the solution would have been to jump back to the main thread before saving the data:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func updateItems() {
    DispatchQueue.global().async {
        let fetched = fetchPhotos()
        DispatchQueue.main.async {
            self.items = fetched
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;With Swift 5.5, a better approach is to instead make the fetching an asynchronous operation and use &lt;code&gt;await&lt;/code&gt; to wait for the result&lt;/p&gt;
&lt;p&gt;By using &lt;code&gt;await&lt;/code&gt; we "yield" the current (main) thread instead of blocking it and it's free to perform next run loop ticks in the meantime, and when the response is received, the method continues execution&amp;nbsp;– on the same thread where it started, automatically:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func updateItems() async {
    let fetched = await fetchPhotos()
    items = fetched
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To make sure that all of the updates to properties in the &lt;code&gt;Photos&lt;/code&gt; class happen on the main thread and that we don't make a mistake somewhere, we can mark the class with the global actor &lt;code&gt;@MainActor&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@MainActor
class Photos: ObservableObject {
    @Published private(set) var items: [SpacePhoto] = []

    ...
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This way the Swift compiler guarantees that the code in this class runs on the main thread, at compile time&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Associating async tasks with views&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;SwiftUI provides a new &lt;code&gt;.task&lt;/code&gt; modifier that can be used to associate an asynchronous task with a given view&lt;/p&gt;
&lt;p&gt;The task is run at the beginning of the view's lifetime, like &lt;code&gt;.onAppear&lt;/code&gt;, but the closure your provide to it is asynchronous, which makes it easier to make asynchronous calls with &lt;code&gt;await&lt;/code&gt; inside&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In this app, we can use the &lt;code&gt;.task&lt;/code&gt; modifier to run the method which prepares the photo data in the model that we've written above:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;struct CatalogView: View {
    @StateObject private var photos = Photos()

    var body: some View {
        NavigationView {
            List {
                ...
            }
        }
        .task {
            await photos.updateItems()
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;What's more, the task's lifetime is tied to the view's lifetime, so the task is also automatically cancelled when the view is removed from the UI&lt;/p&gt;
&lt;p&gt;You can use this to e.g. create a task that continuously reads data from an async sequence for the whole duration of the view's lifetime&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Async images&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;SwiftUI now includes an &lt;code&gt;AsyncImage()&lt;/code&gt; view that automatically loads the contents of the image for you from a given URL:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;AsyncImage(url: photo.url)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can customize both the look of the resulting image and the placeholder that is displayed while the image is loading:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;AsyncImage(url: photo.url) { image in
    image
        .resizable()
        .aspectRatio(contentMode: .fill)
} placeholder: {
    ProgressView()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also customize the error handling&amp;nbsp;– for that, check out the &lt;code&gt;AsyncImage(url:scale:transaction:content:)&lt;/code&gt; initializer of this view&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Running async code from SwiftUI action handlers&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;To call asynchronous code from inside a button handler closure, which is synchronous, use a &lt;code&gt;Task&lt;/code&gt; block to execute a piece of asynchronous code in synchronous context:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;struct SavePhotoButton: View {
    var photo: SpacePhoto
    @State private var isSaving = false

    var body: some View {
        Button {
            Task {
                isSaving = true
                await photo.save()
                isSaving = false
            }
        } label: {
            Text("Save")
                .opacity(isSaving ? 0 : 1)
                .overlay {
                    if isSaving {
                        ProgressView()
                    }
                }
        }
        .disabled(isSaving)
        .buttonStyle(.bordered)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;💡 Tip: to show a spinner inside a button like this, use &lt;code&gt;.opacity()&lt;/code&gt; to hide its label while the spinner is displayed&amp;nbsp;– since the label is still there, just with 0 opacity, it makes sure that the button stays at the same size in the loading state&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p class="help"&gt;ℹ️ In the talk, the asynchronous code was instead wrapped in a call to an &lt;code&gt;async {}&lt;/code&gt; function, which was later replaced with the &lt;code&gt;Task&lt;/code&gt; initializer&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Reloading the data using pull-to-refresh&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;SwiftUI adds a new &lt;code&gt;.refreshable&lt;/code&gt; modifier this year which automatically implements a pull-to-refresh behavior in your view, where scrolling the contents of the view beyond the top edge triggers a reload of the data&lt;/p&gt;
&lt;p&gt;Inside the closure passed to &lt;code&gt;.refreshable&lt;/code&gt;, provide the reloading code that should be run on refresh&amp;nbsp;– the code can be asynchronous, so it can use &lt;code&gt;await&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;List {
    ForEach(photos.items) { item in
        ...
    }
}
.refreshable {
    await photos.updateItems()
}&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/customize-and-resize-sheets-uikit/</id>
    <title>Customize and resize sheets in UIKit</title>
    <published>2022-05-28T21:42:00Z</published>
    <updated>2022-05-28T21:42:00Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/customize-and-resize-sheets-uikit/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;iOS 13 introduced a &lt;a href="/notes/wwdc19/modernizing-your-ui-for-ios13/"&gt;refined appearance for sheets&lt;/a&gt;, which don't cover the full screen anymore on the iPhone, but instead show a curved top edge that can be used to dismiss the sheet by pulling it down&lt;/p&gt;
&lt;p&gt;iOS 15 expands the sheets API adding a lot of new customizations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;you can make vertically resizable sheets that only cover half of the screen&lt;/li&gt;
&lt;li&gt;you can remove the dimming view, creating non-modal UIs where the user can interact with the content behind the sheet&lt;/li&gt;
&lt;li&gt;you can display non-full-screen sheets on iPhones in landscape position&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h3&gt;Creating sheets&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Sheet appearance is managed through a new presentation controller class, named &lt;code&gt;UISheetPresentationController&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You don't create a sheet controller instance yourself, but instead get it from the view controller managing the modal screen that you want to present:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;if let sheet = viewController.sheetPresentationController {
    // customize the sheet...
}

present(viewController, animated: true)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;sheetPresentationController&lt;/code&gt; property will always return a non-nil value as long as the view controller's &lt;code&gt;modalPresentationStyle&lt;/code&gt; is &lt;code&gt;formSheet&lt;/code&gt; or &lt;code&gt;pageSheet&lt;/code&gt; (which it is by default unless you override it)&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Detents&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;A sheet can be configured with a list of so-called &lt;em&gt;"detents"&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Detents are positions at which the end of the sheet can rest after it's opened or resized&lt;/p&gt;
&lt;p&gt;There are currently two system-defined detents available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;large&lt;/code&gt;, which is the normal full size of a sheet, as in previous versions of iOS (the sheet going up almost to the top of the screen on the iPhone)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;medium&lt;/code&gt;, which is about half the standard height (the sheet filling bottom half of the screen on the iPhone)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can set the &lt;code&gt;detents&lt;/code&gt; property of a sheet to either or both of these:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;// standard full-height sheet - the default value
sheet.detents = [.large()]

// a sheet that can be switched between half-height and full-height
// the order matters - the first detent is the initial position
sheet.detents = [.medium(), .large()]

// a sheet that is medium height and cannot be made larger
sheet.detents = [.medium()]&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This can even be used on system modal views that normally expect to be presented in a full-height sheet, like the &lt;code&gt;PHPhotoPicker&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func showImagePicker()
    let picker = PHPickerViewController()
    picker.delegate = self

    if let sheet = picker.sheetPresentationController {
        sheet.detents = [.medium(), .large()]
    }

    present(picker, animated: true)
}

func picker(_ picker: PHPickerViewController,
    didFinishPicking results: [PHPickerResult]) {

    // assign result to imageView.image
    // do not dismiss the picker
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In this example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;calling the first function shows a system photo picker in a half-height sheet, in the bottom half of the screen&lt;/li&gt;
&lt;li&gt;the top half of the screen shows the parent screen behind it, which displays the previously selected photo&lt;/li&gt;
&lt;li&gt;when a photo is selected, we display it in the parent view, but we do not dismiss the picker sheet, allowing the user to quickly test different photos&lt;/li&gt;
&lt;li&gt;the sheet can be closed by pressing the Cancel button inside the sheet or by dragging the sheet down&lt;/li&gt;
&lt;li&gt;the sheet can also be resized by the user by dragging it up or down, switching between half height and full height&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The sheet can also be resized by scrolling the scrollable contents inside the sheet, e.g. the grid of photos in the picker in this case&lt;/p&gt;
&lt;p&gt;This is normally useful, but it might not be what you want&lt;/p&gt;
&lt;p&gt;To disable this behavior, turn off this option in the sheet:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;sheet.prefersScrollingExpandsWhenScrolledToEdge = false&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In the example above, if we do not dismiss the dialog when the user makes a selection, it will work fine in "medium" half-height mode, but in the "large" full-height mode it might seem like the app is broken, because the user will not see the view behind the dialog&lt;/p&gt;
&lt;p&gt;In such cases, it makes sense to programmatically adjust the detent position&lt;/p&gt;
&lt;p&gt;You can do that by setting &lt;code&gt;selectedDetentIdentifier&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func picker(_ picker: PHPickerViewController,
    didFinishPicking results: [PHPickerResult]) {

    // assign result to imageView.image

    if let sheet = picker.sheetPresentationController {
        sheet.animateChanges {
            sheet.selectedDetentIdentifier = .medium
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;sheet.animateChanges&lt;/code&gt; does exactly what you'd expect&amp;nbsp;– if you want to change the detent position instantly, without animation, then just set the property without using an &lt;code&gt;animateChanges&lt;/code&gt; block&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Sheets also automatically adjust when the software keyboard is shown or hidden&amp;nbsp;– if the sheet is in half-height mode and the keyboard slides up, it will automatically move to the top of the screen, and back to the medium position when the keyboard goes away&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Removing dimming overlay:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can choose to hide the overlay covering the view behind the sheet&lt;/p&gt;
&lt;p&gt;This makes the back view more visible, and also makes it possible to interact with it while the sheet is open, making the sheet non-modal&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To hide the overlay, you set the largest detent level at which the dimming overlay should be hidden; at detent levels higher than this, the overlay will be visible&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;sheet.largestUndimmedDetentIdentifier = .medium&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;So:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;setting it to &lt;code&gt;.medium&lt;/code&gt; means that &lt;code&gt;.medium&lt;/code&gt; is the largest detent with no overlay, and &lt;code&gt;.large&lt;/code&gt; will still show an overlay&lt;/li&gt;
&lt;li&gt;setting it to &lt;code&gt;.large&lt;/code&gt; means that it will show no overlay in either &lt;code&gt;.medium&lt;/code&gt; or &lt;code&gt;.large&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;leaving it at the default value (&lt;code&gt;nil&lt;/code&gt;) means the overlay will be shown in all positions&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p class="help"&gt;ℹ️ In the talk, the property was called &lt;code&gt;smallestUndimmedDetentIdentifier&lt;/code&gt;, which didn't really make sense&amp;nbsp;– the name was changed in one of the betas&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Sheets in horizontal mode&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;When the iPhone is in horizontal orientation, presented view controllers are normally displayed in full screen mode, without the cut-off rounded edge at the top and covering full width of the device&lt;/p&gt;
&lt;p&gt;You can now request to show a modal view as a sheet also in horizontal position by setting:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;sheet.prefersEdgeAttachedInCompactHeight = true&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The sheet will by default use the whole available width within in the safe area&lt;/p&gt;
&lt;p&gt;If you want the sheet to have a custom width, you can additionally set:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;sheet.widthFollowsPreferredContentSizeWhenEdgeAttached&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can customize the preferred sheet width using &lt;code&gt;preferredContentSize&lt;/code&gt;, just like on the iPad&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Additional customizations&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can show a "grabber" bar at the top edge of the sheet, like on the bottom sheet in the Maps app, in order to make it more obvious that a sheet can be dragged by moving the top edge:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;sheet.prefersGrabberVisible = true&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also customize the corner radius of the top corners of the sheet:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;sheet.preferredCornerRadius = 20.0&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Note that when the screen behind the sheet is shown in a stacked sheet, it will also use the same corner radius for consistency&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Popovers adapting into sheets&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;It's possible to present the same view as a popover on the iPad and as a sheet on the iPhone and in partial-width modes on the iPad (automatically adjusting between the two as you resize the window)&lt;/p&gt;
&lt;p&gt;To do that, customize the popover presentation controller and use &lt;code&gt;adaptiveSheetPresentationController&lt;/code&gt; to configure its sheet presentation:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func showImagePicker(_ sender: UIBarButtonItem) {
    let picker = PHPickerViewController()
    picker.delegate = self

    // show the view as a popover by default
    picker.modalPresentationStyle = .popover

    // picker.sheetPresentationController is nil
    if let popover = picker.popoverPresentationController {
        popover.barButtonItem = sender

        // access the sheet controller of the popover
        let sheet = popover.adaptiveSheetPresentationController

        sheet.detents = [.medium(), .large()]
        sheet.smallestUndimmedDetentIdentifier = .medium
    }

    present(picker, animated: true)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Note that if &lt;code&gt;modalPresentationStyle&lt;/code&gt; is set to &lt;code&gt;.popover&lt;/code&gt;, the &lt;code&gt;sheetPresentationController&lt;/code&gt; of the presented view will always be nil, so you need to access it through &lt;code&gt;popoverPresentationController.adaptiveSheetPresentationController&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Updating your app&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;How you can update your app for iOS 15:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;review your app for areas that would benefit from medium-height or non-modal sheets&lt;/li&gt;
&lt;li&gt;if you have any custom built half-height sheet views, replace them with built-in sheets&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/improve-access-to-photos/</id>
    <title>Improve access to Photos in your app</title>
    <published>2022-05-28T16:10:23Z</published>
    <updated>2022-05-28T16:10:23Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/improve-access-to-photos/"/>
    <content type="html">&lt;h3&gt;Improvements to the Photo Picker&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Privacy:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;When an app doesn't ask for a Photo Library access and only presents the Photo Picker&amp;nbsp;– which runs out of process and automatically has access to the whole library, from which the user can pick some photos for the app&amp;nbsp;– it was possible for people to misinterpret this workflow and assume that the app itself had a full access to the whole photo library, even if it didn't&lt;/p&gt;
&lt;p&gt;So now, in the Settings app, on the Photos Privacy screen, there is a new section for apps that only use the Photos Picker, titled "Apps with one-time photo selection"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Ordered selection:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In some apps, users may need to be able to select a few photos in a specific order (e.g. when adding them to a social media post)&lt;/p&gt;
&lt;p&gt;In iOS 15, your app can configure the picker to show selection order as badges ① ② ③ etc.&lt;/p&gt;
&lt;p&gt;To do that, set the &lt;code&gt;selection&lt;/code&gt; property in the picker configuration to &lt;code&gt;.ordered&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var configuration = PHPickerConfiguration()
configuration.selectionLimit = 0
configuration.selection = .ordered&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Selection adjustment:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now preselect some photos in the picker to display user's previous selection that they can edit, adding additional photos or removing some previously selected ones&lt;/p&gt;
&lt;p&gt;To preselect photos, pass an array of asset identifiers to the picker configuration:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let assetIdentifiers: [String] = previousSelection

var configuration = PHPickerConfiguration(photoLibrary: .shared)
configuration.selectionLimit = 0
configuration.preselectedAssetIdentifiers = assetIdentifiers&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;⚠️ One caveat: when the picker returns updated results, all photos that have been previously selected (and included in the preselection config you provide) will not include the item providers&lt;/p&gt;
&lt;p&gt;So you need to keep the original list of photos with their data until after the updated picker results are returned&lt;/p&gt;
&lt;p&gt;If the preselected picker dialog is cancelled, it will return the preselected assets you've provided with all item providers empty&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In the delegate callback, you need to merge together the old results with the new results, taking the value from the old results for any photo that was selected previously (since there won't be an item provider in the new one):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func picker(
    _ picker: PHPickerViewController,
    didFinishPicking newResults: [PHPickerResult]
) {
    dismiss(animated: true)

    let existingSelection: [String: PHPickerResult] = self.lastSelection
    var newSelection = [String: PHPickerResult]()

    for result in results {
        let identifier = result.assetIdentifier!
        newSelection[identifier] = existingSelection[identifier] ?? result
    }

    self.lastSelection = newSelection

    // do something with selected assets
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Reporting progress:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Some assets that the user selects in the picker may not be immediately available and need to be downloaded from the cloud first, which may take a moment (especially for videos)&amp;nbsp;– this can happen if they're selecting something from their iCloud Photos and the "Optimize Storage" option is turned on&lt;/p&gt;
&lt;p&gt;In that case, previously your app could only show a spinner indicator, since it didn't have access to information about the download progress&lt;/p&gt;
&lt;p&gt;Now, the asset's item provider can give you a &lt;code&gt;Progress&lt;/code&gt; object that you can use to track download progress and show a more appropriate loading UI:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let result: PHPickerResult = ...
let provider: NSItemProvider = result.itemProvider
let identifier: String = UTType.movie.identifier

if provider.hasItemConformingToTypeIdentifier(identifier) {
    let progress: Progress = provider.loadFileRepresentation(
        forTypeIdentifier: identifier
    ) { url, error in
        // Do something with the video, or handle the error
    }

    // Show progress in the meantime
}&lt;/pre&gt;
&lt;h3&gt;New cloud identifier APIs&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are some categories of apps that require full or partial direct access to the user's photo library, e.g. photo editing apps, camera apps or apps whose purpose is to display the photo library in some specific way&lt;/p&gt;
&lt;p&gt;Those apps use the PhotoKit APIs to access and modify the photo library&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;When using those APIs, assets are returned with unique identifiers that your app can save for later and then use again to retrieve the same assets on the next launch&lt;/p&gt;
&lt;p&gt;These identifiers are specific to each device, even if some of the assets are synced between devices using iCloud Photos&lt;/p&gt;
&lt;p&gt;If your app syncs some user-generated data that references user's photos between devices, and you want to access the same photos on multiple devices, you can use the new cloud identifiers that identify the same photo globally&lt;/p&gt;
&lt;p&gt;There is a mapping between local identifiers and cloud identifiers and you can use a cloud identifier to look up a local copy of the photo&lt;/p&gt;
&lt;p&gt;Cloud identifiers work even if the device is not signed into iCloud Photos (or even never was)&lt;/p&gt;
&lt;p&gt;The new identifiers are represented by &lt;code&gt;PHCloudIdentifier&lt;/code&gt; objects&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;How to use cloud identifiers:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;1) Get some local photos from a device's library using CloudKit&lt;/p&gt;
&lt;p&gt;2) Map local identifiers to cloud identifiers:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let cloudMappings = PHPhotoLibrary.shared()
        .cloudIdentifierMappings(forLocalIdentifiers: localIdentifiers)

for (localIdentifier, cloudMapping) in cloudMappings {
    if let cloudIdentifier = cloudMapping.cloudIdentifier {
        // save the cloudIdentifier for later
        resolved[localIdentifier] = cloudIdentifier
    } else {
        // handle the cloudMapping error
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;3) Transfer the cloud identifiers to other devices using whatever communication method you want to use (iCloud / CloudKit etc.)&lt;/p&gt;
&lt;p class="arrow"&gt;→ use &lt;code&gt;stringValue&lt;/code&gt; to encode the identifier to a string&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;4) Look up local identifiers on each device based on the cloud identifiers in the same way:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let localMappings = PHPhotoLibrary.shared()
        .localIdentifierMappings(for: cloudIdentifiers)

for (cloudIdentifier, localMapping) in localMappings {
    if let localIdentifier = localMapping.localIdentifier {
        // add the localIdentifier to our resolved assets
        resolved[cloudIdentifier] = localIdentifier
    } else {
        // handle the localMapping error
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;5) Use the local identifiers to fetch the assets and display them&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Error handling:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The mapping in both directions may return an error instead of the identifier, so you need to be able to handle that case&lt;/p&gt;
&lt;p&gt;There are two possible kinds of errors to take into account:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;1) Identifier Not Found&amp;nbsp;– if the app isn't able to find or access the relevant record:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let error = localMapping.error! as NSError

if error.code == PHPhotosError.identifierNotFound.rawValue {
    // couldn't find this photo, add it to the missing photos list
    missingPhotos.append(cloudIdentifier)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;2) Multiple Identifiers Found&amp;nbsp;– this can happen if the cloud state isn't completely in sync, and the app tries to find the image using content match and finds multiple copies; in this case, the error info will contain the list of matching identifiers under &lt;code&gt;PHLocalIdentifiersErrorKey&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let error = localMapping.error! as NSError

if error.code == PHPhotosError.multipleIdentifiersFound.rawValue {
    // found multiple matches, prompt the user to pick one
    let matches = error.userInfo[PHLocalIdentifiersErrorKey] as! [String]
    multipleMatches[cloudIdentifier] = matches
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Note: looking up cloud identifiers takes some work, so use local identifiers for normal app interactions and map them to cloud identifiers only for syncing with other devices&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Updates to the limited library&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The limited library is when you access the user's photo library using PhotoKit after the user has requested to only share selected photos with your app&lt;/p&gt;
&lt;p&gt;This is designed to work transparently for your app&amp;nbsp;– all PhotoKit APIs work fine, but they work as if the photo library only contained those selected photos and nothing else&lt;/p&gt;
&lt;p&gt;See last year's talk &lt;a href="/notes/wwdc20/handle-the-limited-photos-library/"&gt;Handle the Limited Photos Library in Your App&lt;/a&gt; for more info&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In iOS 15, apps can now create, fetch and update their own photo albums within the user's photo library when running in the limited library mode&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Also, when you call &lt;code&gt;photoLibrary.presentLimitedLibraryPicker()&lt;/code&gt; to let the user adjust a previous selection of photos shared with your app, you can now provide a callback which will be given a list of photos that have just been added to the selection:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let library = PHPhotoLibrary.shared()
library.presentLimitedLibraryPicker(from: controller) { addedIdentifiers in
   // fetch the newly added photos and use them in your app 
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;If your app still uses the old &lt;a href="https://developer.apple.com/documentation/assetslibrary"&gt;Assets Library framework&lt;/a&gt; that was deprecated in iOS 9 (&lt;code&gt;ALAssets*&lt;/code&gt;)&amp;nbsp;– please switch to PhotoKit and the Photos Picker, the old API will be removed in a future SDK&lt;/p&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/craft-search-experiences-in-swiftui/</id>
    <title>Craft search experiences in SwiftUI</title>
    <published>2022-05-17T22:39:39Z</published>
    <updated>2022-05-17T22:39:39Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/craft-search-experiences-in-swiftui/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;This year, SwiftUI adds a &lt;code&gt;.searchable&lt;/code&gt; view modifier which adds a search UI appropriate for the current platform&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Here's how it's used in the new Weather app written in SwiftUI:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationView {
    WeatherList(text: $text) {
        ForEach(data) { item in
            WeatherCell(item)
        }
    }
}
.searchable(text: $text)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;What happens behind the scenes is that the &lt;code&gt;.searchable&lt;/code&gt; modifier puts the search field configuration into the Environment, and the views within the hierarchy look for the search field configuration there and apply it in the best way for the given platform&lt;/p&gt;
&lt;p&gt;In this case, the &lt;code&gt;NavigationView&lt;/code&gt; knows how to display the search field and will show it e.g. at the top of one of its panes on iOS, or on the right side of the window toolbar on macOS&lt;/p&gt;
&lt;p&gt;If no view knows how to display the search field, a default rendering is used which puts it in the toolbar&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A search field isn't very useful without another piece of UI that displays the search results, which is something that you need to implement yourself&lt;/p&gt;
&lt;p&gt;The Weather app does it this way:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;struct WeatherList: View {
    @Binding var text: String

    @Environment(\.isSearching) private var isSearching: Bool

    var body: some View {
        WeatherCitiesList()
        .overlay {
            if isSearching &amp;&amp; !text.isEmpty {
                WeatherSearchResults()
            }
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;.searchable&lt;/code&gt; modifier puts an &lt;code&gt;\.isSearching&lt;/code&gt; key in the Environment, which you can check to see if a search field is focused somewhere on the screen, and show a different UI with search results if it is&lt;/li&gt;
&lt;li&gt;the view checks the &lt;code&gt;text&lt;/code&gt; property (which you need to pass yourself) and if it's not empty, displays a list of matching locations&lt;/li&gt;
&lt;li&gt;the results list is displayed as an overlay on top of the standard UI&amp;nbsp;– this is done so that when the user leaves the search field and the results list is hidden, the main UI below remains unchanged (unless they user has picked something from the results)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The second example is the "Colors" app:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationView {
    Sidebar()
    DetailView()
}
.searchable(text: $text)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Here's how we implement search here:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;ul&gt;
&lt;li&gt;when you apply &lt;code&gt;.searchable&lt;/code&gt; to a two-pane &lt;code&gt;NavigationView&lt;/code&gt;, it will add the search field to its first pane (the master view); if you want it to be added to the detail view, add the modifier to the detail view directly&lt;/li&gt;
&lt;li&gt;on iOS and iPad OS, the &lt;code&gt;Sidebar&lt;/code&gt; view uses the &lt;code&gt;\.isSearching&lt;/code&gt; Environment key to display search results as an overlay over the list sidebar&lt;/li&gt;
&lt;li&gt;on macOS, &lt;code&gt;NavigationView&lt;/code&gt; puts the search field in the right side of the sidebar, automatically collapsing it into a button if the window is too small&lt;/li&gt;
&lt;li&gt;on the Mac we display the search results instead in the main window pane, on top of the &lt;code&gt;DetailView&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;on tvOS, we use a slightly different layout structure, adding a tab bar with one tab used for the standard navigation and the other for search, and putting the search UI in the second tab above a results list:&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NavigationView {
    TabView {
        Sidebar()
        ColorsSearch()
            .searchable(text: $text)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In most cases, the &lt;code&gt;.searchable&lt;/code&gt; modifier applied to &lt;code&gt;NavigationView&lt;/code&gt; will automatically render the search field in the appropriate place of the hierarchy&lt;/p&gt;
&lt;p&gt;However, you always need to decide how and where to display the search results in a way appropriate for your app (which may vary between platforms)&lt;/p&gt;
&lt;p&gt;In some cases you may want to use a different layout between platforms, as we do here in case of tvOS&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Search suggestions&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can provide search suggestions for the search UI, which are examples of search queries that may be autocompleted into the field when the user picks them; suggestions give the user an idea of the types of things they can search for&lt;/p&gt;
&lt;p&gt;Search suggestions also render in a platform-appropriate way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;as a menu popover below the field on macOS&lt;/li&gt;
&lt;li&gt;as a complete search results screen on iOS&lt;/li&gt;
&lt;li&gt;as a button that opens the list on watchOS&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Suggestions are provided as an optional view builder closure that returns a set of buttons:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.searchable(text: $text) {
    ForEach(suggestions) { suggestion in
        Button {
            text = suggestion.text
        } label: {
            ColorsSuggestionLabel(suggestion)
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is also a shorthand version available, using the &lt;code&gt;.searchCompletion&lt;/code&gt; modifier&lt;/p&gt;
&lt;p&gt;The modifier should be applied to a non-interactive element like a &lt;code&gt;Label&lt;/code&gt;, and it transforms it into a button which:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;updates the search text, like the button above&lt;/li&gt;
&lt;li&gt;dismisses the suggestions view&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.searchable(text: $text) {
    ForEach(suggestions) { suggestion in
        ColorsSuggestionLabel(suggestion)
            .searchCompletion(suggestion.text)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you want to run a search query only when the user submits the search field, not live while they're typing (e.g. because it requires a request to a server), you can use the new &lt;code&gt;.onSubmit&lt;/code&gt; modifier:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.searchable(text: $text) {
    ForEach ...
}
.onSubmit(of: .search) {
    fetchResults()
}&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/direct-and-reflect-focus-swiftui/</id>
    <title>Direct and reflect focus in SwiftUI</title>
    <published>2022-05-17T22:23:54Z</published>
    <updated>2022-05-17T22:23:54Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/direct-and-reflect-focus-swiftui/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;SwiftUI in most cases manages focus automatically on your behalf based on given platform's conventions&lt;/p&gt;
&lt;p&gt;In more complex cases, where SwiftUI can't figure this out by itself, there are APIs that let you customize the focus behavior&lt;/p&gt;
&lt;p&gt;Example situations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;focusing the text editor of a new note when the "New" button is pressed in Notes&lt;/li&gt;
&lt;li&gt;moving focus from a vertical list of buttons in the sidebar to a horizontal list of items in the main area on tvOS&lt;/li&gt;
&lt;li&gt;programmatically dismissing the keyboard on iOS&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h3&gt;The @FocusState API&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;A view property marked &lt;code&gt;@FocusState&lt;/code&gt; is a special type of state that changes depending on which of the view's subviews is currently focused:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;enum Field: Hashable {
    case email
    case password
}

struct ContentView: View {
    @FocusState private var focusedField: Field?

    var body: some View {
        VStack {
            TextField("Email", text: $email)
                .focused($focusedField, equals: .email)

            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In this case, the &lt;code&gt;Field&lt;/code&gt; type is a custom enum type, but you can also use strings, integers or any other &lt;code&gt;Hashable&lt;/code&gt; type&lt;/p&gt;
&lt;p&gt;Notice that the property is an optional, since it's set to &lt;code&gt;nil&lt;/code&gt; if none of the fields is focused&amp;nbsp;– in general, a &lt;code&gt;@FocusState&lt;/code&gt; should always be optional&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The binding is two-way&amp;nbsp;– the value changes when the focus changes, but you can also move the focus by modifying the value&lt;/p&gt;
&lt;p&gt;For example, you can move the focus back to the email field if the user tries to submit the form, but the email is invalid:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;VStack {
    ...
}
.onSubmit {
    if !isEmailValid {
        focusedField = .email
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also dismiss the keyboard when the form is submitted by setting the value to &lt;code&gt;nil&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;VStack {
    ...
}
.onSubmit {
    if !isEmailValid {
        focusedField = .email
    } else {
        focusedField = nil
        logIn()
    }
}&lt;/pre&gt;
&lt;h3&gt;Creating navigation targets (tvOS)&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;On tvOS, all navigation is done by moving focus and then selecting the focused element&lt;/p&gt;
&lt;p&gt;You may sometimes want to be able to move focus between two parts of a screen with different content, and the default focus navigation will not work automatically if the two elements in two sections are not directly adjacent to each other&lt;/p&gt;
&lt;p&gt;You can fix this by extending the effective focusable area of some elements like buttons to a larger area that is not normally focusable by itself&lt;/p&gt;
&lt;p&gt;This is done using the new &lt;code&gt;.focusSection&lt;/code&gt; API:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;HStack {
    VStack {
        TextField("Email", text: $email)
        SecureField("Password", text: $password)
        SignInWithAppleButton(...)
    }
    .onSubmit { ... }
    .focusSection()

    VStack {
        Image(photoName)
        BrowsePhotosButton()
    }
    .focusSection()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;When the &lt;code&gt;.focusSection()&lt;/code&gt; view modifier is applied to a view like the &lt;code&gt;VStack&lt;/code&gt; here, the view becomes capable of accepting focus as long as it contains any focusable subviews (the browse button)&lt;/p&gt;
&lt;p&gt;Now, the user is able to move from the "Browse photos" button to the login sidebar on the left, even though the buttons in the sidebar aren't directly to the left of the button, and to move right from the email/password fields to the browse button, even though the button isn't directly to the right&lt;/p&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc21/whats-new-in-swiftui/</id>
    <title>What's new in SwiftUI</title>
    <published>2022-05-15T00:48:11Z</published>
    <updated>2022-05-15T00:48:11Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/whats-new-in-swiftui/"/>
    <content type="html">&lt;h3&gt;Async images&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Built-in support for loading images asynchronously:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;AsyncImage(url: photo.url)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;AsyncImage&lt;/code&gt; provides a default placeholder, but it can also be customized:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;AsyncImage(url: photo.url) { image in
    image
        .resizable()
        .aspectRatio(contentMode: .fill)
} placeholder: {
    Color.blue
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Custom animations and error handling:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;AsyncImage(
    url: photo.url,
    transaction: .init(animation: .spring())
) { phase in
    switch phase {
        case .empty: ...
        case .success(let image): ...
        case .failure(let error): ...
    }
}&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Improvements to lists&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Pull to refresh:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Support for pull-to-refresh in lists on iOS:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;List {
    ...
}
.refreshable {
    await items.reload()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Tasks:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Asynchronous task associated with a view:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.task {
    await items.load()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The task is run when the view appears and is automatically cancelled when the view is removed&lt;/p&gt;
&lt;p&gt;This can be also used to load data from an async sequence that produces items continuously for the whole duration of the view being visible&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;See more about new concurrency features in "&lt;a href="/notes/wwdc21/discover-concurrency-in-swiftui/"&gt;Discover concurrency in SwiftUI&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Bindings to list items:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A &lt;code&gt;List&lt;/code&gt; can now accept a binding to an array property and will pass a binding to each specific item to the closure&lt;/p&gt;
&lt;p&gt;This lets you use controls that require a binding (like &lt;code&gt;TextField&lt;/code&gt;) within list items:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;List($items) { $item in
    Text(item.title)
    TextField("Item", text: $item.text)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The previous solution for cases like this was to iterate over list indexes and bind to e.g. &lt;code&gt;$list[index].text&lt;/code&gt;, but this causes SwiftUI to reload the list after every change anywhere&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This is actually a Swift language change, so it works in all previous versions of SwiftUI too&lt;/p&gt;
&lt;p&gt;Also works in &lt;code&gt;ForEach&lt;/code&gt; and some other places&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Customizing lists:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;.listRowSeparatorTint(Color.blue)&lt;/code&gt;&amp;nbsp;– customizing the color of separators&lt;/p&gt;
&lt;p class="arrow"&gt;→ can be defined on the item to give each item a different separator&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;.listRowSeparator(.hidden)&lt;/code&gt;&amp;nbsp;– hide separators completely&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Same for customizing separators between sections: &lt;code&gt;.listSectionSeparator&lt;/code&gt;, &lt;code&gt;.listSectionSeparatorTint&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Swipe actions:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now define custom swipe actions on list items:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;CharacterProfile(character)
.swipeActions {
    Button {
        character.isPinned.toggle()
    } label: {
        Label("Pin", systemImage: "pin")
    }
    .tint(.yellow)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;By default the action is on the right edge&amp;nbsp;– use &lt;code&gt;.swipeActions(edge: .leading)&lt;/code&gt; to put the action on the left edge&lt;/p&gt;
&lt;p&gt;Add multiple &lt;code&gt;.swipeActions&lt;/code&gt; modifiers to include swipe actions on both sides&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Improvements on the Mac:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;List style with alternating backgrounds, like in standard &lt;code&gt;NSTableView&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;.listStyle(.inset(alternatesRowBackgrounds: true)&lt;/code&gt;)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;New control for full-featured multi-column tables:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Table(characters) {
    TableColumn("&amp;lt;&amp;gt;") { CharacterIcon($0) }
      .width(20)
    TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") }
      .width(40)
    TableColumn("Name", value: \.name)
    TableColumn("Powers", value: \.powers)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Like a &lt;code&gt;List&lt;/code&gt;, a &lt;code&gt;Table&lt;/code&gt; presents a list of rows rendered from the given content, except a &lt;code&gt;Table&lt;/code&gt; has a defined list of columns&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Table&lt;/code&gt; supports single- and multi-row selection:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@State private var singleSelection: StoryCharacter.ID?
@State private var multiSelection = Set&amp;lt;StoryCharacter.ID&amp;gt;()

Table(characters, selection: $multiSelection) { ... }&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can sort the table by selected column&amp;nbsp;– to support sorting in a column, provide a key-path to a model value which should be used for sorting:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@State private var sortOrder = [KeyPathComparator(\StoryCharacter.name)]

Table(characters, selection: $selection, sortOrder: $sortOrder) {
    TableColumn("Name", value: \.name)
    ...
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Tables also support various visual styles and allow you to customize the appearance of each column&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Integration with Core Data fetch requests:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Fetch requests now provide a binding to their sort descriptors, which lets you build a sortable multi-column table backed by a Core Data request:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@FetchRequest(sortDescriptors: [SortDescriptor(\.name)])
private var characters: FetchedResults&amp;lt;StoryCharacter&amp;gt;

Table(characters, selection: $selection, sortOrder: $characters.sortDescriptors) {
    ...
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;New &lt;code&gt;@SectionedFetchRequest&lt;/code&gt; which lets you build a list divided into sections from a Core Data request:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@SectionedFetchRequest(
    sectionIdentifier: \.isPinned,
    sortDescriptors: [
        SortDescriptor(\.isPinned, order: .reverse),
        SortDescriptor(\.lastModified)
    ],
    animation: .default
)
private var characterSections: SectionedFetchResults&amp;lt;...&amp;gt;&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The value of the property is a list of sections, each containing a list of items:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;List {
    ForEach(characterSections) { section in
        Section(section.id)
        ForEach(section) { character in
            CharacterRowView(character)
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Support for search in lists:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.searchable(text: $characters.filterText)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;SwiftUI automatically adds a search field in the appropriate place depending on the platform, and may automatically provide suggestions based on the context&lt;/p&gt;
&lt;p&gt;The modifier takes a binding to the entered filter text, which you should use to filter the results&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;See more in "&lt;a href="/notes/wwdc21/craft-search-experiences-in-swiftui/"&gt;Craft search experiences in SwiftUI&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Sharing data with other apps&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Support for customized previews during drag &amp;amp; drop operation:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;CharacterIcon(character)
.onDrag {
    character.itemProvider
} preview: {
    CharacterDragPreview(character)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;New ways to import and export data to/from your app using Item Providers&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Importing items from outside:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.importsItemProviders(StoryCharacter.imageAttachmentTypes) { itemProviders in
    guard let firstItem = itemProviders.first else { return false }

    Task {
        selectedCharacter.imageAttachment = await StoryCharacter.loadImage(from: firstItem)
    }

    return true
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This lets you e.g. import an image taken with an iPhone's camera to a Mac app using the Continuity Camera feature&lt;/p&gt;
&lt;p&gt;The new &lt;code&gt;ImportFromDevicesCommands()&lt;/code&gt; helper lets you add the necessary menu entries (File &amp;gt; Import from iPhone or iPad):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;WindowGroup { ... }
    .commands {
        ImportFromDevicesCommands()
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Exporting items to another app, e.g. to Shortcuts:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;.exportsItemProviders(StoryCharacter.contentTypes) {
    if let selectedCharacter = selectedCharacter {
        return [selectedCharacter.itemProvider]
    } else {
        return []
    }
}&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;Graphics&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;SF Symbols:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Many new symbols&lt;/p&gt;
&lt;p&gt;Two new rendering modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;hierarchical&amp;nbsp;– uses one color like monochrome, but adds multiple levels of opacity to emphasize key elements of the symbol&lt;/li&gt;
&lt;li&gt;palette&amp;nbsp;– gives you fine grained control over which parts of the symbol should be filled with what color&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;New SwiftUI colors: mint, teal, cyan, indigo, brown&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Symbol variants like "filled" are now automatically chosen for you depending on the context&lt;/p&gt;
&lt;p&gt;So if e.g. filled symbols should be used in tab bars, you can just use &lt;code&gt;"person.circle"&lt;/code&gt; instead of &lt;code&gt;"person.circle.fill"&lt;/code&gt; and the framework will choose the right variant automatically&lt;/p&gt;
&lt;p&gt;This makes your code more reusable&amp;nbsp;– e.g. you can use the same tab bar on macOS and it will use the outline versions of the same symbols there&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;More in "&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10097/"&gt;What's new in SF Symbols&lt;/a&gt;" and "&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10349"&gt;SF Symbols in SwiftUI&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Canvas view:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A new view that gives you a rectangular canvas for free drawing, like custom &lt;code&gt;NSViews/UIViews&lt;/code&gt; with &lt;code&gt;drawRect:&lt;/code&gt; in &lt;code&gt;AppKit/UIKit&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You can use this to draw a specific custom element, or to draw multiple elements like a grid of small icons that don't need individual tracking and invalidation&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Canvas { context, size in
    for symbol in symbols {
        let image = context.resolve(symbol.image)
        context.draw(image, in: symbol.rect)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;To provide accessibility to a &lt;code&gt;Canvas&lt;/code&gt; element, you can use the new &lt;code&gt;.accessibilityChildren&lt;/code&gt; modifier:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Canvas {
    ...
}
.accessibilityChildren {
    List(symbols) { Text($0.name) }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This provides a completely separate view hierarchy to accessibility interfaces which is more readable than the actual view&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Timeline view:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;TimelineView&lt;/code&gt; is a new view that generates different content based on the time of rendering&lt;/p&gt;
&lt;p&gt;Can be used to build e.g. an animated screen saver for tvOS&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;TimelineView(.animation) {
    let time = $0.date.timeIntervalSince1970

    Canvas { context, size in
        // draw items differently based on the time parameter
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The timeline takes a &lt;code&gt;TimelineSchedule&lt;/code&gt; parameter, which specifies when or how often it should be updated&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Timeline view can also be used to provide a view for a Watch app which automatically updates when the app is in dimmed mode on always-on displays&lt;/p&gt;
&lt;p&gt;Also in the dimmed mode on watchOS you can use the new &lt;code&gt;.privacySensitive&lt;/code&gt; modifier to blur out elements which can include private information and should be hidden when the Watch is not being used:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;VStack(alignment: .center) {
    Text("Favorite Symbol")
    Image(systemName: favoriteSymbol)
        .privacySensitive(true)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;.privacySensitive&lt;/code&gt; modifier can also be used in widgets&amp;nbsp;– in this case some content may be hidden (blurred) when the widget is shown on the lock screen while the device is locked&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;More info in "&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10002/"&gt;What's new in watchOS 8&lt;/a&gt;" and "&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10048/"&gt;Principles of great widgets&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Materials:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now create views with material backgrounds (the ones with a translucent blur effect):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;VStack {
    Text("Symbol Browser")
        .font(.largeTitle)
    Text("\(symbols.count) symbols")
        .foregroundStyle(.secondary)
        .font(.title2)
}
.padding()
.background(.ultraThinMaterial,
  in: RoundedRectangle(cornerRadius: 16.0))&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;When you use semantic styles like &lt;code&gt;.foregroundStyle(.secondary)&lt;/code&gt; for the text, the content of a container with material background uses the appropriate "vibrancy" effect&lt;/p&gt;
&lt;p&gt;The effect is applied to text, with the exception of emoji, which are excluded because this would render them an in incorrect way&lt;/p&gt;
&lt;p&gt;On the Mac, system controls like sidebars and popups use the material background and now also apply the vibrancy effect to their content&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;More about canvas and materials in the "&lt;a href="/notes/wwdc21/add-rich-graphics-to-swiftui-app/"&gt;Add rich graphics to your SwiftUI app&lt;/a&gt;" talk&lt;/p&gt;
&lt;p&gt;You can also read more about vibrancy and materials in my old blog post about the introduction of dark mode in macOS Mojave&amp;nbsp;– "&lt;a href="/2018/07/04/dark-side-mac-1/"&gt;Dark Side of the Mac: Appearance &amp;amp; Materials&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Safe area inset:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is a new view modifier &lt;code&gt;.safeAreaInset&lt;/code&gt; that allows you to position a view on top of a &lt;code&gt;ScrollView&lt;/code&gt; in a way that adjusts the scroll view's top/bottom insets so that all the content can be reached and the beginning/end isn't obscured by the overlay:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ScrollView {
    ...
}
.safeAreaInset(edge: .bottom, spacing: 0) {
    VStack {
        Text("\(symbols.count) symbols selected")
    }
    .padding()
    .background(.regularMaterial)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;See more in the "&lt;a href="/notes/wwdc21/add-rich-graphics-to-swiftui-app/"&gt;Add rich graphics to your SwiftUI app&lt;/a&gt;" talk&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Preview enhancements:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;New &lt;code&gt;.previewInterfaceOrientation&lt;/code&gt; modifier for choosing portrait/landscape orientation:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;static var previews: some View {
    ColorList()
        .previewInterfaceOrientation(.vertical)

    ColorList()
        .previewInterfaceOrientation(.horizontal)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The Attributes Inspector in the preview now includes a section for accessibility attributes, and there is a new inspector tab that shows the accessibility properties of all view elements at a glance&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Text and keyboard&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Markdown support:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;Text&lt;/code&gt; now automatically interprets Markdown formatting:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Text("**Hello**, [WWDC](https://developer.apple.com/wwdc21)!")
Text("`print(helloText)`")&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This is built on top of the new Swift-native &lt;code&gt;AttributedString&lt;/code&gt; in Foundation&lt;/p&gt;
&lt;p&gt;It has a new rich, type-safe API for adding attributes, and even allows defining custom attributes that can be applied to text through the Markdown syntax&lt;/p&gt;
&lt;p&gt;See more in "&lt;a href="/notes/wwdc21/whats-new-in-foundation/"&gt;What's new in Foundation&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Localization:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Xcode 13 can now automatically extract strings for localization using the Swift compiler (see option in build settings)&lt;/p&gt;
&lt;p&gt;See more in "&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10220/"&gt;Localize your SwiftUI app&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Dynamic Type:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now restrict some content to only a specified range of Dynamic Type text sizes&amp;nbsp;– so that when the user picks one of the extra large sizes which would make your text too large to fit, it stays at the maximum allowed size:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Header("Today's Activities")
    .dynamicTypeSize(.large .. .extraExtraLarge)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Text selection:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can enable selection of non-editable text:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Text(activity.description)
    .textSelection(.enabled)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This allows the user to select and copy any part of the text on macOS&lt;/p&gt;
&lt;p&gt;On iOS this only allows copying the whole text though&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;New formatter APIs:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Foundation now includes new &lt;code&gt;.formatted()&lt;/code&gt; APIs for various values like dates that allow you to specify the format in a type-safe way:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Text(date.formatted())

Text(date.formatted(date: .omitted, time: .shortened))

Text(date.formatted(
    .dateTime.weekday(.wide).day().month().hour().minute()))&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;There's even a list formatter API like &lt;code&gt;ListFormatter&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;people.map(\.nameComponents).formatted(
    .list(memberStyle: .name(style: .short), type: .and))

// =&amp;gt; "Matt, Jacob, and Taylor"&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Format styles can also be specified in &lt;code&gt;TextField&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@State private var newAttendee = PersonNameComponents()

var body: some View {
    ...
    TextField("New Person", value: $newAttendee, format: .name(style: .medium))
    ...
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The entered text is automatically validated and reformatted according to the specified format, and then parsed into a value of a correct type if possible&lt;/p&gt;
&lt;p&gt;See more about the new format styles in "&lt;a href="/notes/wwdc21/whats-new-in-foundation/"&gt;What's new in Foundation&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Text field prompts:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;TextField&lt;/code&gt; now includes a &lt;code&gt;prompt:&lt;/code&gt; parameter, which allows you to specify a placeholder value (an example text to enter) that is different from the label (which normally should specify the name or meaning of the field)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Text fields inside a &lt;code&gt;Form&lt;/code&gt; on macOS now display with labels outside on the left, right-aligned to the text field, with the prompt (if any) shown as the placeholder&lt;/p&gt;
&lt;p class="arrow"&gt;→ previously they were rendered like on iOS, with the label used as the placeholder inside the field&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Text field submission:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;New &lt;code&gt;.onSubmit&lt;/code&gt; modifier allows you to specify an action to execute when a text field is submitted by pressing a physical or virtual Return key:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;TextField(
    "New Person",
    value: $newAttendee,
    format: .name(style: .medium)
)
.onSubmit {
    people.append(Person(newAttendee))
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This can also be applied to the entire form:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Form {
    TextField("Username", text: $viewModel.userName)
    SecureField("Password", text: $viewModel.password)
}
.onSubmit(of: .text) {
    guard viewModel.validate() else { return }
    viewModel.login()
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now also configure the title of the Return key on the iOS keyboard (which gives it a blue background):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;TextField(...)
.onSubmit {
    ...
}
.submitLabel(.done)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Keyboard accessory views:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Adding accessory views to the iOS keyboard&amp;nbsp;– configure toolbar items with the placement of &lt;code&gt;.keyboard&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Form {
    ...
}
.toolbar {
    ToolbarItemGroup(placement: .keyboard) {
        Button(action: selectPreviousField) {
            Label("Previous", systemImage: "chevron.up")
        }
        .disabled(!hasPreviousField)

        Button(action: selectNextField) {
            Label("Next", systemImage: "chevron.down")
        }
        .disabled(!hasNextField)
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p class="arrow"&gt;→ on macOS the buttons will be shown in the Touch Bar instead&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Focus state:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;New property wrapper &lt;code&gt;@FocusState&lt;/code&gt; that reflects the state of focus and provides precise control over it&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;@FocusState&lt;/code&gt; property is bound to a field's focus using &lt;code&gt;.focused&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The value of the property will then show if and which view is currently focused, and you can move the focus by modifying the value&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Simple form: declare a boolean property, and it will be set to true when the view is focused:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@FocusState private var newPersonIsFocused: Bool

var body: some View {
    TextField("New Person", value: $newAttendee,
        format: .name(style: .medium)
    )
    .focused($newPersonIsFocused)

    Button {
        newPersonIsFocused = true
    } label: {
        Label("Add Attendee", systemImage: "plus")
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In a more advanced version, the focus property can use any &lt;code&gt;Hashable&lt;/code&gt; as its type, e.g. an enum, and be used to show &lt;em&gt;which&lt;/em&gt; of the possible fields is currently focused:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;private enum Field: Int, Hashable {
    case name, location, date, addAttendee
}

@FocusState private var focusedField: Field?

var body: some View {
    TextField("New Person", value: $newAttendee,
        format: .name(style: .medium)
    )
    .focused(focusedField, equals: .addAttendee)

    Button {
        focusedField = .addAttendee
    } label: {
        Label("Add Attendee", systemImage: "plus")
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This allows you to build any kind of complex screen with multiple focusable fields that you may want to move between programmatically&lt;/p&gt;
&lt;p&gt;You can also use &lt;code&gt;.focused&lt;/code&gt; to dismiss the keyboard on iOS by unfocusing a focused field:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@FocusState private var newPersonIsFocused: Bool

var body: some View {
    TextField(...).focused($newPersonIsFocused)
}

func endEditing() {
    newPersonIsFocused = false
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;See more about the new focus API in "&lt;a href="/notes/wwdc21/direct-and-reflect-focus-swiftui/"&gt;Direct and reflect focus in SwiftUI&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;hr&gt;
&lt;h3&gt;Buttons&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Support for bordered buttons on iOS:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Button("Add") { ... }
    .buttonStyle(.bordered)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Like all modifiers, this can be added to a larger container and applies to all buttons inside&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Choose a color for a bordered button by specifying a tint:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Button("Buy") { ... }
    .buttonStyle(.bordered)
    .tint(.green)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Make buttons smaller or larger by using the &lt;code&gt;.controlSize&lt;/code&gt; modifier, previously used only on macOS:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ForEach(entry.tag) { tag in
    Button(tag.name) { ... }
        .tint(tag.color)
}
.controlSize(.small)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p class="arrow"&gt;→ this only seems to work for buttons at the moment&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also make "prominent" bordered buttons which use a style that stands out more (stronger background):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Button("Submit", action: submitForm)
    .buttonStyle(.borderedProminent)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p class="help"&gt;ℹ️ In the video, this is shown as &lt;code&gt;.controlProminence(.increased)&lt;/code&gt;. The &lt;code&gt;.controlProminence&lt;/code&gt; modifier was replaced in a later beta with a separate &lt;code&gt;.borderedProminent&lt;/code&gt; button style.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Buttons with a &lt;code&gt;.large&lt;/code&gt; size can be used to build a menu of action buttons at the bottom of a screen/form, with the default button marked as prominent:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;VStack {
    Button(action: addtoJar) {
        Text("Add to Jar").frame(maxWidth: 300)
    }
    .buttonStyle(.borderedProminent)
    .keyboardShortcut(.defaultAction)

    Button(action: addToWatchlist) {
        Text("Add to Watchlist").frame(maxWidth: 300)
    }
    .tint(.accentColor)
    .buttonStyle(.bordered)
}
.controlSize(.large)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Note: do not add &lt;code&gt;.borderedProminent&lt;/code&gt; to every single button on the screen&amp;nbsp;– this should be only used for single primary actions&lt;/p&gt;
&lt;p class="arrow"&gt;→ this is the equivalent of a blue-colored default button in a dialog on macOS, one that is bound to the Return key shortcut, of which by definition there can be only one within a dialog&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Buttons using the new styles like &lt;code&gt;.controlSize&lt;/code&gt;, &lt;code&gt;.borderedProminent&lt;/code&gt; and &lt;code&gt;.tint&lt;/code&gt; automatically provide appropriate pressed states and automatically adapt to dark mode, Dynamic Type font sizes etc.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now specify a "role" for a button, currently &lt;code&gt;.cancel&lt;/code&gt; or &lt;code&gt;.destructive&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Button("Delete...", role: .destructive) { ... }&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Marking a button as destructive adds a red foreground and/or background depending on the context, to emphasize that the action is potentially dangerous&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Cancel and destructive buttons are often used in confirmation dialogs, which now have a new view modifier made specifically for them:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;RowCell(entry)
.contextMenu {
    Button("Delete...", role: .destructive) {
        showConfirmation = true
    }
}
.confirmationDialog("Are you sure you want to delete \(title)?",
    isPresented: $showConfirmation
) {
    Button("Delete", role: .destructive) {
        deleteEntry(entry)
    }
} message:
    Text("Deleting \(title) will remove it from all of your jars.")
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;A confirmation dialog is shown as an action sheet on iOS, as a popover on the iPad, and as an alert on macOS&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Another type of button is a menu button (available before):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Menu("Add") {
    ForEach(jarStore.allJars) { jar in
        Button("Add to \(jar.name)") {
            jarStore.add(buttonEntry, to: jar)
        }
    }
}
.menuStyle(.borderedButton)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p class="help"&gt;ℹ️ In the video, this is shown as &lt;code&gt;.menuStyle(.button)&lt;/code&gt;. There is however no such menu style as &lt;code&gt;.button&lt;/code&gt;&amp;nbsp;– use &lt;code&gt;.borderedButton&lt;/code&gt; on macOS and &lt;code&gt;.borderlessButton&lt;/code&gt; elsewhere (or just keep the default).&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A bordered button menu is rendered as a button with a down arrow on the right on macOS (a &lt;a href="https://developer.apple.com/design/human-interface-guidelines/macos/buttons/pull-down-buttons/"&gt;pull-down menu button&lt;/a&gt;)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A new option for button menus (both macOS and iOS) is to provide a "primary action":&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Menu("Add") {
    ForEach(jarStore.allJars) { jar in
        Button("Add to \(jar.name)") {
            jarStore.add(buttonEntry, to: jar)
        }
    }
} primaryAction: {
    jarStore.add(buttonEntry)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This makes the button act as both a normal button and a menu:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;on iOS, a normal tap runs the primary action, and a long press shows the menu&lt;/li&gt;
&lt;li&gt;on macOS, the button uses a slightly different style and lets you either click the main button part for the primary action, or click the arrow on the right to show the menu (without a primary action, clicking either of the parts shows the menu)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can also choose to hide the arrow indicator of a menu button on macOS using &lt;code&gt;.menuIndicator&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Menu("Add") {
    ...
}
.menuStyle(.borderedButton)
.menuIndicator(.hidden)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Such menu button looks like a completely plain button, but still acts as a menu button:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;without a primary action, it always shows the menu when pressed&lt;/li&gt;
&lt;li&gt;with a primary action, it runs the primary action when pressed, and to show the menu you need to do a long-press like on iOS&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can use such buttons with a hidden indicator to decrease the visual prominence of the button, e.g. if there are a lot of them in the same view&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A &lt;code&gt;Toggle&lt;/code&gt; can now also be displayed as a toggle button:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;Toggle(isOn: $showOnlyNewFilter) {
    Label("Show Only New", systemImage: "sparkles")
}
.toggleStyle(.button)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;This works like a toggle button on macOS (&lt;code&gt;NSButton.ButtonType.toggle&lt;/code&gt;)&amp;nbsp;– one that cycles between an "on" state (with a blue background) and an "off" state when pressed; it works the same way on iOS&amp;nbsp;– in the "on" state it's rendered as a bordered button with a blue background&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There is also a new control that groups buttons, mainly toolbar buttons&amp;nbsp;– &lt;code&gt;ControlGroup&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ControlGroup {
    Button(action: archive) {
        Label("Archive", systemImage: "archiveBox")
    }
    Button(action: delete) {
        Label("Delete", systemImage: "trash")
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Toolbar buttons in a &lt;code&gt;ControlGroup&lt;/code&gt; are displayed grouped together visually, as a kind of segmented control&amp;nbsp;– as opposed to buttons in &lt;code&gt;ToolbarItemGroup&lt;/code&gt;, which are only arranged side by side, but not joined visually in any way&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;ControlGroup&lt;/code&gt; with menu buttons can be used to create a pair of standard back/forward navigation buttons:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;ControlGroup {
    Menu {
        ForEach(history) { ... }
    } label: {
        Label("Back", systemImage: "chevron.backward")
    } primaryAction: {
        goBack(to: history[0])
    }
    .disabled(history.isEmpty)

    Menu {
        ForEach(forwardHistory) { ... }
    } label: {
        Label("Forward", systemImage: "chevron.forward")
    } primaryAction: {
        goForward(to: forwardHistory[0])
    }
    .disabled(forwardHistory.isEmpty)
}&lt;/pre&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc20/app-essentials-in-swiftui/</id>
    <title>App essentials in SwiftUI</title>
    <published>2022-05-09T22:47:08Z</published>
    <updated>2022-05-09T22:47:08Z</updated>
    <link href="https://mackuba.eu/notes/wwdc20/app-essentials-in-swiftui/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;You can now build the entire app using just SwiftUI&lt;/p&gt;
&lt;p&gt;New APIs for defining apps and their scenes and views&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Defining apps &amp; scenes&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;An app consists of one or more &lt;em&gt;"scenes"&lt;/em&gt;&lt;/p&gt;
&lt;p class="arrow"&gt;→ see "&lt;a href="/notes/wwdc19/introducing-multiple-windows-on-ipad/"&gt;Introducing Multiple Windows on iPad&lt;/a&gt;" from 2019 for an introduction to the UIKit scenes API&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A scene roughly maps to a window:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;on iOS, watchOS and tvOS the app can present one scene at a time&lt;/li&gt;
&lt;li&gt;on iPadOS you can see multiple scenes from the same or different apps side by side&lt;/li&gt;
&lt;li&gt;on macOS, each scene is displayed in a different window or inside tabs within a window&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each scene contains a view hierarchy and/or a hierarchy of child scenes&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;An app declaration looks similar to a view declaration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;it's also a lightweight struct&lt;/li&gt;
&lt;li&gt;it implements the &lt;code&gt;App&lt;/code&gt; protocol similar to &lt;code&gt;View&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the struct can include properties defining data dependencies, marked with wrappers like &lt;code&gt;@State&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the protocol requires a single &lt;code&gt;body&lt;/code&gt; property which returns &lt;code&gt;some Scene&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;inside the body definition, a DSL similar to the one for views is used to define the scene hierarchy:&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@main
struct BookClubApp: App {
  @StateObject private var store = ReadingListStore()

  var body: some Scene {
    WindowGroup {
      ReadingListView(store: store)
    }
  }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;@main&lt;/code&gt; attribute is a new feature of Swift 5.3&amp;nbsp;– it allows a type to serve as the program entry point&lt;/p&gt;
&lt;p&gt;This replaces the &lt;code&gt;@NSApplicationMain&lt;/code&gt;/&lt;code&gt;@UIApplicationMain&lt;/code&gt; or &lt;code&gt;main.swift&lt;/code&gt; file&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The code above is a complete declaration of the app structure&lt;/p&gt;
&lt;p&gt;Even though it's just a few lines of code, this automatically provides quite a lot of functionality for free&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Window Group&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The most common type of scene is a &lt;code&gt;WindowGroup&lt;/code&gt;, which defines the primary interface (main view) of your app&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A &lt;code&gt;WindowGroup&lt;/code&gt; displays its view in the expected way or each platform:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;on iPhone, tvOS and watchOS it's shown as a single full-screen window&lt;/li&gt;
&lt;li&gt;on the iPad it supports the new multi-window scene system&lt;/li&gt;
&lt;li&gt;on the Mac it creates multiple Mac windows&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The lifecycle of scenes depends on the platform&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;On iPadOS, the app switcher shows the app name and an optional scene title above each scene/window&amp;nbsp;– this can be the document title, title of the open web page etc.&lt;/p&gt;
&lt;p&gt;In latest SwiftUI, you can set that scene title using the new &lt;code&gt;.navigationTitle("Name")&lt;/code&gt; view modifier, which replaces &lt;code&gt;.navigationBarTitle&lt;/code&gt; (it sets both the title seen in the navigation bar and the scene name in the app switcher)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;On the Mac, &lt;code&gt;WindowGroup&lt;/code&gt; automatically provides a "File &amp;gt; New Window" command that opens a new window and a Window menu that lists all windows&lt;/p&gt;
&lt;p&gt;The scene title is used as the window title in the title bar and the Window menu&lt;/p&gt;
&lt;p&gt;Scene windows can also me merged into a single window with tabs by choosing Window &amp;gt; Merge All Windows&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Scene storage&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;@SceneStorage&lt;/code&gt; is a new property wrapper that helps you manage persistence and restoration of view state in scenes&lt;/p&gt;
&lt;p&gt;Provide a unique key under which the given value should be saved, and it will be saved and restored at appropriate times automatically&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;struct ReadingListViewer: View {
  @SceneStorage("selectedItem") var selectedItem: String?

  var body: some View {
    NavigationView {
      ReadingList(selectedItem: $selectedItem)
    }
  }
}&lt;/pre&gt;
&lt;h3&gt;Document Group&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Another basic kind of scene in the app definition is &lt;code&gt;DocumentGroup&lt;/code&gt;, which defines documents in a document-based app:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@main
struct ShapeEditApp: App {
  var body: some Scene {
    DocumentGroup(newDocument: SketchDocument()) { file in
      DocumentView(file.$document)
    }
  }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;See more in "&lt;a href="/notes/wwdc20/build-document-based-apps-in-swiftui/"&gt;Build document-based apps in SwiftUI&lt;/a&gt;"&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Preferences windows&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The last scene type, &lt;code&gt;Settings&lt;/code&gt;, is Mac-only and defines standard Mac Preferences windows:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var body: some Scene {
  WindowGroup {
    ...
  }

  Settings {
    BooksSettingsView()
  }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The Preferences window is automatically linked to from the main app menu with the standard &lt;code&gt;Cmd+,&lt;/code&gt; keyboard shortcut&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Menu commands&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The new &lt;code&gt;.commands&lt;/code&gt; view modifier allows you to define menus and menu items with shortcuts for the macOS app menu:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;WindowGroup {
  ...
}
.commands {
  BookCommands()
}&lt;/pre&gt;
&lt;pre class="brush: swift"&gt;struct BookCommands: Commands {
  @FocusedBinding(\.selectedBook) private var selectedBook: Book?

  var body: some Commands {
    CommandMenu("Book") {
      Section {
        Button("Update Progress...", action: updateProgress)
          .keyboardShortcut("u")
        Button("Mark Completed", action: markCompleted)
          .keyboardShortcut("C")
      }
      .disabled(selectedBook == nil)
    }
  }

  private func updateProgress() { selectedBook?.recordNewProgress() }
  private func markCompleted() { selectedBook?.markCompleted() }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The commands are also used on the iPad and are displayed in the keyboard shortcuts help popup&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;@FocusedBinding&lt;/code&gt; property wrapper used in this commands block allows you to selectively enable/disable some groups of commands based on your focus, similar to how the responder chain is used in AppKit or UIKit&lt;/p&gt;
&lt;p&gt;See more about commands in the reference documentation&lt;/p&gt;
&lt;/div&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?1777132788" 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/notes/wwdc21/whats-new-in-appkit/</id>
    <title>What's new in AppKit</title>
    <published>2021-06-11T12:41:10Z</published>
    <updated>2021-06-11T12:41:10Z</updated>
    <link href="https://mackuba.eu/notes/wwdc21/whats-new-in-appkit/"/>
    <content type="html">&lt;h3&gt;Design &amp; control updates&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are design updates for some system controls:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;popovers appear with an animation&lt;/li&gt;
&lt;li&gt;sliders smoothly glide into position when clicked&lt;/li&gt;
&lt;li&gt;smaller things like increased spacing between table sections or slightly wider toolbar buttons&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Control tinting:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Individual controls like buttons, segmented controls or sliders can have a custom tint&lt;/p&gt;
&lt;p&gt;Properties: &lt;code&gt;NSButton.bezelColor&lt;/code&gt;, &lt;code&gt;NSSegmentedControl.selectedSegmentBezelColor&lt;/code&gt;, &lt;code&gt;NSSlider.trackFillColor&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;These properties have been introduced in macOS Sierra for Touch Bar controls; in macOS Monterey they’re functional also for normal app controls&lt;/p&gt;
&lt;p&gt;This is useful for specific controls that need to have some kind of semantically meaningful color&lt;/p&gt;
&lt;p class="arrow"&gt;→ e.g. a green “Accept Call” and a red “End Call” buttons in a video call app&lt;/p&gt;
&lt;p&gt;Avoid confusion with the default button if there is one in the same view, since it will also be colorful&lt;/p&gt;
&lt;p&gt;Make sure to indicate purpose with more than just the color (using a clear label or an icon), since some of your users may not be able to distinguish buttons by color&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Push buttons:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Push buttons no longer highlight using the accent color on click&amp;nbsp;– they behave just like e.g. segmented controls in macOS Big Sur&lt;/p&gt;
&lt;p&gt;Don’t make assumptions about how the highlight state looks (e.g. drawing white text over a button that should be blue when pressed, but will now be light gray)&lt;/p&gt;
&lt;p&gt;Instead, check the interior background style &lt;code&gt;NSButtonCell.interiorBackgroundStyle&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.normal&lt;/code&gt; = colorless state&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.emphasized&lt;/code&gt; = colorful state&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The old “regular square button” aka “bevel button” has now been refreshed as “Flexible push style button” and can be used as a variable height push button&lt;/p&gt;
&lt;p&gt;It supports the same kind of configuration as a regular push button, so it can serve as a default button and can be tinted&lt;/p&gt;
&lt;p&gt;Its corner radius and padding now match other button styles&lt;/p&gt;
&lt;p&gt;It can contain larger icons or multi-line text&lt;/p&gt;
&lt;p&gt;The vast majority of buttons should still use the standard fixed height push button&amp;nbsp;– the variable height button is meant for special cases&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Localizing keyboard shortcuts&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Some keyboard shortcuts should be localized for different keyboard layouts, because in some layouts they may be hard or impossible to type, or it may make sense to adapt them for right-to-left languages&lt;/p&gt;
&lt;p&gt;E.g. &lt;code&gt;Cmd + \&lt;/code&gt; is not possible to type on the Japanese keyboard, which doesn’t have a backslash key&lt;/p&gt;
&lt;p&gt;AppKit can now handle this for you&lt;/p&gt;
&lt;p&gt;In macOS Monterey, the system automatically remaps such shortcuts to different ones that are more natural on the given keyboard layout&lt;/p&gt;
&lt;p&gt;Shortcuts like &lt;code&gt;Cmd + [&lt;/code&gt; and &lt;code&gt;Cmd + ]&lt;/code&gt; to go back and forward will be swapped in right-to-left languages&lt;/p&gt;
&lt;p class="arrow"&gt;→ this applies to brackets, braces, parentheses and arrow keys&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can opt out using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;NSMenuItem.allowsAutomaticKeyEquivalentMirroring&lt;/code&gt;&amp;nbsp;– for directional keys like brackets&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NSMenuItem.allowsAutomaticKeyEquivalentLocalization&lt;/code&gt;&amp;nbsp;– turns off all key localization, including mirroring&lt;/li&gt;
&lt;li&gt;if you really don’t want to use this feature at all, you can also disable it completely in your app by implementing the &lt;code&gt;NSApplicationDelegate&lt;/code&gt; method: &lt;code&gt;applicationShouldAutomaticallyLocalizeKeyEquivalents(_:)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h3&gt;Update to SF Symbols&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;New version&amp;nbsp;– SF Symbols 3&lt;/p&gt;
&lt;p&gt;Expands capabilities of the SF Symbols app&lt;/p&gt;
&lt;p&gt;Some symbols now have multiple layers that can be individually colored&lt;/p&gt;
&lt;p&gt;Updated format for custom symbols&amp;nbsp;– allows you to annotate distinct layers within an image&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Big Sur had two coloring modes for symbols:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;traditional monochrome template style, drawing the whole symbol using one accent color&lt;/li&gt;
&lt;li&gt;a multicolor style that uses multiple colors that are predefined in the symbol itself&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;SF Symbols 3 in macOS Monterey adds two new rendering modes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;"hierarchical"&amp;nbsp;– uses a single tint color, but draws different layers of the image in an emphasized or deemphasized way (lighter or darker than the base color)&lt;/li&gt;
&lt;li&gt;"palette"&amp;nbsp;– lets you assign each layer any custom color independently&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;APIs for the new rendering modes:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;NSImage.SymbolConfiguration(hierarchicalColor: .red)
NSImage.SymbolConfiguration(paletteColors: […])
NSImage.SymbolConfiguration.preferringMulticolor()&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Symbol variants:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There are also new APIs for mapping between symbol variants, e.g. outline heart symbol &amp;nbsp;⭤&amp;nbsp; filled heart symbol, or variants with circles etc.&lt;/p&gt;
&lt;p&gt;Useful e.g. when you’re building a picker control that uses outline icons for unselected states and filled variants of the same icons for the selected item&lt;/p&gt;
&lt;p&gt;To convert between variants, call e.g.: &lt;code&gt;baseImage.image(with: .fill)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;There are constants for each kind of symbol variant, and you can combine multiple variants together (e.g. circle + fill)&lt;/p&gt;
&lt;p&gt;See “Design and build SF Symbols” for more info&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;TextKit 2&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Huge update to the text system&lt;/p&gt;
&lt;p&gt;TextKit is a great text engine with a long track record, used across all Apple systems&lt;/p&gt;
&lt;p&gt;However, TextKit is a &lt;em&gt;linear&lt;/em&gt; text layout engine, which means it typesets a block of text from the beginning to the end&lt;/p&gt;
&lt;p&gt;There are a lot of use cases where a &lt;em&gt;non-linear&lt;/em&gt; layout engine is more useful&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;TextKit 2 always uses a non-linear layout system&lt;/p&gt;
&lt;p&gt;This means it can perform layout on a more granular level, which allows it to avoid some unnecessary work&lt;/p&gt;
&lt;p&gt;For example, when you’re looking at a middle fragment of a long document, a linear layout system needs to process all text from the beginning up to the given fragment in order to render it; a non-linear system can start at the nearest start of a paragraph&lt;/p&gt;
&lt;p&gt;The non-linear layout system also makes it easier to mix text with non-text elements, and improves performance for large documents&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;TextKit 2 provides a lot of customization points, which allow you to extend its behavior&lt;/p&gt;
&lt;p&gt;The new version coexists with TextKit 1, you can choose which engine to use for each view&lt;/p&gt;
&lt;p&gt;TextKit 2 has actually already been used in some system apps and controls in Big Sur&lt;/p&gt;
&lt;p&gt;See “&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10061/"&gt;Meet TextKit 2&lt;/a&gt;” for more info&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;New Swift features&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Swift 5.5 introduces some important new features for managing concurrency: async/await and actors&lt;/p&gt;
&lt;p&gt;In AppKit, many asynchronous methods that return value through a completion handler now have variants that work with async/await:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@IBAction func pickColor(_ sender: Any?) {
  async {
    guard let color = await NSColorSampler().sample() else { return }
    textField.textColor = color
  }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The actor model is a great fit for a UI framework like AppKit where most APIs should be called on a single main thread&lt;/p&gt;
&lt;p&gt;The macOS SDK now has a &lt;code&gt;@MainActor&lt;/code&gt; property wrapper that marks all types that have to be accessed from the main thread&lt;/p&gt;
&lt;p&gt;Classes such as &lt;code&gt;NSView&lt;/code&gt;, &lt;code&gt;NSView/WindowController&lt;/code&gt;, &lt;code&gt;NSApplication&lt;/code&gt;, &lt;code&gt;NSCell&lt;/code&gt;, &lt;code&gt;NSDocument&lt;/code&gt; etc. are now marked with &lt;code&gt;@MainActor&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Code running in the main actor can freely call methods on other main actor types&lt;/p&gt;
&lt;p&gt;However, code that isn’t running on the main thread needs to use async/await to run code on a &lt;code&gt;@MainActor&lt;/code&gt; type&lt;/p&gt;
&lt;p&gt;This is enforced at the compiler level, which lets you avoid common errors that happen when mixing concurrency with UI code&lt;/p&gt;
&lt;p&gt;See “&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10132/"&gt;Meet async/await in Swift&lt;/a&gt;” and “&lt;a href="https://developer.apple.com/videos/play/wwdc2021/10133"&gt;Protect mutable state with Swift actors&lt;/a&gt;” for more info&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;AttributedString:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Swift 5.5 also adds a new value type &lt;code&gt;AttributedString&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;It has type-safe attributes and a more swifty API for reading &amp;amp; writing attributes&lt;/p&gt;
&lt;p&gt;You can easily convert between &lt;code&gt;AttributedString&lt;/code&gt; and &lt;code&gt;NSAttributedString&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;See “&lt;a href="/notes/wwdc21/whats-new-in-foundation/"&gt;What’s new in Foundation&lt;/a&gt;” for more info&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Updating NSViews:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There is a new Swift property wrapper which should reduce boilerplate around view properties&lt;/p&gt;
&lt;p&gt;Let’s say we have a custom view class like this:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;class BadgeView: NSView {
  var fillColor: NSColor
  var shadow: NSShadow
  var scaling: NSImageScaling
  …
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;These properties will usually need to have a &lt;code&gt;didSet&lt;/code&gt; which updates properties like &lt;code&gt;needsDisplay&lt;/code&gt; or &lt;code&gt;needsLayout&lt;/code&gt; when they’re modified:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;var fillColor: NSColor {
  didSet { needsDisplay = true }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The new &lt;code&gt;@Invalidating&lt;/code&gt; attribute in &lt;code&gt;NSView&lt;/code&gt; lets you easily specify which other view properties should be updated when the given property is modified:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;@Invalidating(.display) var fillColor: NSColor&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Properties that can be invalidated include: display, layout, constraints, intrinsic content size, restorable state&lt;/p&gt;
&lt;p&gt;The marked property needs to be &lt;code&gt;Equatable&lt;/code&gt;, since AppKit checks if the value was actually changed before triggering a view update&lt;/p&gt;
&lt;p&gt;You can extend the invalidation system by conforming to &lt;code&gt;NSViewInvalidating&lt;/code&gt; protocol&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Shortcuts&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;iOS Shortcuts are now available on the Mac&lt;/p&gt;
&lt;p&gt;Shortcuts appear in all the places where you can access services today&amp;nbsp;– if your app supports services, it will also support Shortcuts&lt;/p&gt;
&lt;p&gt;AppKit decides which shortcuts are available at the given place by checking the responder chain&lt;/p&gt;
&lt;p&gt;It asks each responder whether it can provide or receive the type of data used by each shortcut&lt;/p&gt;
&lt;p&gt;The types of data are represented by &lt;code&gt;NSPasteboard.PasteboardType&lt;/code&gt; (usually a UTI)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To support shortcuts in a given responder object, implement the method:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func validRequestor(forSendType sendType: NSPasteboard.PasteboardType?,
                              returnType: NSPasteboard.PasteboardType?) -&amp;gt; Any&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;In that method return an instance of a type implementing &lt;code&gt;NSServicesMenuRequestor&lt;/code&gt; (usually the same object):&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;protocol NSServicesMenuRequestor {
  func writeSelection(to pasteboard: NSPasteboard,
                              types: [NSPasteboard.PasteboardType]) -&amp;gt; Bool

  func readSelection(from pasteboard: NSPasteboard) -&amp;gt; Bool
}&lt;/pre&gt;
&lt;h3&gt;Siri Intents&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can now use Siri Intents in a Mac app by adding an Intents Extension&lt;/p&gt;
&lt;p&gt;You can also return an intents handler from the application delegate:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;protocol NSApplicationDelegate {
  optional func application(_ application: NSApplication,
                        handlerFor intent: INIntent) -&amp;gt; Any?
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;The returned object should conform to an appropriate intent handler protocol, depending on the intent type&lt;/p&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc16/cloudkit-best-practices/</id>
    <title>CloudKit Best Practices</title>
    <published>2020-11-14T15:47:57Z</published>
    <updated>2020-11-14T15:47:57Z</updated>
    <link href="https://mackuba.eu/notes/wwdc16/cloudkit-best-practices/"/>
    <content type="html">&lt;h3&gt;Short CloudKit overview&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Apple uses CloudKit in their applications, so you can be confident that it scales, because for Apple it scales to hundreds of millions of users&lt;/p&gt;
&lt;p&gt;CloudKit lets you focus on building your applications and not worry about building backend services for them&lt;/p&gt;
&lt;p&gt;It provides your users automatic authentication&amp;nbsp;– if the user is logged in to iCloud on their device, they don’t need to log in separately in your app&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A CloudKit container now includes 3 databases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;public database for data visible to everyone&lt;/li&gt;
&lt;li&gt;private database for a given user’s private data&lt;/li&gt;
&lt;li&gt;new this year: shared database for user data that they decided to share with others&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Zones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;public database has 1 default zone&lt;/li&gt;
&lt;li&gt;private database has a default zone and it can have one or more custom zones&lt;/li&gt;
&lt;li&gt;shared database includes some number of shared zones&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A record always exists in a specific zone&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Building an app with a sync feature&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;A common use case (e.g. Notes app):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;user creates some data/records/documents on one of their devices&lt;/li&gt;
&lt;li&gt;later, they open another device and they expect to see these documents there and be able to read/edit them&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The way this is implemented is that CloudKit needs to be the source of truth, and the devices should maintain a local cache of all the app data and synchronize it using CloudKit&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The recommended workflow:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;1. On app launch, fetch changes from the server&lt;/li&gt;
&lt;li&gt;2. Subscribe to any future changes&lt;/li&gt;
&lt;li&gt;3. Fetch changes when you receive a push&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Subscriptions:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Subscriptions let you ask the server to notify you whenever a change happens in the specified set of data. Previously you could subscribe to a specific query to a record type or to all changes in a zone.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;New in iOS 10&amp;nbsp;– &lt;code&gt;CKDatabaseSubscription&lt;/code&gt;&amp;nbsp;– lets you subscribe to all changes in the whole database (private or shared).&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Types of subscription notifications:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;ol&gt;
&lt;li&gt;1. Silent push:&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let notificationInfo = CKNotificationInfo()

// we only set this, but none of the UI related keys
notificationInfo.shouldSendContentAvailable = true

// do this once. no need to ask the user for push notifications permission,
// since we won't show any visible notifications
application.registerForRemoteNotifications(…)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;ol&gt;
&lt;li&gt;2. Visual notification:&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let notificationInfo = CKNotificationInfo()

// set any of these
notificationInfo.shouldBadge = true
notificationInfo.alertBody = "alertBody"
notificationInfo.soundName = "default"

// we need to prompt the user for push notification access:
application.registerUserNotificationSettings(…)
application.registerForRemoteNotifications(…)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Remember that push notifications can be coalesced, so you may only get one out of a series. Push notifications tell you that *something* has changed, but not necessarily every single thing that has changed.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Creating a subscription:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;This only needs to be done the first time you launch an app&amp;nbsp;– so we set a flag when we create a subscription and the next time we skip this part.&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;if subscriptionIsLocallyCached { return }

let subscription = CKDatabaseSubscription(subscriptionID: "shared-changes")

let notificationInfo = CKNotificationInfo()
notificationInfo.shouldSendContentAvailable = true
subscription.notificationInfo = notificationInfo

let operation = CKModifySubscriptionsOperation(
    subscriptionsToSave: [subscription],
    subscriptionIDsToDelete: []
)

operation.modifySubscriptionsCompletionBlock = { …
    if error != nil {
        …
    } else {
        self.subscriptionIsLocallyCached = true
    }
}

operation.qualityOfService = .utility
self.sharedDB.add(operation)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Listening for pushes:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;ul&gt;
&lt;li&gt;turn on “Remote notifications” and “Background fetch” capabilities&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func application(_ application: UIApplication,
    didReceiveRemoteNotification userInfo: [NSObject: AnyObject],
    fetchCompletionHandler completionHandler: (UIBackgroundFetchResult) -&amp;gt; Void) {

    let dict = userInfo as! [String: NSObject]
    let notification = CKNotification(fromRemoteNotificationDictionary: dict)

    if notification.subscriptionID == "shared-changes" {
        fetchSharedChanges {
              completionHandler(.newData)
        }
    }
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Fetching the changes:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ask in which zones something was changed (in shared db&amp;nbsp;– because there may be new zones added when a new user shares some content)&lt;/li&gt;
&lt;li&gt;ask which records have changed in each relevant zone&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The server will not send you pushes about the changes you’re doing on this device, but you may receive those changes you’ve done on the list when fetching a delta download&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;fetchAllChanges&lt;/code&gt;: previously, in some operations you had to manually check for a flag that says there are more results waiting for you that you need to manually request (i.e. another page)&lt;/p&gt;
&lt;p&gt;Now, CloudKit does the paging automatically for you if &lt;code&gt;fetchAllChanges = true&lt;/code&gt; (which is the default)&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func fetchSharedChanges(_ callback: () -&amp;gt; Void) {
    let changesOperation = CKFetchDatabaseChangesOperation(
        previousServerChangeToken: sharedDBChangeToken  // cached between runs
    )

    // this gives you IDs of changed zones
    changesOperation.recordZoneWithIDChangedBlock = { … }

    // this gives you IDs of deleted zones
    changesOperation.recordZoneWithIDWasDeletedBlock = { … }

    // this gives you the current change token which you need to save
    // may be called multiple times if the operation fetches multiple pages of content
    // save the token each time, so in case of an error you don"t repeat all work
    changesOperation.changeTokenUpdatedBlock = { … }

    changesOperation.fetchDatabaseChangesCompletionBlock = {
        (newToken: CKServerChangeToken?, more: Bool, error: NSError?) -&amp;gt; Void in

        self.sharedDBChangeToken = newToken
        self.fetchZoneChanges(callback)
    }

    self.sharedDB.add(operation)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;fetchZoneChanges&lt;/code&gt; looks very similar, but fetches changes for a specific zone using &lt;code&gt;CKFetchRecordZoneChangesOperation&lt;/code&gt; (you pass it a list of zones)&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;CloudKit best practices:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Automatic authentication:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;CloudKit allows you to authenticate users (if they’re logged in to iCloud) without requiring any private information&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You use the CloudKit user record for authentication&lt;/p&gt;
&lt;p&gt;The user record is unique per container and never changes for that user&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;container.fetchUserRecordID(completionHandler: (CKRecordID?, NSError?) -&amp;gt; Void)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;CKOperation&lt;/code&gt; API:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The convenience API works on single items and it’s simpler to use&lt;/p&gt;
&lt;p&gt;Every convenience API call has a &lt;code&gt;CKOperation&lt;/code&gt; counterpart that lets you perform an operation on a batch of records&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;CKOperation&lt;/code&gt; also has other advantages&amp;nbsp;– for example, it lets you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;set up dependencies between operations&lt;/li&gt;
&lt;li&gt;specify quality of service and queue priorities&lt;/li&gt;
&lt;li&gt;cancel operations that have started executing&lt;/li&gt;
&lt;li&gt;specify if you want the operation to work over cellular network&lt;/li&gt;
&lt;li&gt;limit the number of records or set of fetched keys&lt;/li&gt;
&lt;li&gt;report progress&lt;/li&gt;
&lt;li&gt;… and everything that &lt;code&gt;NSOperation&lt;/code&gt; provides&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;(*) watch the &lt;a href="https://developer.apple.com/videos/play/wwdc2015/226/"&gt;Advanced NSOperations talk from 2015&lt;/a&gt; to learn more about &lt;code&gt;NSOperation&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Quality of service:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;QoS: select a quality of service (&lt;code&gt;.userInteractive&lt;/code&gt; / &lt;code&gt;.userInitiated&lt;/code&gt; / &lt;code&gt;.utility&lt;/code&gt; / &lt;code&gt;.background&lt;/code&gt;) depending on the task priority&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;default is &lt;code&gt;.utility&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.utility&lt;/code&gt; and below enable discretionary networking&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Discretionary networking means that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the system decides when is the best moment to run your request, so it may take longer than you expect&lt;/li&gt;
&lt;li&gt;however, all network failures will be automatically retried for you&lt;/li&gt;
&lt;li&gt;the request gets a timeout period of 7 days by default&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Long lived operations:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you have some operations that you want to continue/retry if they don’t manage to complete by the time your app is terminated, iOS 9.3 adds “CloudKit long lived operations”&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Once you run such operation, the system will finish it even if the app is killed by the system or the user&lt;/p&gt;
&lt;p&gt;The request is executed even if your app isn’t running, the result is cached and is returned to you once the app restarts&lt;/p&gt;
&lt;p&gt;Results are kept by the OS for at least 24 hours&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To use this API:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;set &lt;code&gt;isLongLived = true&lt;/code&gt; on &lt;code&gt;CKOperation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;save the operation’s &lt;code&gt;operationID&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;use &lt;code&gt;CKContainer.fetchLongLivedOperation(withId:)&lt;/code&gt; to get the operation object back&lt;/li&gt;
&lt;li&gt;set completion blocks and run it again just like a new one&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;CKContainer.default().fetchLongLivedOperation(withID: myOpID) {
    (operation: CKOperation?, error: NSError?) in

    let fetchRecords = operation as! CKFetchRecordsOperation
    fetchRecords.fetchRecordsCompletionBlock = { … }

    CKContainer.default().privateCloudDatabase.add(fetchRecords)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Parent references:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A new type of reference added this year to help you better model data, especially with sharing in mind&lt;/p&gt;
&lt;p&gt;If your app supports sharing, it’s recommended that you set the parent reference to create a hierarchy between records&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Example: Album &amp;nbsp;⭢&amp;nbsp; list of photos&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let photoRecord = CKRecord(recordType: "photo")
photoRecord.setParent(albumRecordID)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;What this gives you: when the user shares the album record, the whole record hierarchy under this album (photos and other data) will also be shared&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Types of errors:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;1) Fatal error (bad request)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Error codes like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.internalError&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.serverRejectedRequest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.invalidArguments&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.permissionFailure&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p class="arrow"&gt;→ in this case, you should show an alert to the user and tell them this can’t be executed&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;2) Connection/server error&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Error codes like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.zoneBusy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.serviceUnavailable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.requestRateLimited&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p class="arrow"&gt;→ in this case, check for &lt;code&gt;CKErrorRetryAfterKey&lt;/code&gt; and retry after specified time&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;3) Errors that are returned before connection is even made&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;.networkUnavailable&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;you should monitor network reachability (&lt;code&gt;SCNetworkReachability&lt;/code&gt;) and retry when the device is connected again&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;.notAuthenticated&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;when the user is not logged in and can’t access their private database&lt;/li&gt;
&lt;li&gt;you should register at startup for &lt;code&gt;CKAccountChangedNotification&lt;/code&gt;, and when it fires, recheck account status and update the UI&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc15/cloudkit-tips-and-tricks/</id>
    <title>CloudKit Tips and Tricks</title>
    <published>2020-11-11T18:39:56Z</published>
    <updated>2020-11-11T18:39:56Z</updated>
    <link href="https://mackuba.eu/notes/wwdc15/cloudkit-tips-and-tricks/"/>
    <content type="html">&lt;h3&gt;Error Handling&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Accounts:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To check the account status of the current user:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;container.accountStatusWithCompletionHandler { status, error in … }&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;All APIs that fail because they require an authenticated user return &lt;code&gt;CKErrorNotAuthenticated&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You can now subscribe for &lt;code&gt;CKAccountChangedNotification&lt;/code&gt; to be notified when account status changes&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You should avoid showing alerts to the user about a missing account&amp;nbsp;– simply disable parts of the UI that require an account, and reenable them when you get a notification that an account is now available&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Network errors:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Network connection errors that you may sometimes get: &lt;code&gt;CKErrorNetworkFailure&lt;/code&gt;, &lt;code&gt;CKErrorServiceUnavailable&lt;/code&gt;, &lt;code&gt;CKErrorZoneBusy&lt;/code&gt;, &lt;code&gt;CKErrorRequestRateLimited&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;These errors include a key &lt;code&gt;CKErrorRetryAfterKey&lt;/code&gt; in their user info dictionary that tells you how long you should wait before retrying&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Handling conflicts:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you try to save a record that has been modified in the meantime on the server (meaning: the record change tag you’re sending with the save request is outdated), you will receive the error &lt;code&gt;CKErrorServerRecordChanged&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;There is no magic happening behind the scenes in such case, iCloud doesn’t make assumptions about how you want to resolve conflicts, you need to handle this yourself&lt;/p&gt;
&lt;p&gt;However, the SDK provides you the necessary information in the &lt;code&gt;userInfo:&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CKRecordChangedErrorClientRecordKey&lt;/code&gt;&amp;nbsp;– what you tried to save&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CKRecordChangedErrorAncestorRecordKey&lt;/code&gt;&amp;nbsp;– the original version&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CKRecordChangedErrorServerRecordKey&lt;/code&gt;&amp;nbsp;– what is currently on the server&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Usually you will want to resolve the conflict by applying the same changes that you did on the original record to the current server version of the record&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;CloudKit Operations&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Batch operations:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you create and save a lot of records in one go, each of them will create a separate network request, and making a lot of requests in a short period means you’re likely to hit some kind of rate limit and they will be queued up&lt;/p&gt;
&lt;p&gt;To avoid making multiple similar requests, you can use the &lt;code&gt;CKOperation&lt;/code&gt; API&lt;/p&gt;
&lt;p&gt;Almost every convenience API method that works on one record at a time has a &lt;code&gt;CKOperation&lt;/code&gt; counterpart that allows you to work on a batch of records&lt;/p&gt;
&lt;p&gt;For saving multiple records, use &lt;code&gt;CKModifyRecordsOperation&lt;/code&gt;:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;class CKModifyRecordsOperation: CKDatabaseOperation {
  convenience init(recordsToSave: [CKRecord]?, recordIDsToDelete: [CKRecordID]?)
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Note: there are certain limits on how large batch operations you can make (number of items in the request and total request size)&lt;/p&gt;
&lt;p&gt;This doesn’t include the size of saved binary assets, just the record field data&lt;/p&gt;
&lt;p&gt;If you hit this limit, you will get the &lt;code&gt;CKErrorLimitExceeded&lt;/code&gt; error&lt;/p&gt;
&lt;p&gt;In that case, the best solution is usually to try to divide the batch in half and make two requests&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If one or more records in the batch can’t be saved, you will get &lt;code&gt;CKErrorPartialFailure&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;From this error’s &lt;code&gt;userInfo&lt;/code&gt; you can get a dictionary with specific record errors under &lt;code&gt;CKPartialErrorsByItemIDKey&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;In a standard zone, in such scenario some records will be saved and those with errors won’t&lt;/p&gt;
&lt;p&gt;In a custom zone you can make an atomic update&amp;nbsp;– in this case, in case of a problem with some of the records, no records will actually be saved, but instead you will get an error &lt;code&gt;CKErrorBatchRequestFailed&lt;/code&gt; for those that could have been saved but weren’t&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Queries:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you expect a query to return a large number of records, but you only need a small number of them at a time, you can use the &lt;code&gt;CKQueryOperation.resultsLimit&lt;/code&gt; property&lt;/p&gt;
&lt;p&gt;Also available on &lt;code&gt;CKFetchRecordChangesOperation&lt;/code&gt;, &lt;code&gt;CKFetchNotificationChangesOperation&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;When limiting the number of records, you will usually also want to set the query's &lt;code&gt;sortDescriptors&lt;/code&gt; to e.g. sort records by oldest or newest first&lt;/p&gt;
&lt;p&gt;You can use the &lt;code&gt;creationDate&lt;/code&gt; key which is automatically added to all saved records regardless of type&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To implement pagination and get further pages beyond the first one, use the &lt;code&gt;CKQueryCursor&lt;/code&gt; object that you get in response to the &lt;code&gt;queryCompletionBlock&lt;/code&gt; callback&lt;/p&gt;
&lt;p&gt;Then, initialize the next &lt;code&gt;CKOperation&lt;/code&gt; passing it the cursor object in the argument to the initializer&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you don’t need all information contained in a record immediately, you can also use the &lt;code&gt;desiredKeys&lt;/code&gt; property to only download the keys you want&lt;/p&gt;
&lt;p&gt;E.g. download the record’s thumbnail image but not a full-size photo&lt;/p&gt;
&lt;p&gt;Also available on &lt;code&gt;CKFetchRecordsOperation&lt;/code&gt;, &lt;code&gt;CKFetchRecordChangesOperation&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Maintaining a local cache:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Let's say you want to keep some subset of all data completely cached on all local devices for quicker access (e.g. user’s personal notes)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You have two options:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;CKQueryOperation&lt;/code&gt; to fetch all records and synchronize them manually&lt;/li&gt;
&lt;li&gt;make a custom zone and use delta downloads using &lt;code&gt;CKFetchRecordChangesOperation&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;When saving fetched records to a local database, you should save CKRecord’s system fields like the change tag together with your own data&lt;/p&gt;
&lt;p&gt;To do that, you can use &lt;code&gt;encodeSystemFieldsWithCoder:&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let data = NSMutableData()
let archiver = NSKeyedArchiver(forWritingWithMutableData: data)
archiver.requiresSecureCoding = true
record.encodeSystemFieldsWithCoder(archiver)
archiver.finishEncoding()&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;When restoring a record from the local storage, you don’t have to set all its data fields&amp;nbsp;– it’s fine to only set those you want to change&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To synchronize any changes from the server, create a subscription subscribing to a given record type using silent notifications, and use &lt;code&gt;CKFetchRecordChangesOperation&lt;/code&gt; to fetch all recent changes when notified&lt;/p&gt;
&lt;p&gt;Subscriptions (&lt;code&gt;CKSubscription&lt;/code&gt;) are persistent queries on the server that send remote notifications about a relevant change&amp;nbsp;– either in a specific record set (query subscription) or in the whole zone (zone subscription)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To get CloudKit subscription notifications, you need to follow the usual setup for push notifications:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;have push notification capability enabled for your app&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;registerForRemoteNotifications()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;registerUserNotificationSettings(…)&lt;/code&gt; if you want to show notifications to the user&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To ask for silent subscription notifications, configure the &lt;code&gt;CKNotificationInfo&lt;/code&gt; object appropriately:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;set the &lt;code&gt;shouldSendContentAvailable&lt;/code&gt; key&lt;/li&gt;
&lt;li&gt;do not set any of the UI-related keys: &lt;code&gt;alertBody&lt;/code&gt;, &lt;code&gt;shouldBadge&lt;/code&gt;, &lt;code&gt;soundName&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Notification priorities: a notification is high priority if it has any UI keys set, otherwise it’s medium priority&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;For silent notifications, the &lt;code&gt;UIApplicationDelegate&lt;/code&gt; will receive the following callback:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;func application(application: UIApplication,
	didReceiveRemoteNotification: [NSObject: AnyObject],
	fetchCompletionHandler: (UIBackgroundFetchResult) -&amp;gt; Void) { … }&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Remember that push notification delivery in general is “best effort”&amp;nbsp;– pushes can be dropped if many are received in a short period of time or because of network issues&lt;/p&gt;
&lt;p&gt;Silent notifications may also be additionally delayed if the system is waiting for better conditions&lt;/p&gt;
&lt;p&gt;When you receive a notification, use &lt;code&gt;CKFetchNotificationChangesOperation&lt;/code&gt; to check the server’s notification collection for any notifications you might have missed&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You may want to use a &lt;code&gt;UIApplication&lt;/code&gt; background task (&lt;code&gt;beginBackgroundTaskWithName(…)&lt;/code&gt;) for syncing tasks&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Interactive notifications: you can now make CloudKit notifications interactive (e.g. show action buttons) by setting the &lt;code&gt;category&lt;/code&gt; key on &lt;code&gt;CKNotificationInfo&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Other performance tips&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;CloudKit is a highly asynchronous API, most operations require a network call and take some time to execute&lt;/p&gt;
&lt;p&gt;You will often want to make a series of operations that have some dependencies between them&lt;/p&gt;
&lt;p&gt;Things to keep in mind:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;however you implement task handling, remember to always handle all errors&lt;/li&gt;
&lt;li&gt;never block the main thread with an operation in progress&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Don’t nest calls to the convenience API methods, creating a “callback hell”&lt;/p&gt;
&lt;p&gt;Don’t use locks/semaphores to wait for an API call to finish&lt;/p&gt;
&lt;p&gt;Instead, use the &lt;code&gt;addDependency()&lt;/code&gt; API in &lt;code&gt;CKOperation&lt;/code&gt; to add dependencies between operations:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let firstFetch = CKFetchRecordsOperation(…)
let secondFetch = CKFetchRecordsOperation(…)
secondFetch.addDependency(firstFetch)

let queue = NSOperationQueue()
queue.addOperations([firstFetch, secondFetch], waitUntilFinished: false)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Use the &lt;code&gt;qualityOfService&lt;/code&gt; property on &lt;code&gt;NSOperation&lt;/code&gt; to indicate which operations are something you need in the UI and which are low priority background operations&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;there used to be a &lt;code&gt;usesBackgroundSession&lt;/code&gt; property on &lt;code&gt;CKOperation&lt;/code&gt; too, but it’s deprecated now &amp;nbsp;⭢&amp;nbsp; use quality of service for this&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;QoS = &lt;code&gt;.utility&lt;/code&gt; and &lt;code&gt;.background&lt;/code&gt; use discretionary networking, use &lt;code&gt;.userInteractive&lt;/code&gt; and &lt;code&gt;.userInitiated&lt;/code&gt; for high priority tasks&lt;/p&gt;
&lt;p&gt;Note: &lt;code&gt;.background&lt;/code&gt; QoS is the default if you don’t change it!&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;update:&lt;/strong&gt; now it's &lt;code&gt;.utility&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc14/advanced-cloudkit/</id>
    <title>Advanced CloudKit</title>
    <published>2020-11-03T14:57:14Z</published>
    <updated>2020-11-03T14:57:14Z</updated>
    <link href="https://mackuba.eu/notes/wwdc14/advanced-cloudkit/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;CloudKit API is designed to be asynchronous, all calls return through a callback, because they all require a network connection&lt;/p&gt;
&lt;p&gt;The main API (“operational API”) is based on &lt;code&gt;NSOperation&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You use it by creating special &lt;code&gt;NSOperation&lt;/code&gt; objects for a given use case, e.g. &lt;code&gt;CKFetchRecordsOperation&lt;/code&gt;, and specifying parameters and callbacks in their properties&lt;/p&gt;
&lt;p&gt;Apart from the final result callback, you can set callbacks e.g. for reporting download progress or to get records one by one as they’re downloaded&lt;/p&gt;
&lt;p&gt;Operation lifecycle (cancelling, suspending etc.) can be managed through standard &lt;code&gt;NSOperation&lt;/code&gt; methods and &lt;code&gt;NSOperationQueue&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are separete fetch/modify operation types for records, subscriptions, zones, users and notifications&lt;/p&gt;
&lt;p&gt;You can set dependencies between operations (also if they’re in different queues), e.g. make a fetch operation and then a modify operation that needs to wait for the object to load&lt;/p&gt;
&lt;p&gt;Operations can also have different priority levels&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Starting an operation:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;(ℹ️ Note: this wasn’t in the video, but it really should have been, because it's completely not obvious.)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;How to start an operation once you prepare the &lt;code&gt;CKOperation&lt;/code&gt; object:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;1) Use the database’s built-in queue:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let fetchOperation = ...
CKContainer.default().privateCloudDatabase.add(fetchOperation)&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;2) Use your own operation queue and assign a reference to the database:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: swift"&gt;let operationQueue = NSOperationQueue()

let fetchOperation = ...
fetchOperation.database = CKContainer.default().privateCloudDatabase
operationQueue.addOperation(fetchOperation)&lt;/pre&gt;
&lt;h3&gt;Custom zones&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Custom zones (in the private database) let you compartmentalize data and add some special features&lt;/p&gt;
&lt;p&gt;Records can’t be moved between zones or have cross-zone relationships&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are some operations that can only be done in custom zones:&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Atomic commits:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Objects in the CloudKit database have relationships between them, and you want to keep all data consistent&lt;/p&gt;
&lt;p&gt;Atomic commits are kind of like transactions in a relational database: batch operations succeed or fail together&lt;/p&gt;
&lt;p&gt;Only available in the private database (because public database may be accessed by millions of users at the same time)&lt;/p&gt;
&lt;p&gt;If an operation fails, you get a &lt;code&gt;CKErrorPartialFailure&lt;/code&gt; response, with the user info containing info about errors on specific records (&lt;code&gt;CKPartialErrorsByItemID&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;Error &lt;code&gt;CKErrorBatchRequestFailed&lt;/code&gt; means that this record wasn’t saved because of a problem with another record in the batch&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Delta downloads:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Allow you to download a list of all changes since the last time the app was online, to let you perform a full sync&lt;/p&gt;
&lt;p&gt;When a device connects, you can send a “change token” to the server asking for all changes since that version&lt;/p&gt;
&lt;p&gt;This lets you implement an offline cache of the whole dataset and sync any changes when possible&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To do that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;track all local changes&lt;/li&gt;
&lt;li&gt;send changes to the server when connected&lt;/li&gt;
&lt;li&gt;resolve conflicts&lt;/li&gt;
&lt;li&gt;fetch server changes with &lt;code&gt;CKFetchRecordChangesOperation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;remember the received new server change token and send it back next time&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Zone subscriptions:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Let you subscribe for notifications about any change in the zone&lt;/p&gt;
&lt;p&gt;When you get a notification, you request a delta download&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Advanced record operations&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Record changes:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;When you change some fields in a &lt;code&gt;CKRecord&lt;/code&gt;, the changes are automatically tracked locally and only the changed fields are transmitted when you save it&lt;/p&gt;
&lt;p&gt;By default CloudKit performs a “locked update”, which makes sure that the update is only saved on the server if the record wasn’t modified in the meantime by another client (this uses record change tokens)&lt;/p&gt;
&lt;p&gt;After you execute a save, the server returns your record with a new change token&amp;nbsp;– so you should use that returned version for any subsequent changes&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Unlocked update &amp;nbsp;⭢&amp;nbsp; just overwrites server data regardless what is there&lt;/p&gt;
&lt;p&gt;Locked update &amp;nbsp;⭢&amp;nbsp; if the record was changed in the meantime, you get back an error (&lt;code&gt;CKErrorServerRecordChanged&lt;/code&gt;)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;userInfo&lt;/code&gt; of the &lt;code&gt;CKErrorServerRecordChanged&lt;/code&gt; error contains info that lets you perform a 3-way merge:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CKRecordChangedErrorClientRecordKey&lt;/code&gt;&amp;nbsp;– what you tried to save&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CKRecordChangedErrorAncestorRecordKey&lt;/code&gt;&amp;nbsp;– the original version&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CKRecordChangedErrorServerRecordKey&lt;/code&gt;&amp;nbsp;– what is currently on the server&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Based on the values from these 3 copies of the record you can decide what state the record should be in, and then retry the save&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can modify the behavior with “save policies”:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SaveIfServerUnchanged&lt;/code&gt; &amp;nbsp;⭢&amp;nbsp; default, performs a locked update and sends only changed keys&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SaveChangedKeys&lt;/code&gt; &amp;nbsp;⭢&amp;nbsp; unlocked update, sends only changed keys&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SaveAllKeys&lt;/code&gt; &amp;nbsp;⭢&amp;nbsp; unlocked update, overwrites all keys in the record (note: this doesn’t affect keys that aren’t present in the local copy at all)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You should almost always use the default locked update (&lt;code&gt;SaveIfServerUnchanged&lt;/code&gt;), use unlocked updates only to forcefully resolve serious conflicts&lt;/p&gt;
&lt;p&gt;Use &lt;code&gt;SaveAllKeys&lt;/code&gt; if the user requests to overwrite server data with local data&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Partial records:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The &lt;code&gt;desiredKeys&lt;/code&gt; field present in most operation types lets you specify that you only want to download selected keys from the server&lt;/p&gt;
&lt;p&gt;This is useful if the whole record is very large and you don’t need all of it&lt;/p&gt;
&lt;p&gt;Partial records can be normally saved after a change&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;CloudKit data modeling&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Forward reference &amp;nbsp;⭢&amp;nbsp; a parent object keeps an array of references to children in its property&lt;/p&gt;
&lt;p&gt;Backward reference &amp;nbsp;⭢&amp;nbsp; only child objects have a reference to the parent&lt;/p&gt;
&lt;p&gt;It’s recommended to use backward references&amp;nbsp;– with a forward reference you need to update the parent object every time a new child is added, and you will run into conflicts if multiple clients are adding records&lt;/p&gt;
&lt;p&gt;To get a list of all children using backward references, make a query for all child records with a predicate “owner = X”&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;References give you cascading deletes&amp;nbsp;– when you delete the parent object, all child objects and their children are deleted&lt;/p&gt;
&lt;p&gt;If an object has two parent references, it’s deleted when the first parent is deleted&lt;/p&gt;
&lt;p&gt;When batch uploading a tree of objects, CloudKit makes sure that parent objects are uploaded first so that you don’t get inconsistent data during upload (important in the public database)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Your data objects:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;CloudKit is only a transport mechanism and requires you to keep and manage your own local copy of all data&lt;/p&gt;
&lt;p&gt;It’s recommended that you don’t subclass &lt;code&gt;CK*&lt;/code&gt; objects to build your models&amp;nbsp;– make your own completely independent model classes and translate to/from CloudKit objects when fetching and saving&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Handling push notifications:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You need to remember that push notifications in general aren’t guaranteed to be delivered&lt;/p&gt;
&lt;p&gt;The server only stores one push per client, so if you reconnect e.g. after a flight, you might miss some previous notifications&lt;/p&gt;
&lt;p&gt;You can find pushes that you’ve missed in a “Notification Collection” where every notification is saved&lt;/p&gt;
&lt;p&gt;The Notification Collection works kind of like delta updates&amp;nbsp;– you ask for notifications since a given change token and you get a list of everything added since then&lt;/p&gt;
&lt;p&gt;You can mark a notification as read, which notifies all other clients that they can ignore it&lt;/p&gt;
&lt;p&gt;You should check the Notification Collection every time you get a push, since you never know what you might have missed (this doesn’t only happen with airplane mode)&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;The iCloud Dashboard&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;The dashboard lets you browse data saved by your app&amp;nbsp;– the whole public database and the private database for your developer account (but not anyone else’s private database)&lt;/p&gt;
&lt;p&gt;You can view saved records, run queries with any filters, and add new records&lt;/p&gt;
&lt;p&gt;You can define roles in the public database and define for each model who can create/read/modify records (e.g. specify that records are publicly readable but only an admin can create them)&lt;/p&gt;
&lt;p&gt;You will also see a list of all user ids and first/last names of those users that marked themselves as discoverable&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Schema:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;The CloudKit database has two separate “environments”: development and production&lt;/p&gt;
&lt;p&gt;The schema for each record type is “just in time” during development, i.e. when you save a new type of record, it automatically creates a new schema for that record type, recording every field type, and when you save a record with a new field, it adds a field to the list&lt;/p&gt;
&lt;p&gt;However, once you’re ready to release a new version of your app, you need to save the schema to production and at that point it’s locked&amp;nbsp;– a production version of the app can’t save records or fields that aren’t defined in the schema&lt;/p&gt;
&lt;p&gt;CloudKit also automatically creates indexes for each field in each record type&amp;nbsp;– when you’re done with development, you can delete some indexes that you won’t need so they don’t waste space in the production database&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Tips &amp; tricks&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Please handle all errors&amp;nbsp;:)&lt;/p&gt;
&lt;p&gt;Remember that you can get partial errors (when atomic commits aren’t used), so some records might be saved while others aren’t&lt;/p&gt;
&lt;p&gt;Retry any “server busy” errors (&lt;code&gt;CKErrorRetryAfterKey&lt;/code&gt; tells you the amount of time you should wait)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Don’t waste space in your users’ iCloud in private databases, they may be paying real money for it&lt;/p&gt;
&lt;p&gt;Limits in the public database are mostly to prevent abuse, they should be fine for most normal use (the limits scale with the number of users)&lt;/p&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc12/icloud-storage-overview/</id>
    <title>iCloud Storage Overview</title>
    <published>2020-11-01T20:52:45Z</published>
    <updated>2020-11-01T20:52:45Z</updated>
    <link href="https://mackuba.eu/notes/wwdc12/icloud-storage-overview/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;iCloud Storage APIs allow you to store your app’s data in iCloud&lt;/p&gt;
&lt;p&gt;System services sync your data automatically even when your app isn’t running&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;3 different types of storage:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;key-value storage&amp;nbsp;– simple storage for things like preferences, game state (“it’s so simple, we actually don’t have another session talking about it”)&lt;/li&gt;
&lt;li&gt;document storage&amp;nbsp;– a filesystem in the cloud scoped for your application, where you can store any kinds of files and folders, synced between devices; ideal for productivity apps like iWork&lt;/li&gt;
&lt;li&gt;Core Data storage&amp;nbsp;– an extension for Core Data that lets you store Core Data databases in the cloud [note: deprecated in 2016]&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;What iCloud handles for you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;account setup&amp;nbsp;– users don’t need to create a new account for your service, they already have an iCloud account&lt;/li&gt;
&lt;li&gt;APIs for your apps integrated into the OSX/iOS SDKs&lt;/li&gt;
&lt;li&gt;server code you don’t have to write, for things like load balancing, replication, backup and recovery&lt;/li&gt;
&lt;li&gt;the personnel handling the servers, support etc.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;em&gt;“If you happen to know some friends, who like writing server code that scales to hundreds of millions of users, send them our way&amp;nbsp;– we’re hiring”&lt;/em&gt; :D&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;How it works:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Key-value storage:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Accessed through &lt;code&gt;NSUbiquitousKeyValueStore&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Lets you put simple plist-type values into the cloud&lt;/p&gt;
&lt;p&gt;It talks to a key-value service running on the device which talks to iCloud on your behalf&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Document storage:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Use &lt;code&gt;UIDocument&lt;/code&gt; (iOS) / &lt;code&gt;NSDocument&lt;/code&gt; (OSX) / &lt;code&gt;UIManagedDocument&lt;/code&gt; (Core Data)&lt;/p&gt;
&lt;p&gt;You can also use lower level file storage APIs like &lt;code&gt;NSFileCoordination&lt;/code&gt;, &lt;code&gt;NSFilePresenter&lt;/code&gt;, &lt;code&gt;NSMetadataQuery&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;All these APIs (also the Core Data iCloud API) talk to OS’s document service which talks to the iCloud for you&lt;/p&gt;
&lt;p&gt;You never actually interact with the iCloud servers yourself, you just use these APIs in the SDK and system services&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;How to enable iCloud in your project:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;ul&gt;
&lt;li&gt;your app needs to be distributed through the App Store [note: no longer true for Mac apps]&lt;/li&gt;
&lt;li&gt;enable the relevant entitlement: &lt;code&gt;com.apple.developer.ubiquity-kvstore-identifier&lt;/code&gt; and/or &lt;code&gt;com.apple.developer.ubiquity-container-identifiers&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h3&gt;Working with Key Value Storage:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NSUbiquitousKeyValueStore&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Lets you store simple plist values&amp;nbsp;– strings, numbers, booleans, dictionaries and arrays of those&lt;/p&gt;
&lt;p&gt;Similar API to &lt;code&gt;UserDefaults&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Simple conflict resolution: if two devices independently change or add a value for a given key, the latest change wins&lt;/p&gt;
&lt;p&gt;Works even if iCloud isn’t configured&amp;nbsp;– in this case it just acts as local user defaults that don’t sync&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Improvements from last year’s initial release:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;increased capacity&amp;nbsp;– now up to 1024 keys, up to 1 MB per application (not part of the total user quota); it’s fine to have just one key of 1 MB&lt;/li&gt;
&lt;li&gt;improved responsiveness (allows around 15 requests every 90 seconds)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;How to set up:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;// get a reference to the store
NSUbiquitousKeyValueStore *store = [NSUbiquitousKeyValueStore defaultStore];

// observe changes
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
           selector:@selector(kvStoreDidChange:)
               name:NSUbiquitousKeyValueStoreDidChangeExternallyNotification
             object:nil];

// ask for any changes since the last launch
[store synchronize];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Making changes:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;[store setObject:someObject forKey:@"someKey"];
[store setBool:YES forKey:@"someOtherKey"];

[store synchronize];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;It’s recommended to also store another copy of the same data locally, so that you can do conflict resolution manually in case if just keeping the latest change isn’t always the right strategy for your app&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Handling notifications about a change:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;- (void)kvStoreDidChange:(NSNotification *)notification {
    NSDictionary *userInfo = [notification userInfo];

    // get change reason
    int reason = [userInfo objectForKey:NSUbiquitousKeyValueStoreChangeReasonKey];
    NSArray *changedKeys = [userInfo objectForKey:NSUbiquitousKeyValueStoreChangedKeysKey];

    // … store values locally, do conflict resolution etc.
}&lt;/pre&gt;
&lt;h3&gt;Working with document storage:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Apart from your app’s container, each app has a separate “ubiquity container” (or iCloud container)&lt;/p&gt;
&lt;p&gt;The app can put any files inside that container, and whatever is put there is synced with other devices via iCloud, kind of like Dropbox&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;When you put a file in the ubiquity container, the file is broken into chunks and the chunks that are new or modified are uploaded to iCloud&amp;nbsp;– so if you only change a few bytes of the file, most of it doesn’t need to be uploaded again&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Metadata about the file is uploaded first&amp;nbsp;– info about the file name, type, size etc.&lt;/p&gt;
&lt;p&gt;So every device knows about each file that’s uploaded, but it doesn’t necessarily have to download each file&amp;nbsp;– the decision is made independently on the device depending on the platform and settings: OSX usually downloads all files if it has space, iOS only downloads files on demand&lt;/p&gt;
&lt;p&gt;Once a file is downloaded to the device, all later changes are also automatically synced&lt;/p&gt;
&lt;p&gt;Local peer to peer communication is used if possible&amp;nbsp;– e.g. if you have a file on the Mac, your iPhone will copy it from the Mac instead of the iCloud network&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Automatic conflict resolution&amp;nbsp;– if a file is edited on two devices in parallel, the system picks a winner automatically, but your application gets access to both versions and can override it if needed&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;URL publishing: you can make the current version of the document public and available through a URL which you can share with others (if it’s changed later in the iCloud, the URL still downloads that previous version)&lt;/p&gt;
&lt;p&gt;URLs are not permanent, they expire after some time&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Detecting an iCloud account:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Unlike key-value storage, using this API requires that the user has an iCloud account&lt;/p&gt;
&lt;p&gt;You can check for an “Ubiquity Identity Token” to see if they have an account configured&lt;/p&gt;
&lt;p&gt;The token is anonymous so it doesn’t tell you anything about the user, but it will change if the user switches to another account (you also get a notification then)&lt;/p&gt;
&lt;p&gt;The token is also unique to your app and to this specific device, so the same app will get a different token from that user on another device&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;id token = [[NSFileManager defaultManager] ubiquityIdentityToken];
if (token) {
    // cache the token
    // the next time the app launches, check if it has changed
}

// register for the identity changed notification
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center addObserver:self
           selector:@selector(handleUbiquityIdentityChanged:)
               name:NSUbiquityIdentityDidChangeNotification
             object:nil];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;When the account changes, clear any local caches specific to this account and refresh the UI&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To access the ubiquity container, you ask for the container URL&amp;nbsp;– note that the container will be created on demand the first time you ask for it; this should ideally not be called on the main thread&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    containerURL = [fileManager URLForUbiquityContainerIdentifier:nil];
});&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Types of documents you can store in the document storage:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;normal files, also symlinks&lt;/li&gt;
&lt;li&gt;directories of files&lt;/li&gt;
&lt;li&gt;packages&amp;nbsp;– bundles of files that act as a single document&lt;/li&gt;
&lt;li&gt;Core Data stores&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;File extended attributes are also synced&lt;/p&gt;
&lt;p&gt;Watch out for filesystem case sensitivity issues&amp;nbsp;– users running on a Mac might have a case-sensitive filesystem&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;For packages, iCloud updates only the files from a package that have been changed, but it handles updates to the whole package atomically, so you will not get a package in an inconsistent state&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Core Data:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Useful for so-called “shoebox style applications”, like iPhoto or iTunes, which work with a single database in app’s own format&lt;/p&gt;
&lt;p&gt;The Core Data store remains local and only change logs are uploaded to iCloud&lt;/p&gt;
&lt;p&gt;Not recommended to use binary and XML stores, because in those cases every change modifies the whole file (only use those for small data sets that don’t change often)&amp;nbsp;– use SQLite stores for iCloud sync instead&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;UIManagedDocument&lt;/code&gt;&amp;nbsp;– a subclass of &lt;code&gt;UIDocument&lt;/code&gt; for managing Core Data stores that supports syncing them with iCloud&lt;/p&gt;
&lt;p&gt;Note: &lt;code&gt;NSPersistentDocument&lt;/code&gt;, the AppKit equivalent, does not support iCloud&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;Designing your document format for iCloud:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;Design with network efficiency in mind&amp;nbsp;– don’t write a lot of changes very often&lt;/p&gt;
&lt;p&gt;Keep in mind any possible differences between platforms&lt;/p&gt;
&lt;p&gt;Plan for future app upgrades&amp;nbsp;– include version number in the format and keep compatibility if possible&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Beware of sync loops&amp;nbsp;– when one instance of the app receives a change, merges it and writes back the result, and the other side does the same and triggers a change in the first copy again&lt;/p&gt;
&lt;p&gt;&lt;em&gt;“And you have two versions of the app playing ping-pong with the user’s iCloud account”&lt;/em&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Avoid making rapid changes to the file, e.g. updating some position tag while the user is scrolling the document&lt;/p&gt;
&lt;p&gt;Don’t put the last open date into the document itself, so that opening it doesn’t count as making a change&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Use iCloud for user data only: don’t put any caches, temporary files or auto-generated content there&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Think about privacy when allowing the user to publish a document: don’t include any sensitive info or things they might not be aware they’re publishing (e.g. undo history) in the publicly accessible view&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;APIs:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NSFileManager&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;NSFileCoordinator&lt;/code&gt; &amp;amp; &lt;code&gt;NSFilePresenter&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;NSMetadataQuery&lt;/code&gt;&amp;nbsp;– use a live metadata query to be notified of new files and changes before the file contents are downloaded&lt;/p&gt;
&lt;p&gt;&lt;code&gt;NSDocument&lt;/code&gt; &amp;amp; &lt;code&gt;UIDocument&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UIManagedDocument&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;code&gt;NSDocument/UIDocument&lt;/code&gt; handle most of the integration with iCloud for you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;using the ubiquity container&lt;/li&gt;
&lt;li&gt;coordinating with the OS&lt;/li&gt;
&lt;li&gt;tracking files and their versions&lt;/li&gt;
&lt;li&gt;resolving conflicts&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Tips:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;ul&gt;
&lt;li&gt;subclass native document types&lt;/li&gt;
&lt;li&gt;use the default auto-save behavior&lt;/li&gt;
&lt;li&gt;if the app provides a prepopulated Core Data store on first launch, use a migration instead of copying a packaged store file&lt;/li&gt;
&lt;li&gt;track documents using &lt;code&gt;NSMetadataQuery&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;if it makes sense for your app, manually control conflict resolution (&lt;code&gt;UIDocumentStateInConflict&lt;/code&gt;); avoid user involvement if possible&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Tips for debugging:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;ul&gt;
&lt;li&gt;test with multiple devices&lt;/li&gt;
&lt;li&gt;monitor network traffic&lt;/li&gt;
&lt;li&gt;use Airplane Mode to create conflicts artificially&lt;/li&gt;
&lt;li&gt;there will be a configuration profile available that makes the document service log additional messages&lt;/li&gt;
&lt;li&gt;&lt;code&gt;developer.icloud.com&lt;/code&gt;&amp;nbsp;– a web tool that shows you all iCloud storage on your account from various apps [note: not available anymore, replaced with CloudKit dashboard, but it only shows CloudKit data]&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
</content>
  </entry>
  <entry>
    <id>https://mackuba.eu/notes/wwdc14/introducing-cloudkit/</id>
    <title>Introducing CloudKit</title>
    <published>2020-11-01T20:52:04Z</published>
    <updated>2020-11-01T20:52:04Z</updated>
    <link href="https://mackuba.eu/notes/wwdc14/introducing-cloudkit/"/>
    <content type="html">&lt;div class="block"&gt;
&lt;p&gt;CloudKit lets you write client applications without having to build and host a server part to handle things like database, accounts or push notifications&lt;/p&gt;
&lt;p&gt;Usage is free for the developer up to pretty big limits&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;CloudKit gives you more direct access to iCloud servers&lt;/p&gt;
&lt;p&gt;It’s the framework that’s used behind the scenes by iCloud Photo Library and iCloud Drive&lt;/p&gt;
&lt;p&gt;Uses the same iCloud account as iCloud documents or key-value storage&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Two types of databases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;public&amp;nbsp;– accessible to everyone&lt;/li&gt;
&lt;li&gt;private&amp;nbsp;– private data of a specific user&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;It’s only a transport technology, it does not deal with local data persistence&amp;nbsp;– you need to decide how you store the data that you load from the cloud&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;To enable iCloud in your app, set it up in the Capabilities tab in Xcode just like with other iCloud APIs&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Containers:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Each app’s data is kept in a separate container (&lt;code&gt;CKContainer&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;Containers give you the safety that your app’s data will not be mixed with someone else’s app’s data&lt;/p&gt;
&lt;p&gt;A container’s ID needs to be unique in the whole iCloud, so use reverse-domain style identifiers&lt;/p&gt;
&lt;p&gt;By default each app has one container of its own, but apps can additionally use shared containers&lt;/p&gt;
&lt;p&gt;Containers are managed by the developer through the WWDR portal&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Databases:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A container contains one shared public database for everyone, and separate private databases for each user&lt;/p&gt;
&lt;p&gt;An app running on the device has access to one public and one private database (&lt;code&gt;CKDatabase&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;The database is the initial entry point to CloudKit (from a container)&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;CKDatabase *publicDatabase = [[CKContainer defaultContainer] publicCloudDatabase];
CKDatabase *privateDatabase = [[CKContainer defaultContainer] privateCloudDatabase];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Private database:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;requires a logged in iCloud account&lt;/li&gt;
&lt;li&gt;data stored counts against the user’s iCloud account quota&lt;/li&gt;
&lt;li&gt;default permission for data is user readable&lt;/li&gt;
&lt;li&gt;the data your users store in your app’s CloudKit container is *not* accessible to you&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Public database:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;can be accessed anonymously even if the user isn’t logged in&lt;/li&gt;
&lt;li&gt;data stored counts against the developer’s app quota&lt;/li&gt;
&lt;li&gt;default permission for data is world readable&lt;/li&gt;
&lt;li&gt;permissions can be customized using iCloud Dashboard Roles&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Records:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A record (&lt;code&gt;CKRecord&lt;/code&gt;) is a single “object” in the CloudKit database, essentially a list of key-value pairs&lt;/p&gt;
&lt;p&gt;Records have a Record Type (~ table name)&lt;/p&gt;
&lt;p&gt;There is no defined up front schema, you can just save a record of any type with any keys and the schema will be updated based on that&lt;/p&gt;
&lt;p class="arrow"&gt;→ note: this only works in development, the schema is fixed in production, see &lt;a href="/notes/wwdc14/advanced-cloudkit/"&gt;"Advanced CloudKit"&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Records can have metadata: who created it &amp;amp; modified it and when, also includes a “change tag” (version id)&amp;nbsp;– for determining if two sides have the same version of a record&lt;/p&gt;
&lt;p&gt;Record values can be: strings, numbers, dates, &lt;code&gt;NSData&lt;/code&gt;, &lt;code&gt;CLLocation&lt;/code&gt;, &lt;code&gt;CKReference&lt;/code&gt;, &lt;code&gt;CKAsset&lt;/code&gt;, arrays of any of these&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;- (instancetype)initWithRecordType:(NSString *)recordType;
- (id)objectForKey:(NSString*)key;
- (void)setObject:(id)object forKey:(NSString *)key;
- (NSArray *)allKeys;&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Subscripts also work:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;CKRecord *party = [[CKRecord alloc] initWithRecordType:@"Party"];
party[@"start"] = [NSDate date];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Record zones:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Records are grouped within a database inside “zones” (&lt;code&gt;CKRecordZoneID&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;The public database has one zone, the private database has one default zone, but it can have additional custom zones&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Record identifiers:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Record identifier (&lt;code&gt;CKRecordID&lt;/code&gt;) is a tuple grouping: a “record name” + zone ID&lt;/p&gt;
&lt;p&gt;You can provide a &lt;code&gt;recordID&lt;/code&gt; when creating a record instance&lt;/p&gt;
&lt;p&gt;If you don’t provide a &lt;code&gt;recordID&lt;/code&gt;, a random UUID will be assigned&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A reference (&lt;code&gt;CKReference&lt;/code&gt;) is a pointer from one record to another, as an id of the “parent” record contained in a child record’s field&lt;/p&gt;
&lt;p&gt;References allow you to do cascade deletes, deleting child records when parent is deleted&lt;/p&gt;
&lt;p&gt;You can create a reference from a &lt;code&gt;CKRecord&lt;/code&gt; object or from a &lt;code&gt;CKRecordID&lt;/code&gt; if you know the ID but don’t have the object in memory&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Assets:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;An asset (&lt;code&gt;CKAsset&lt;/code&gt;) is an unstructured piece of data, basically a binary file&lt;/p&gt;
&lt;p&gt;Assets are downloaded and uploaded from/to files on disk, not from memory&lt;/p&gt;
&lt;p&gt;An asset is always owned by a record, and is deleted when the record is deleted&lt;/p&gt;
&lt;p&gt;Transport of assets is optimized so that only the minimal amount of data is transferred&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;APIs:&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;There are two different APIs for managing CloudKit data: “operational API” and “convenience API”&lt;/p&gt;
&lt;p&gt;The operational API has every possible operation you might need, the convenience API is more convenient&lt;/p&gt;
&lt;p&gt;Start with the convenience API, use operational API for tweaking and overriding options if needed&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;CloudKit APIs for saving/fetching data are asynchronous&amp;nbsp;– there is no SDK-managed local data, everything needs to go over the network unless you manually cache it&lt;/p&gt;
&lt;p&gt;In CloudKit it’s absolutely necessary to properly handle error cases&amp;nbsp;– every network call can fail and your app needs to be prepared for this&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Convenience API:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;[publicDatabase saveRecord:obj completionHandler: { … }];
[publicDatabase retchRecordWithID:recordID completionHandler: { … }];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Queries:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;For any large database, or the shared public database, you shouldn’t try to keep a copy of the whole database on disk and sync all of it, but instead fetch what you need on demand&amp;nbsp;– for this you can use queries&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;A query (&lt;code&gt;CKQuery&lt;/code&gt;) allows you to fetch a list of records matching some conditions&lt;/p&gt;
&lt;p&gt;Query can specify a &lt;code&gt;RecordType&lt;/code&gt;, &lt;code&gt;NSPredicate&lt;/code&gt; and optionally &lt;code&gt;NSSortDescriptors&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;A subset of &lt;code&gt;NSPredicate&lt;/code&gt; language is supported, if something is not supported you’ll get an exception&lt;/p&gt;
&lt;p&gt;Predicates such as “equal”, “greater than”, “distance to location”, string tokenizing, and OR / AND are supported&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;[publicDatabase performQuery:query inZoneWithID:nil completionHandler: { … }];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Subscriptions:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;If you repeatedly run the same query, polling for the same data, you can ask the server to run the query for you and notify you immediately when a new record is added&lt;/p&gt;
&lt;p&gt;You do that by creating a subscription (&lt;code&gt;CKSubscription&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;A subscription includes: &lt;code&gt;RecordType&lt;/code&gt;, &lt;code&gt;NSPredicate&lt;/code&gt; and push configuration (&lt;code&gt;CKNotificationInfo&lt;/code&gt;)&lt;/p&gt;
&lt;p&gt;Your app is notified of changes through a push notification with some additional data&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;CKSubscription *subscription =
  [[CKSubscription alloc] initWithRecordType:@"Party"
                                   predicate:predicate
                                     options:CKSubscriptionOptionsFiresOnRecordCreation];

[publicDatabase saveSubscription:subscription completionHandler: { … }];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Pushes are handled through the usual push API:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;- (void)application:(UIApplication *)application
    didReceiveRemoteNotification:(NSDictionary *)userInfo;&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Build a &lt;code&gt;CKNotification&lt;/code&gt; object from the user info:&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;CKNotification *cloudKitNotification =
    [CKNotification notificationFromRemoteNotificationDictionary:userInfo];

NSString *alertBody = cloudKitNotification.alertBody;

if (cloudKitNotification.notificationType == CKNotificationTypeQuery) {
    CKQueryNotification *queryNotification = cloudKitNotification;
    CKRecordID *recordID = [queryNotification recordID];
}&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;Handling user accounts:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Your application does not get direct access to any user identifiers like iCloud email address&lt;/p&gt;
&lt;p&gt;Instead, in each container each user is represented as a unique ID within that container that doesn’t change unless the user switches to another account&lt;/p&gt;
&lt;p&gt;The same user will have a different ID in a different CloudKit container&lt;/p&gt;
&lt;p&gt;The ID is an instance of &lt;code&gt;CKRecordID&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;[[CKContainer defaultContainer] fetchUserRecordIDWithCompletionHandler: { … }];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Each user has a user record representing them (which is almost like any other record) with their user id and record type = &lt;code&gt;CKRecordTypeUserRecord&lt;/code&gt;, one in the private database, and another with the same ID in the public database&lt;/p&gt;
&lt;p&gt;You can set and read any key-value data on this record like on other records&lt;/p&gt;
&lt;p&gt;However, these records aren’t created by you and can’t be queried to get a list of users&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;[publicDatabase fetchRecordWithID:userRecordID completionHandler: { … }];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;&lt;strong&gt;User discovery:&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;You can ask the user to allow you to make them discoverable by other users (they get a request popup)&lt;/p&gt;
&lt;p&gt;If they agree, they can be looked up by user ID, specific email, or by fetching a list of all users matching your user’s contacts from the address book (this doesn’t give your app access to the address book itself, just a list of matching users)&lt;/p&gt;
&lt;p&gt;You get back record IDs, first &amp;amp; last names of users, but no emails&lt;/p&gt;
&lt;/div&gt;
&lt;pre class="brush: objc"&gt;[defaultContainer discoverAllContactUserInfosWithCompletionHandler: { … }];&lt;/pre&gt;
&lt;div class="block"&gt;
&lt;p&gt;Returns an array of &lt;code&gt;CKDiscoveredUserInfo&lt;/code&gt; objects with properties &lt;code&gt;userRecordID&lt;/code&gt;, &lt;code&gt;firstName&lt;/code&gt;, &lt;code&gt;lastName&lt;/code&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;[note: this API has been replaced since then with a new one that returns &lt;code&gt;CKUserIdentity&lt;/code&gt; objects]&lt;/p&gt;
&lt;/div&gt;
&lt;h3&gt;When to use CloudKit vs. other APIs?&lt;/h3&gt;
&lt;div class="block"&gt;
&lt;p&gt;CloudKit doesn’t replace or deprecate any existing iCloud APIs [yet ;P], it’s just an additional tool&lt;/p&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;Key-value store:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;asynchronous, small amounts of data&lt;/li&gt;
&lt;li&gt;mostly for application preferences&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;iCloud Drive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;works on files and folders&lt;/li&gt;
&lt;li&gt;on OSX it makes a full offline cache of the drive&lt;/li&gt;
&lt;li&gt;good for document-centric apps&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;iCloud Core Data:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;built on top of iCloud Drive&lt;/li&gt;
&lt;li&gt;good for keeping private, structured data (custom databases) in sync&lt;/li&gt;
&lt;li&gt;note: the whole data set is downloaded to each device&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="block"&gt;
&lt;p&gt;CloudKit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;good for sharing public data between users, both structured data and large files&lt;/li&gt;
&lt;li&gt;good for large data sets where not every device needs to have a copy of the whole database&lt;/li&gt;
&lt;li&gt;for attaching some data to the user’s identity and sharing info between users that know each other&lt;/li&gt;
&lt;li&gt;more low-level, your app is in control of when any information is downloaded or uploaded to the iCloud servers, and has responsibility for handling sync&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&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 - 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 - run - find an error - 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 - 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 - 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?1777132788" 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?1777132788" 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 - 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 - 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; - 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 - 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 - 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 - 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 - from things like sulphur oxides, nitrogen oxides, benzene etc. - 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 - 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 - 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 - 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 - 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 - 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?1777132788" 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 - 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?1777132788" 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?1777132788" 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 - 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 - 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?1777132788" 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 - 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 - 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?1777132788" 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 - 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?1777132788" 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 - 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?1777132788" 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 - 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?1777132788" 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; - 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; - 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 - 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 - 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 - 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; - 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?1777132788" 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 - 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 - 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 - 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; - 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; - 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 - 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 - 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 - 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 - 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; - 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?1777132788" 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?1777132788" 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>
</feed>
