mackuba.eu's Avatar

mackuba.eu

@mackuba.eu.web.brid.gy

Kuba Suder's blog on Mac & iOS development [bridged from https://mackuba.eu/ on the web: https://fed.brid.gy/web/mackuba.eu ]

7 Followers  |  0 Following  |  176 Posts  |  Joined: 18.07.2024  |  3.8115

Latest posts by mackuba.eu.web.brid.gy on Bluesky

How I ran one Ruby app on three SQL databases for six months Since June 2023, I’ve been running a service written in Ruby (Sinatra) that provides several Bluesky custom feeds (initially built with a feed for the iOS/Mac developers community 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 endpoint. The Bluesky AppView (API server) then calls your service passing some request parameters, and your service responds with a list of URIs of posts (which the API 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’re interested, check out my example feed service project.) In order to provide such service, in practice you need to connect to the Bluesky “firehose” streaming API 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 chose the latter, since that lets me retry the matching at any time after I modify the keyword lists and see what would be added after that change (and also some of the feeds I now run require having all posts). I also use the same database/service to generate e.g. the total daily/weekly stats here. 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 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. In addition to that, the firehose stream (if you use the original CBOR stream from a relay, not Jetstream, 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.) On disk, the millions of posts per day add up to a few gigabytes per day. Since I was running this on a VPS with a 256 GB disk (Netcup, RS 1000 – reflink), I 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 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. And until March this year, I was keeping all this data in… SQLite 🫠 * * * ## Chapter 1: You’re probably wondering how I ended up in this situation I think I had never used SQLite in a web app before this – I normally always used MySQL, since that was commonly used in PHP first and then in Ruby webapps (Rails/Sinatra w/ ActiveRecord). I was used to it, I knew how it worked more or less, and I always thought SQLite was only meant to be used in embedded scenarios like native desktop/mobile apps. But the example feed-generator project in JS shared by Bluesky used SQLite, and since I started out by porting that to Ruby, I 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 expected, with much more data than I expected, and it turns out it can be absolutely ok for server/webapp database purposes, at least in some scenarios. But eventually I started hitting some limitations. 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 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. However… at some point I added a second parallel thread, which separately reads data from the plc.directory 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 sometimes run Rake tasks manually to e.g. rebuild feeds. And this is where I started running into a second related problem: how this concurrent writing is/was handled in ActiveRecord. I don’t know if I can explain this all correctly (see this GitHub issue and this StackOverflow comment), but the gist is, ActiveRecord used some mode of data locking in SQLite, which resulted in a flow like this: 1. A transaction is started which locks the data for reading. 2. Some records are loaded and turned into AR models (through a `where`/`find_by` call etc.). 3. Some updates are made to the model objects. 4. When I call `save` on the model, AR tries to change the lock mode that would allow it to also make a write. 5. But something else has made other writes in the meantime. 6. 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 (`SQLite3::BusyException: database is locked`). 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: * trying to avoid doing multiple writing operations in parallel at all * rearranging the code a bit artificially so it opens a transaction and then _first_ 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: 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 or so that it does the write without touching the original model, e.g.: # instead of: @post.update(thread_id: root.id) # do: Post.where(id: @post.id).update_all(thread_id: root.id) This has mostly let me avoid the problem for a long time, but this meant it kept popping back up sometimes, and I 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 plc.directory thread, I 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. (Ironically, the ActiveRecord folks finally fixed this whole problem in the 8.0 release, just as I finished migrating away from SQLite…) A second problem was that I started having some performance issues that I 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 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. When I was asking for advice, everyone was telling me “dude, just switch to Postgres” 😛 But I haven’t really worked with Postgres before other than briefly, and I knew some things were different there, and I wasn’t sure if I want to switch to something unknown rather than what I knew (MySQL). And since I have way too much time, no life, and probably a good bit of neurodivergence, I chose the most obvious solution: set up the app on _both_ MySQL and on Postgres on two separate & identical VPSes, and compare how it works in both versions… 🫣 * * * ## Chapter 2: Getting it to run 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 `Gemfile` and `database.yml` – who would’ve guessed… Beyond the obvious, here are some things I had to change on the **MySQL branch** : Some column type changes: * integer column sizes – in SQLite, all numbers are just integers of any size, so here I changed some to `smallint` and some to `bigint` * similarly, some `string` columns were changed to `text` * for `datetime` columns, I’ve set the decimal precision explicitly to 6 digits (this is now the default since AR 7.0, I started on 6.x for some reason) In queries: * I removed some index hacks like `.where("+thread_id IS NULL”)`, which tell the SQLite query optimizer to use/not use a given index * some date operations had to be rewritten to use different functions, e.g. `DATETIME('now', '-7 days')` to `SUBDATE(CURRENT_TIMESTAMP, 7)` * some queries had to be rewritten or updated because they were just throwing SQL syntax errors – e.g. I had to explicitly list table names on some fields in `SELECT`; or there was this thing in MySQL where I had to nest a subquery for DELETE one level deeper * `ActiveRecord::Base.connection.execute` returns rows as arrays instead of hashes for some reason, indexed by `[0]` not by `['field']` For **Postgres** , in addition to most of the above: * I had to replace `0` / `1` used as false/true in boolean columns with an explicit `FALSE` / `TRUE` * date functions in queries were slightly different again, e.g. `DATE_SUBTRACT(CURRENT_TIMESTAMP, INTERVAL '7 days')` * 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 `"post_id"` (what SQLite and MySQL use the backticks for: ``post_id``) I 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. * * * ## Chapter 3: Migrating the data For migration to MySQL, I used the sqlite3mysql tool, written in Python: 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 ... For the posts table (which is the vast majority of the database size), I used the `-c` (`--chunk`) option to import posts in batches: sqlite3mysql -K -E -f bluesky.sqlite3 -d bluefeeds_production -u kuba -i DEFAULT -t posts -c 1000000 For Postgres, I used pgloader. 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: 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'; I’ve split the tables into several command files, because I wanted to do it a bit more step by step since some of the imports were failing. For the posts table, I’ve similarly set a “prefetch rows” flag to do it in batches: load database from sqlite://./db/bluesky.sqlite3 into postgresql:///bluefeeds_production with data only, truncate, prefetch rows = 10000 including only table names like 'posts'; 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’s finished, and even if some tables fail to import, it’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 don’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). One surprise realization I 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 had a whole bunch of records in some tables where the values were much longer than the expected max length… because I was missing Ruby-side AR validations (`validates_length_of`) in some models – and those records were being rejected by both new databases. So I had to add all those missing length validations and clean up the invalid data first. An additional problem cropped up in MySQL, which has different text collation rules depending on accents and unicode normalization than SQLite & Postgres. I 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 tried to pick a different collation for the table (`utf8mb4_0900_as_cs`, i.e. both accent-sensitive and case-sensitive), but that only partially helped with some name pairs (“pokemon” vs. “pokémon”), but not with others (different normalization, or invisible control characters, and there are *countless* different types of those, as I have learned…). I eventually gave up and ended up just dropping the unique index for now. 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 `\0` in the post text… So I had to just filter out such posts as invalid. if text.include?("\u0000") return end Finally, something that somehow caused an issue in both versions was a thing that every programmer loves – timezones… When I made a query to count posts added in the last 5 minutes, and it always returned 0, but “last 65 minutes” didn’t, I immediately knew what was going on 🫠 In Postgres, the solution was to tell ActiveRecord to use the `timestamptz` data type for timestamp columns instead of the default `timestamp`: ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz (and then migrate the existing columns to use that type). In MySQL, you need to either tell AR to use the timezone the database is using, if it’s not set to UTC: ActiveRecord.default_timezone = :local Or tell the database to use UTC: [mysqld] default-time-zone = '+00:00' (but not both…) * * * ## Chapter 4: Optimizing 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’ve set up a kind of “database A/B test” 🙃 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 “DID” (user ID) of the caller or current timestamp, proxied the call to that server, took the response, and returned it back to the caller: 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' => env['HTTP_AUTHORIZATION'] } : {} response = Net::HTTP.get_response(URI(url), headers) content_type :json [response.code.to_i, response.body] end This meant that each feed load took a bit longer, but it wasn’t very noticeable in practice, and I had the real traffic split into two more or less equal parts, going to the two servers. (Coincidentally, it’s exactly one year today since I deployed that change – I’ve been procrastinating way too long on this blog post 🫠) And then started my months-long work of optimizing the databases and queries… 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 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 `DESC`), or rearrange a query to make it use the intended index, add some limiting condition, or occasionally (in MySQL) add `FORCE INDEX`. Those issues were generally fairly easy to fix, and the fix either clearly worked or not. I think some queries I 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. 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 mostly wrote everything about in on my Journal blog on micro.blog back in January, and I remembered much more about it then than I do now, so I’ll just link to that old blog post here. The TLDR is that I have a set of three feeds: Follows & Replies, Only Replies and Only Posts, which share the same code, just with slightly different filters; these are personalized feeds (i.e. having different content depending on who’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 “most recent N posts from any of the users that this account follows” – so basically a reimplementation of the standard “Following” feed, with some changes. This query was much slower on the Postgres server than on the other two databases, and Postgres insisted on using the posts index on `(time)` (scanning possibly millions of rows to find the right ones) instead of using the one on `(user, time)` some number of times and merging the results (and apparently asking “how to do FORCE INDEX in Postgres” is a terrible heresy 😛). I 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 went through: bumping up the `STATISTICS` target and/or hardcoding `n_distinct` (and rolling those back), tweaking some configuration variables, rearranging the query/index in various ways – and what I finally settled on was: * changing the `(user, time)` index to also include the `id` primary key as the third field, i.e. `(user, time DESC, id)`, to let it do “Index Only Scans” on it – I haven’t realized that indexes in Postgres don’t reference the primary key, so they can’t be used this way unless the `id` is explicitly included there! * and setting up a cron job to do very frequent manual VACUUM (4× a day, with forced `index_cleanup`), because otherwise it has to re-check some of the ids fetched from the index to verify if the rows haven’t been deleted 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 `(time)` index is almost always better – but I think that’s somewhat unavoidable.) * * * ## Epilogue: There can be only one In the end, after all the tweaks and optimizations, both servers on both databases were working quite fine, and I think I would probably be ok with either of them. But I had to pick one. I ended up picking… … … Postgres! 🏆 In those few months, I managed to read sooo many pages of the documentation, articles about various specific settings, spent so much time in the `psql` console, reading output from the analyzer, that I got much more comfortable with it than I was at the beginning… So ironically, the fact that I had to spend more time tweaking it to get it to work all smoothly made me prefer it in the end. I felt like specifically because there were so many different dials and switches, I felt more “in control” 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 `innodb_buffer_pool_size` to half the RAM and you’re done”. And if you’ve already set that and you’d like to optimize things further? Well… ¯\\_(ツ)_/¯ Postgres’s query analyzer output is also more readable and helps you more with figuring out how it’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 `SHOW ENGINE INNODB STATUS` command, which just pukes several pages of text output at you. 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 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 wasn’t doing it in a super scientifically controlled way. One thing that I could see on Munin charts in the end was that the Postgres server had higher numbers on the disk **read** IO/throughput charts, while the MySQL server had noticeably higher numbers on the disk **write** 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. So since March, the app has been running on a new 512 GB VPS on a Postgres database, and it’s been working fine since then.
15.10.2025 19:11 — 👍 0    🔁 0    💬 0    📌 0
Preview
Introduction to AT Protocol Some time ago I wrote a long blog post I called “Complete guide to Bluesky”, 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 want to explain in hopefully understandable language what all the pieces of the network architecture are and how they all fit together. I 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. This post is a first part of a series – next I 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. But before we start, a little philosophical aside: ## What is “Bluesky”? Which “Bluesky” are we talking about? Discussions about Bluesky sometimes get a little confusing because… “Bluesky” could mean a few different things. Language is hard. First, we have Bluesky the company, the team. Usually, when people want to clarify that they’re talking about the group of people or the organization, they say “Bluesky PBC” (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 these two sections in the Bluesky Guide blog post.) And we also have Bluesky the product, the social network, the thing that they’ve built. This network is not a single black box like Twitter or Facebook are (despite what they say about it on Mastodon), it’s more like a set of separate and actually very transparent boxes. The system they’ve built, of which Bluesky was initially meant to be just a tech demo, is called the **Authenticated Transfer Protocol** , or AT Protocol, or ATProto. Bluesky is built on ATProto, and it is in practice a huge part of what ATProto currently is, which makes the boundary between Bluesky and non-Bluesky a bit hard to define at times, but it’s still only a subset of it. Bluesky in this second meaning is some nebulous thing that consists of: the data types (“lexicons”) that are specific to the Bluesky microblogging aspect of ATProto, like Bluesky posts or 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. One of the coolest things about Bluesky & ATProto, in my opinion, is that it connects many different independent pieces into something that still feels like one shared virtual space. People outside the company can create (and are creating) other such things on ATProto that aren’t necessarily Bluesky-related – see e.g. WhiteWind or Leaflet (blogging platforms), Tangled (GitHub alternative), Frontpage (Hacker News style link aggregator), or Grain (photo sharing site). They use the same underlying mechanisms that are at the base of ATProto, but use separate data types, have different rules, goals, and UIs. How do we call these things as a whole, the different sets of “data types + rules + required servers + client apps” that define different use cases of the network? Bluesky team usually calls them “apps”, but I’m not a big fan of this term, because “app” kinda implies a client app, and that’s just one small piece of it. I sometimes call them “services” – though it’s probably not perfect either, since it implies just the server part in turn. Suggestions welcome :) (I’m mentioning this at the beginning, because this is something that many different parts are related to.) Personally, when I say “the Bluesky app”, I will generally mean the actual client app (mobile / webapp), not the “service”, and when I say “Bluesky-specific”, I will mean the “service”, not the company; and “Bluesky-hosted” will mean run by Bluesky the company. Hopefully in most cases, it can be guessed from context. BTW, the commonly accepted term for the whole shared “multiverse” of all ATProto apps is “The Atmosphere”, or “ATmosphere” (though I much prefer the former personally, the weird capitalization bugs me somehow ;). It was coined by someone from the community, but was accepted by the team and is now mentioned on the official atproto site. * * * Let’s start with defining the various building pieces of the protocol: ## Records & blobs The most basic piece of the ATProto world is a **record**. 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). For example, this is a post record. This is one of the likes of that post. Records are stored on disk and transferred between servers in a binary format called CBOR, although in most API endpoints they’re returned in a JSON form (they are equivalent, just different encodings of the same data). The key thing about records, which has very real consequences for user-facing features, is that you can only create and modify _your own_ 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 AppView section. This also means that there’s often an unexpected assymetry 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. A second, complimentary way of storing user data is **blobs**. Blobs are basically binary files, meant mostly for storing media like images and video. For example, here is a direct link to an image blob showing a photo of when I 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. ## Lexicons 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 **lexicon** in ATProto. (If you’re curious why make a new standard, see threads e.g. here, here, or here, or this blog post). A lexicon needs to have an identifier (called **NSID** , Namespace Identifier), which uses the reverse domain name format, e.g. `app.bsky.feed.post`. 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 `app.bsky`. The structure of a given lexicon’s records is defined in a special JSON file – for example, this file defines the app.bsky.feed.post lexicon. 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 bsky.app – 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 _can_ add additional fields to a record that aren’t defined in its lexicon; many third party apps are doing that, like e.g. Bridgy Fed). 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 `app.bsky.*` – these are things specific to Bluesky the microblogging service – or under `com.atproto.*`, 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 `chat.bsky.*` for the (centralized) DM service, and `tools.ozone.*` for the open source Ozone moderation tool. 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 `com.atproto`, so things like post, profile, follow are all Bluesky-specific and under `app.bsky`, as are APIs for e.g. searching users, getting timelines, custom feeds and so on. Meanwhile, `com.atproto` 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. 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 `blue.flashes`, `social.pinksky`, `events.smokesignal`, `sh.tangled`, 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 conference talks. A good starting point is this blog post by Paul Frazee.) ## Identity Each user is uniquely identified in the network with their **Decentralized Identifier (DID)**. DIDs are a W3C standard, but (as I 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. The format of a DID is: `did:<type>:<…>`, 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 “**DID document** ”, a JSON file (see mine) 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. An important thing to note is that **DIDs are permanent** ; 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. The main DID method is `did:plc`, where IIRC “plc” originally stood for “placeholder” (I think it was meant to be temporary until something better is designed), and was later kind of retconned to mean “Public Ledger of Credentials” 🙃 The DIDs of this type are identified by a random string of characters, which looks like this: `did:plc:vc7f4oafdgxsihk4cry2xpze`. The DID documents of each DID are stored in a centralized service hosted at plc.directory (Bluesky wants to eventually transfer the ownership to some external non-profit), which basically keeps a key-value store mapping a DID to a JSON file. It also keeps an “audit log” 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 understand it, lets anyone verify that everything in the database checks out (don’t ask me how). The other, rarely used method is `did:web`. Those DIDs look like this: `did:web:witchcraft.systems`, and the DID document is stored in a specific `.well-known` path on the given hostname, in this case witchcraft.systems (yes, that’s an actual TLD ;). It does not store an audit log/history like `plc` does. The reason why it’s rarely used and not recommended, is because, first, it’s more complicated to create one (though that’s a solvable problem of course, see a just published guide); 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 `did:web:another.site` 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. If you’re fine with that choice, you can create a `did:web` account and almost everything in Bluesky and ATProto should work exactly the same. “Almost”, 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 :> #### Handles What DIDs enable is that since they act as the unique identifier, your handle doesn’t have to, like it does on the Fediverse. I can be `@mackuba.bsky.social` one day, `@mackuba.eu` the next day, and `@mackuba.martianbase.net` the week after. All existing connections – follows & 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 later section), 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 “@mackuba.eu”. The link still works, because it really links to the DID behind the scenes. Unlike on the Fediverse, 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!). 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 `alsoKnownAs` field of the DID document (see here in mine). 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 `.well-known` path. You might be wondering how handles like `*.bsky.social` work – in this case, each such handle is its own domain name, and you can actually enter a domain like aoc.bsky.social into a browser and it will redirect to a Bluesky profile on bsky.app. Behind the scenes, this is normally handled by having a wildcard domain pointing to one service, which responds to HTTP requests on that `.well-known` path by returning different DIDs, depending on the domain. That’s not only a `bsky.social` thing – e.g. there’s now an open Blacksky PDS server which hands out `blacksky.social` handles, and there are even “handle services” which _only_ give out handles – e.g. you can be yourname.swifties.social if you want ;) One place where handle changes break things is (some) post URLs on bsky.app. 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 `/profile/` 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’re reading) would be something like: https://bsky.app/profile/did:plc:ragtjsm2j2vknwkz3zp4oxrd/post/3llwrsdcdvc2s. ## AT URIs Each record can be uniquely addressed with a specific **URI** with the at:// scheme. The format of the URI is: at://<user_DID>/<lexicon_NSID>/<rkey> **Rkey** is an identifier of a specific record instance – a usually short alphanumeric string, e.g. Bluesky post rkeys look something like `3larljiybf22v`. So a complete post URI might look like this: `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3larljiybf22v`. You can look up at:// URIs in some record browser tools, e.g. PDSls. AT URIs are used for all references between records – quotes, replies, likes, mute list entries, and so on. If you look at this like record, for example, its `subject.uri` points to `at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.post/3lv2b3f5nys2n`, which is the URI of a post record you can see here. Since the URIs use DIDs in the first part, handle changes don’t affect such links. ## User repositories All user data (records and blobs) is stored in a **repository** (or “repo”). The repository is identified by user’s DID, and stores: * records, grouped by lexicon into so-called **collections** * blobs (stored separately from records) * authentication data like access tokens, signing keys, hashed passwords etc. Internally, an important part of how the repo stores user records is a data structure called “Merkle Search Tree” – but this isn’t something that you need to understand when using the protocol, unless you’re working on a PDS/relay implementation (I haven’t needed to get into it so far). You can download the records part of your (or anyone else’s!) repo as a bundle called a CAR file, a Content Addressed Archive (fun fact: the icon for the button in the Bluesky app which downloads a repo backup is the shape of a car 🚘). 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 lexicon/tool for storing arbitrary files on your Bluesky PDS… and did it in Bash, because why not 🙃) ## XRPC **XRPC** is the convention used for APIs in the ATProto network. The API endpoints use the same naming convention as lexicon NSIDs, and they have URLs with paths in the format of `/xrpc/<nsid>`, e.g. `/xrpc/app.bsky.feed.getPosts`. There are similar lexicon definition files 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 _have_ to use the same convention, but it’s generally a good idea, since it integrates better with the rest of the ecosystem. ## Rich text / facets This one is kinda Bluesky-specific, but it’s pretty important to understand, and I think you can reuse it for non-Bluesky apps too. The “**facets** ” 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. 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 this post here, 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 “#ahoy25” 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. (If you’re curious why they implemented it this way, check out this blog post.) 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 this post. The Bsky app doesn’t let you create such posts directly, but some other clients like Skeetdeck do. 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. Likely the most tricky part is that the 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 unicode scalars, which most languages index strings in 😅 This is somewhat of an unfortunate tech debt thing as I understand, and it was made this way mostly because of JavaScript, which doesn’t work with UTF-8 natively. But this means you need to be extra careful with the indexes in most languages. * * * Ok, now that we got through the basic pieces, let’s talk about servers: ## PDS The original copy of all user data is stored on a server called **PDS** , Personal Data Server. This is the “source of truth”. A PDS stores one or more user accounts and repos, handles user authentication, and serves as an “entry point” to the network when connecting from a client app. Most network requests from the client are sent to your PDS, although only some of them are handled directly by the PDS, and the rest are proxied e.g. to the AppView. So in a way, your PDS kind of serves as your “user agent” in the network on the backend side of things (beyond the client app), especially if it’s under your control. Each PDS has an XRPC API 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 called a “**firehose** ” (the subscribeRepos endpoint). 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. One of the most important features of ATProto is that **an account is not permanently assigned to a PDS**. Unlike in ActivityPub, where your identifier is e.g. `mackuba@mastodon.social` and it can never change, because everything uses that 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 plc.directory), 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 ATP Airport or PDS MOOver (although it’s still a bit unpolished at the moment, and for now you can’t migrate back to Bluesky-hosted PDSes). 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 (this is a bit more technical). If everything goes well, nobody even notices that anything has changed (you can’t even easily check in the app what PDS someone is on, although there are external tools for that, like internect.info). Initially, during the limited beta in 2023, Bluesky only had one PDS, `bsky.social`. 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 public federation launch. 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 August 2025, there around 2000 third party 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 have a list of them on my website, and there’s also a more complete list here (mine excludes inactive PDSes and empty accounts). As you can see there, there’s one massive PDS for Bridgy Fed, the Bluesky-Mastodon bridge service, hosting around 30-40k bridged accounts from the Fediverse, Threads, Nostr, Flipboard, or the web (blogs); then some number of small to medium PDSes for various services, and a very long tail of servers with single-digit number of accounts. At this moment, large public PDS in the style of Fedi instances aren’t much of a thing yet, although there are at least a few communities working on setting up one (e.g. Blacksky, Northsky, or Turtle Island). Blacksky specifically has opened up for migrations just last week and has now a few hundred real accounts. The vast majority of PDSes at the moment use the reference implementation from Bluesky (written in TypeScript), but there are a few alternative implementations at various levels of maturity (Blacksky’s Rudy Fraser’s rsky written in Rust, cocoon in Go, or millipds in Python). The official 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. As for the Bluesky-hosted PDSes, the number is currently in high double digits, 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 Amanita, Boletus or Shiitake), hence they are often called “mushroom servers”; you can see the full list e.g. here. `bsky.social` was left as a so-called “**entryway server** ”, 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). ## Relay A **relay** 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: * 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 * 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 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 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. Initially, the relay was also expected 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 late last year, 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 don’t keep a full archive of all repos (this change has massive lowered the resource requirements / cost of running a relay, making it much more accessible). An archival relay could always be set up too, but I’m not aware of any currently operating. Bluesky operates one main relay at bsky.network, which is used as a data source for their AppView and pretty much everyone else in the ATProto ecosystem at the moment (internally, it’s really some kind of “load balancer” using the rainbow service, with a few real relay servers behind it). The relay code is implemented in Go, and isn’t very hard to get up and running (especially the recent “1.1” update improved things quite a lot). Some people have been running alternative relay services privately for some time, and there is now e.g. a public relay run by Rudy Fraser at atproto.africa (with a custom implementation in Rust! 🦀), and a couple run by Phil @bad-example.com. I’m also running my own small relay, feeding content only from non-Bluesky PDSes. #### Jetstream There is also a variant of a relay called Jetstream – it’s a service that reads from a real CBOR relay and outputs a stream that’s JSON based, better organized, and much more lightweight (the full relay includes a lot of additional data that’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.) ## AppView The terribly named **AppView** is the second most important piece of the network after the PDS. The AppView is basically an API server that serves processed data to client apps. It’s an equivalent of an API 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 `posts` table with a `text` column, a `likes` table storing all likes with a foreign key `post_id`, probably also an integer `likes_count` column in `posts` for optimization, and so on. The AppView is designed to be able to easily give information such as: * the latest posts from this user * all the replies in a given thread organized in a tree * most recent posts on the network with the hashtag #rubylang or mentioning “iOS 26” * how many likes/reposts has a given post received, and who made them * how many follows/followers does a given user have, and who are they * is user A allowed to view or reply to a post from user B 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 `app.bsky.feed.like` 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 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 with processed data from the entire network is essential for client apps and various tools and services built around Bluesky by other people. 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’re logged in. It also hides them from your followers list, even if they have a `follow` record referencing you, making it seem like they don’t; and if they try to make an `app.bsky.feed.post` replying to you (they _can_ create such record on their PDS!), it excludes such reply from feeds and threads, as if it never happened. Same goes for “thread gates” which lock access to threads, and so on. The AppView is one of the few components which _aren’t_ completely open source. Initially, the AppView used Postgres as its data store; _that_ version is still in the public repository. In late 2023, Bluesky has migrated to a “v2” version, which uses the NoSQL database ScyllaDB 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 public repository, 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 old Postgres-based data layer (and I think the team uses that internally for development), it just can’t handle as much traffic as the current live version. 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 under $200/month – the biggest requirement is at least a few TB of disk space. 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 shouldn’t be very hard to run, but to be able to serve millions, you’ll need a lot of hardware and something more custom than the Postgres-based version. There have also been some attempts at alternative implementations – the most advanced right now is AppViewLite, built in C#, which goes to great lengths to minimize the resource use. #### CDN A part of the AppView (at least the Bluesky one) is also a CDN for serving images & videos. The API responses from e.g. `getTimeline` or `getPostThread` generally include links to any media on the Bluesky CDN hostname, not directly on the PDS, even though you _can_ fetch every blob from the PDS, since that’s the “source of truth” (although IIRC the Bluesky PDS implementation doesn’t set the CORS headers there). It’s recommended to access any media this way in order to not use too much bandwidth from the PDS. ## Labellers (Or “labelers” officially, but I like the British spelling more here, sue me ¯\\_(ツ)_/¯) 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 atproto.com site). A **labeller** 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. 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 existing labellers are meant for self-labelling instead, letting you assign e.g. a country flag or some fun things like a D&D character class to yourself. The way it works technically is: * 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 Ozone tool for that) * labels, which are lightweight objects (_not_ ATProto records) are emitted from labeller’s special firehose stream (the subscribeLabels endpoint) * 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 * 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 * the specific list of labellers whose labels should be applied is passed explicitly in API requests in the `atproto-accept-labelers` 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) * 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 (Read more about labellers here.) ## Feed generators Custom feeds 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). The way this system works is that you need to run a “**feed generator** ” service on your server. In that service, you expose an API 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. A minimal feed service can be pretty simple – the API is just three endpoints, two of which are static, and the third returns the post URIs. One “small” 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). The flow is like this: * a feed record is uploaded to your repo, including metadata and location of the feed generator service, which lets other users find your feed * when the user opens that feed in the app, the AppView makes a request to your service on their behalf * your service looks at the request params and headers, and returns a list of posts it selected in the form of at:// URIs * the AppView takes those URIs and maps them to full posts (so-called “hydration”), which it returns to the user’s app 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. You don’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’t require technical knowledge to use, like SkyFeed or Graze. ## Client apps Ok, that’s technically not a server, but stay with me… 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 (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 – kind of like when you run a custom web client for Mastodon like Elk or Phanpy, you connect it to your instance, and you view your timeline on elk.zone. So when you go to bsky.app, 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. There aren’t nearly as many alternative clients as there are for Mastodon, and none of them are _really_ great, but there are a few options; see the apps part of my Bluesky Guide blog post for links. ## DMs Notice that I 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 (the `chat.bsky.*` namespace), but it’s not something you can run yourself. The team is very open 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. 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. * * * ## How it all fits together So the flow and hierarchy is like this: * the **client app** you use creates new records as a result of actions you take (new posts, likes, follows), and saves them into your PDS * your **PDS** emits events on its firehose with the record details * Bluesky **relay** and other relays are connected to the firehoses of each PDS they know about (your PDS generally needs to ask them to connect using the `PDS_CRAWLERS` ENV variable), and they pass those events to their output firehose * the Bluesky **AppView** (and other AppViews) listen to the firehose of their selected relay (though it could be multiple relays, or it could even just stream directly from PDSes, but in practice this will normally be one trusted relay) * the AppView gets events including your records, and if they are relevant, saves the data to its internal database in some appropriate representations * 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 Additionally: * **feed generators** run by third party feed operators also stream data from Bluesky’s or some other relay and save it locally, so they can respond to feed requests from the AppView * **labellers** also stream data from Bluesky’s or some other relay, and emit labels on their firehoses, which get sent to the AppView (note: there is no official “labeller relay” sitting between labellers and the AppView, although one third party dev wrote one) Note: * PDSes **do not connect to each other directly** , and they don’t store posts of users from other PDSes, only their own * although right now basically everyone uses the Bluesky relay and AppView, anyone _can_ set up their own alternative relays and AppViews, which feed from all or any subset of known PDSes * PDS chooses which relays to ask to connect, but relays can also connect by themselves to a PDS or another relay; AppView chooses which relay(s) it streams data from; and PDS chooses which AppView it loads timelines & threads from * it’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’s responses on their own AppView, as long as the users aren’t banned on the other user’s infrastructure The metaphor that’s often used to describe these relationship is that PDSes are like websites which publish some blog posts, and relays & 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. * * * ## Where to go next And that’s about it – I 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: #### SDKs: Two languages are officially supported by Bluesky: * JavaScript/TypeScript, in which most of their code is written (see the packages folder in the `atproto` repo) * Go, which is used in some backend pieces like the relay, or the goat command line tool used e.g. for PDS migrations (see the `indigo` repo) For Python, there is a pretty full-featured SDK created by Marshal, which is the only third party SDK officially endorsed by the Bluesky team. For other languages, I have a website called sdk.blue, which lists all libraries and SDKs I know about, grouped by language. As you can see, there is something there for most major languages; I’ve built and maintain a group of Ruby gems myself. 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. #### Docs: There is quite a lot of official documentation, although it’s a bit spread out and sometimes not easy to find. The places to look in are: * atproto.com – the official AT Protocol website; a bit more formal documentation about the elements of the protocol, kind of like what I did here, but with much more info and detailed specifications of each thing * docs.bsky.app – more practical documentation with guides and examples of specific use cases in TS & 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. * docs.bsky.app/blog – developer blog with updates about protocol changes * HTTP reference – a reference of all the API endpoints * something that I also find useful is to have the atproto repo checked out locally and opened in the editor, and look things up in the JSON files from the /lexicons folder And a few other articles that might work better for you: * “ATProto for distributed systems engineers”, Bluesky’s technical overview of the server and data flow architecture * “ATProto Ethos”, also on the Bluesky blog, based on a conference talk * “How Does BlueSky Work?”, by Steve Klabnik (of Ruby & Rust fame) (Feb 2024) * the “Statusphere” app example on atproto.com #### Community: Someone said recently that “ _bsky replies are the only real documentation for ATProto_ ”, 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 conferences. 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’s not necessarily found in the official docs can be found somewhere on Bluesky. The two places I recommend looking at are: * the “ATProto Touchers” Discord chat – ping me or some other developer for an invite :) * my ATProto feed 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 names and technical terms, or you can use `#atproto` or `#atdev` hashtag to be sure Also, there’s a fantastic newsletter called Connected Places (formerly Fediverse Report) by Laurens Hof, who publishes two separate editions every week, about what’s happening in Bluesky/ATProto and in the Fediverse (and *a lot* of things are happening). #### Ideas: Some easy ways to start tinkering: * use one of the existing libraries for your favorite language 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. * make a bot that posts something (not spammy!) * make a simple custom feed service using one of the available templates * connect to the relay firehose and print or record some specific types of data #### Tools: And a couple of tools which will certainly be useful in development: * internect.info – look up an account by handle/DID and see details like assigned PDS or handle history * PDSls – PDS and repository browser, lets you look up repos by account DID or records by at:// URI (there are a few others, but this one is most popular)
20.08.2025 17:32 — 👍 0    🔁 0    💬 0    📌 0
Preview
Social media update 2025 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. 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. Myself, I’ve basically left Twitter; I 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. So where do we go from here? Obviously everyone has their own take on that, this is just mine. But I 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 “Connected places”. That means **Bluesky and Mastodon/Fediverse** (with emphasis on _“ and”_), 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 aren’t very serious about it). My advice: → If you’re currently cross-posting or bridging between **Mastodon and Bluesky** : awesome! ❤️ → If you’re active on **Mastodon** , but currently ignoring or forgot about Bluesky: **please** reconsider it. I know that these two communities have a lot of differences between them, and we love to hate each other (I fully admit I’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 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. 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: * there are some native apps which let you post to two or more services in parallel, e.g. Croissant, Openvibe or SoraSNS * most social media management services like Buffer 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. * e.g. Fedica was one of the ones that had support for Bluesky from very early on * also my friend from my first job, Peter Solnica (known from some Ruby libraries like DataMapper/ROM, dry-rb, Hanami, and now some Elixir libs too) is building his own called JustCrossPost * I use a little tool in Ruby I wrote for myself named tootify, which lets me selectively cross-post relevant posts to Mastodon, almost effortlessly: I post on Bluesky and then “like” my own post if I want it to be copied to Mastodon (which clears the like). So this way I can cross-post e.g. cat photos or iOS related posts, but skip Bluesky-specific content. 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). * I know some people have also written some iOS Shortcuts, scripts, browser extensions etc. (I don’t have any links at hand) * and last but not least, there’s Bridgy Fed: basically enable it once by following the bridge account, and you can forget about it. It creates a “mirror” 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). * there are probably other options too, let me know in the comments :) → If you’re active on **Bluesky** : 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. → If you do have both Mastodon and Bluesky accounts, but you’ve decided that you want to use them for **different kind of content** (e.g. tech vs. non-tech)… please reconsider, in light of what I 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 _everywhere_ in parallel. (Though nobody says you can’t have e.g. two different accounts which are both bridged.) → 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 am begging you, please either figure out some cross-posting solution (see above), or enable the bridge 🙏🏻 → If you’re mostly active on **Threads** (is that a thing?…) – turn on the fediverse sharing option (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 _should_ work, let me know if you have any problems). → If you’re still mostly active on **Twitter** (I see you 👀 and I am kinda judging you)… please rethink it. I mean… you really don’t mind helping Elon and his buddies? I know, there’s more content there, more engagement (maybe), 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 that isn’t controlled by one rich American far-right guy1) who thinks he’s the president of the world?… Where you don’t see a full screen ad every few posts, and don’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. → If you’re posting _both_ on Twitter and on Mastodon/Bluesky, then, well… it’s ok I guess 🙃 I 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’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 understand if not everyone is ready for that step yet. → If you tried Mastodon and/or Bluesky before, but you felt like it was **too empty there** , you didn’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’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: * on Bluesky: find some good custom feeds and/or starter packs to follow * 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. * 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 on. → If you’re on **Nostr** , there are some kind of Nostr ↔ Fedi bridges or gateways, and you can use those + Bridgy to reach Bluesky too (e.g. here’s a Nostr account “double-bridged” to Bluesky). → If you’ve moved to something like Micro.blog, or just blogging – that’s also cool! But I hope you’re somehow posting the links to Bluesky & Fedi too :) → If you’ve just given up on microblogging or social media in general… I understand. It’s probably not a bad choice in the current times. I hope we somehow meet again someday, digitally or in person 🩵 So let’s connect, let’s build bridges. But also, let’s finally help the X-shaped zombie die 🧟‍♂️ * * * (You can find me on Bluesky at @mackuba.eu (I have my private PDS at lab.martianbase.net), which is my main account these days, and on Mastodon at mackuba@martianbase.net (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 `mackuba.eu@bsky.brid.gy`. Twitter account @kuba_suder is left as an archive, because I don’t like deleting useful content from the Internet, but I don’t use it anymore. Lately I’ve been also blogging somewhat more regularly than here on my new “journal” Micro.blog.) 1) If you now want to write a comment mentioning someone with the initials “J.D.”, don’t even think about it! 🫠
30.06.2025 14:43 — 👍 0    🔁 0    💬 0    📌 0
Micro.blog journal Just a quick update, if you’re following this blog via RSS: I’ve started a separate “journal” blog on micro.blog: journal.mackuba.eu. Micro.blog is an interesting service: it’s a one-man indie business that’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’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 understand, is to use a network of blogs to build a social network that uses the web itself as the foundation. I’m not really planning to use it in this social network mode, since I’m pretty happy now posting on Bluesky and to limited degree on Mastodon (I’ve completely stopped posting on Twitter at this point, since last autumn, when Elon started openly supporting Trump). I’m also not completely sold on this “web as a social network” idea. And I don’t intend it to replace this blog here either – I will still be (very) occasionally posting those super long articles here like the one about NSButtons or the guide to Bluesky. But I’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 “mediumblog” so to say (not to be confused with a Medium blog) – like this post, for example. Something where I can sometimes post my thoughts more easily, when I want to write something that doesn’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. It’s also nice that it’s supposed to sync replies from Bluesky/Mastodon under the posted link back to the blog page as comments below (I’ll try to implement the same thing here). I also have it configured with my own domain, so I can possibly migrate it to something self-hosted like Jekyll or Hugo at some point, keeping all the links and content. For now, I’ve posted an update about what I’ve been working on recently: tuning a Postgres database to which I’m trying to migrate my Bluesky feeds service. I don’t know how often I will end up posting there, I don’t want to pressure myself, just to have a place to post when I have a need. So if you’re curious, follow me there via RSS (or on Bluesky or Mastodon).
04.02.2025 16:00 — 👍 0    🔁 0    💬 0    📌 0
Preview
March 2024 projects update I’ve been still pretty busy with various Bluesky- and social-related projects recently, so here’s a small update on what I’ve been working on since my November post, if you’re interested: ### Skythread – quote & hashtag search I was missing one useful feature that’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’t currently collect and expose this info, so it’s not a simple matter of calling the API. But since everything’s open, anyone can build a service that does this, they just need to collect the data themselves. Since I’m already recording all recent posts in a database for the purposes of feeds and other tools, I figured I could just add an indexed `quote_id` 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. Skythread, my thread reading tool, seemed like a good place to add a UI 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, like this (it doesn’t currently do pagination though). You can open that page directly by appending the `bsky.app` URL of a post after the `quotes=` parameter here: https://blue.mackuba.eu/skythread/?quotes=. In the same way, I also indexed posts including hashtags, since hashtags were being written into post records since the autumn, but it wasn’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’t need to use Skythread for that. I hope that the quote search also won’t be needed for much longer :) ### Handles directory 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’ve used as your handle. So for official accounts like The New York Times, The Washington Post, or Alexandria Ocasio-Cortez, it’s enough if they just set their handle to their main website domain (or a subdomain of house.gov in AOC’s case) to prove they’re legit – they don’t need to apply anywhere to get a blue or gold tick on their profile. I was thinking one day that it would be nice to see how many e.g. `.gov` handles there are and notice easily when new ones show up. So I grabbed a list of all custom handes from the plc.directory and started recording new and updated ones from the firehose. In the end, I decided to build a whole catalog of all custom handles, grouped by TLD, and show which TLDs are the most popular. At first I only included the “traditional” main TLDs and country domains, but a lot of people liked it and I got a lot of requests to also include domains like `.art`, `.blue`, `.xyz` and so on, so in the next update I’ve added all other domains too. (I gave it an old-school tables-based design as a homage to the old “web directory” websites like Yahoo Directory 😉) Apart from handles, the website now also tracks third party PDS servers that started to show up after the federation launch in February. ### Bluesky activity charts I’ve also made a page that shows some charts tracking Bluesky user activity – 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 opened up for registrations without an invite (when Japan suddenly took over), and then it’s been falling down again since then (currently around the level of the October top). You can also see some other interesting stats on Jaz’s page and Eric Davis’s Munin charts, especially the one tracking daily/weekly/monthly active user count. (I also have a few more ideas for what to add to my charts.) ### DIDKit In the last few weeks, I’ve been updating the code that tracks custom handles again to adapt to some protocol changes. The `#handle` event in the firehose, which included handle info on every handle change, is now deprecated and being replaced with a new `#identity` event, which only tells you to go fetch the account info from the source again (source being usually plc.directory). At the same time, I 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’t actually own or which don’t exist (the Bluesky official app shows such accounts with an “⚠ Invalid Handle” label, which you’ve probably seen before). For example, the handles directory page initially listed an `amongus.gov` account under `.gov` TLD, which was loaded from plc.directory, but is not in fact a real domain. This should ideally be done by not relying on Bluesky servers, and instead checking the DNS TXT entry and the `.well-known` URL of a given domain manually. There’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 extracted it to another Ruby gem named DIDKit, which lets you do things like: * get the DID of an account with a given handle * load the DID JSON document, which includes info like assigned handle(s) or hosting PDS server * check if any of the assigned handles from the document resolve back to the same DID * fetch all updates to all DIDs in batches from the PLC directory ## mackuba ∕ didkit A library for handling DID identifiers used in Bluesky AT Protocol ### Skyfall I’ve also been making some minor updates to my Skyfall library for streaming data from the Bluesky relay firehose. One thing I’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’t time out and just waits for new packets for hours, until I notice it and restart it. It isn’t only happening to me, others have mentioned it too (and not only in Ruby code); but it happens rarely enough that it’s really hard to debug. My proposed fix is adding a “heartbeat” timer, 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’t been any in a while, then it will forcefully restart the connection. (This isn’t included in the latest release yet, I’m waiting for it to get triggered a few times first.) Another thing I’ve added is being able to connect to a new kind of firehose exposed by “labellers” a.k.a. moderation services. Bluesky has released this new important piece of the federated architecture earlier this month – third party developers and communities can now set up independent moderation services, which manually or automatically add various “labels” to accounts or specific posts, flagging them e.g. as “racism” or “disinformation”. Anyone can subscribe to any labellers they choose, and they’ll see the labels from those selected services shown in the app. The new firehose (the `subscribeLabels` endpoint) allows you to connect to a specific labeller and stream all new labels that it’s adding. I’m also tracking all new registered labeller services and keeping a list here (it’s not curated, just a dump from a database table, so it also includes various test servers etc.). ## mackuba ∕ skyfall A Ruby gem for streaming data from the Bluesky/AtProto firehose ### The “Bluesky guide” Last month I wrote a long blog post titled “Complete guide to Bluesky”, where I included various info and tips for beginners about Bluesky history, available apps, handles, custom feeds, privacy, or currently missing features. I’m now updating it every time Bluesky releases another big feature :) Check it out if you’ve missed it. I 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 and the firehose. I hope I’ll be able to find time for that in the next few months. ### Tootify – cross-posting to Mastodon I still want to finish my Mac app for cross-posting to Twitter, Mastodon and Bluesky one day, but it’s a lot of work and I’ve got too many different things in progress at the same time, so it’s moving at a glacial pace… In the meantime, I started thinking if I could maybe quickly build something much simpler that also does the job. I’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 know from Twitter still mostly follow me on Twitter and Mastodon only and aren’t coming to Bluesky. I also didn’t want to simply copy every single post from here to there, because a lot of things I post on Bluesky are specifically about Bluesky stuff, so it doesn’t always make sense to post them to Mastodon – I only want some selected ones to be copied. But at the same time, I wanted to minimize the amount of friction this would add. So the idea I had one night was that I could mark the Bluesky posts to be copied to Mastodon by simply “liking” my own posts that I 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). I 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’s now running happily on a Raspberry Pi on my local network 😎 The code is published here, if you’re interested – but it’s a bit of a proof of concept at the moment, just enough to make it work for myself, so it’s probably not very user-friendly. But maybe I’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 “facets” system that Bluesky uses for post record data, and because Mastodon’s post length limit is higher than on Bluesky.) ## mackuba ∕ tootify Toot toooooooot ### And One More Thing ;) Paul Frazee, Bluesky’s lead dev, has a lovely cat named Kit and often posts photos of her. I’m a big fan of Kit, so I made a feed named the Kit Feed, which only includes posts with these photos 🙂 Like and subscribe! 🐱
27.03.2024 01:14 — 👍 0    🔁 0    💬 0    📌 0
A complete guide to Bluesky 🦋 _(Last update:12 Nov 2024.)_ For the past year and a half, I’ve been a pretty active user of Bluesky. I enjoy it a lot, and I’ve managed to learn a lot about how it works, what works well and what doesn’t, and also what’s likely coming next. I’ve decided to write down some of the tips & tricks that I often give to friends when I invite them there, or the advice and answers that I sometimes give to people that I find in some feed asking about things. This of course got much longer than I planned 😅 so if only have a moment, here’s a TLDR: * there are official iOS and Android apps, but you can also use bsky.app in the browser, or try e.g. Graysky, deck.blue, Skeets (iOS) or Skywalker (Android) * if your timeline feels empty, check out the default algorithmic feed called “Discover” – or even better, go to the “Feeds” tab, scroll down to the “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 goodfeeds.co. * don’t be afraid to interact with people, repost good posts, like good comments, comment in threads and so on – that’s how you make friends! (but be nice :) * if you see too much NSFW stuff, look for “Content filters” settings in the Moderation tab * everything you post here is very public, so don’t share anything too private 😏 * if you own some cool domain name like “taylorswift.com”, you can set it as your handle * hashtags, word muting, GIFs (from Tenor), DMs (simple version), videos, pinned posts, listing quotes, and thread composer are now available * trends, 2FA and more DM stuff are coming, post editing is planned; some kind of private profiles for sharing to limited audience are probably coming at some point, but not in near future * Jack Dorsey has nothing to do with Bluesky anymore ;) And now the long version: 1. What is Bluesky? 2. Apps 3. Feeds & algorithms 4. Safety & moderation 5. Privacy of your data 6. Account security 7. Handles & IDs 8. How are things called here? 9. What is this federation thing? 10. Search 11. Hashtags 12. GIFs & video 13. Starter packs 14. Settings 15. Missing features 16. Other tools * * * ## What is Bluesky? (A bit of history, skip if you’re not interested :) 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. The project was started in Dec 2019 by Jack Dorsey, 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 – 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. 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. (If this all sounds a lot like the Mastodon social network, or “the Fediverse”, then you’re right – there are a lot of similarities between these two. However, Bluesky is built on a completely different system they’ve designed from scratch, called the AT Protocol or ATProto. They’re hoping that this will let them build some things better than they are done in Mastodon, making the network less confusing, more useful and more user-friendly. Bluesky does not directly connect with Mastodon servers and apps, although there are some “bridges” being worked on.) 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, was chosen as the CEO. Thankfully, Jay had the foresight at that point to insist that they’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’s Twitter takeover. The team has been working on designing and building the pieces of the system throughout 2022, and in February 2023 they’ve launched 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’t ready yet to take in everyone. Since then, the user base has been gradually growing, with people being let in from the waitlist and existing users inviting their friends using invite codes. Meanwhile, the team had to speed some things up to adapt to the new situation and has been working hard on adding the most important features, and building up the backend to allow for more and more traffic. Finally, almost a year later, in the first week of February Bluesky has opened up for registrations from everyone. ### The Bluesky company 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. 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 – the source code 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. The company is set up as a “public benefit corporation”, which basically means (in my non-US layman understanding) that it is a business and it’s meant to make profit, but that profit is not it’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. At the moment, they don’t have a clear plan on how the company is going to make money on the platform – the general idea is to build some extra paid services on top for users and developers. They said they don’t plan to ever add ads and they promise they won’t “enshittify” the service in future. In any case, they’re explicitly building the network to be resilient even in the unlikely scenario that they themselves “turn evil” in the future – the network is meant to be “billionaire-proof”, impossible to completely take over by one guy with too much money. To quote the lead dev: > “Our culture doc includes the phrase “The company is a future adversary” to remind us that we won’t always be at the helm – or at our best – and that we should always give people a safe exit from our company. It’s weird at times to frame our priorities as protecting users from us, but that’s exactly what we’re trying to do. > > 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. > > 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 & usable network. But this is one big reason why we put all the Fancy Technology under the hood. 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. 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 Jack has left the board of directors too – he’s been replaced by Mike Masnick later. Dorsey also doesn’t own any shares of Bluesky. * * * ## Apps Bluesky has an official mobile app for iOS and Android. 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 – the reason is that they’ve started with a very small team at first (I think initially just one guy did all the frontend), so the only way they could do it was to build for both platforms & the web from one codebase. At this point, after various improvements over the last months, the mobile app is mostly ok and gets better with every update, it’s also obviously the most feature-complete one. One issue is that it doesn’t support iPad yet. There is of course also a web interface, at bsky.app, which is pretty good – this is the main UI that I use Bluesky with (also works well 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 now be viewed unauthenticated. But there is also a small but enthusiastic group of third party developers building various apps, tools and experimenting with the protocol. They’ve built several independent apps, libraries to access the API 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. Here’s a few of these apps (in various stages of development): * Graysky – a mobile app, first full-featured third party app, though its dev is working for Bluesky now, so it hasn’t been updated much lately * deck.blue – a web-based “Tweetdeck” column UI, written in Flutter * SkyFeed – a web app with a column UI that also lets you build custom feeds * Skeetdeck – another web-based, more lightweight “Tweetdeck” * Skeets – native iOS app with iPad support and some interesting features * Skywalker – a native app for Android * Skychat – a webapp initially built to let people form a “chat” around a specific hashtag * Sora – a new multi-network client for iOS * Openvibe – another multi-network client with support for cross-posting and a unified timeline Additionally, there is also a third party bridge service called SkyBridge that allows you to use Mastodon apps like Ivory to use Bluesky (although of course it only supports a subset of features). * * * ## Feeds & algorithms 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. Bluesky has both – and much, much more. When you sign up, you start with two default feeds. “Discover” is Bluesky’s main algorithmic feed – it mixes some posts from the people you follow with some other posts that you might like. You can pick the option “Show more like this” or “Show less like this” from the menu on each post to give it some hints on what you like or don’t. The devs are constantly tweaking it and asking for feedback, so it should be getting better over time. The second one, “Following”, is a classic chronological feed ~~although with a twist – by default it shows you more replies from the people you follow than on Twitter or Mastodon. You can configure a lot of aspects of that feed in the settings (see the Settings section), e.g. how many replies you want to see in it~~. (Note: there have been some changes made recently in the Following feed – the option to show all replies of your follows to everyone has been removed. If you really miss seeing those, I made two custom feeds that let you peek at those replies: Follows & Replies and Only Replies. Third party apps like Skeets still show it the old way too.) 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 “Following” and “Discover” 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’t like them, and leave e.g. only the “Cat Pics” custom feed as your only feed tab. Nothing here is forced on you. 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 – 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. There are also various “top posts of the week / all time” feeds, posts using AI models to match some specific kinds of photos like pictures of cats, frogs, or moss, or personal algorithmic feeds that show you posts selected for you according to some specific idea: posts from your mutual follows, from people who follow you, posts with photos only, posts from your friends who post less than others, and so on. These are all feeds built by third party developers who just had an idea and implemented it, without having to register or apply anywhere or ask anyone for permission. There is also a very popular web tool called SkyFeed, built by one German developer, which allows you to build some of the simpler feeds using a web form, without having to write code or host it yourself. This allowed a lot of people without programming knowledge to build their own feeds – currently around 85% of all feeds are built in and hosted by SkyFeed. There is a nice guide to feeds that describes them in more detail, there is also a blog post about feeds on Bluesky’s blog. ### Useful feeds 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: goodfeeds and SkyFeed Builder Feed Stats. Some general feeds that you might find useful: * What’s Hot, Catch Up and Week Peak Feed – general feeds with most popular posts * Quiet Posters – posts from people you follow who post less than others * Latest From Follows or Latest From Mutuals – just one most recent post from each of the people you’re following * Popular With Friends – recent posts liked by the people you follow * Follows & Replies and Only Replies – my feeds that include all replies from your follows to anyone * The ‘Gram – only posts with images from the people you follow And some “utility feeds”: * All-Time Bangers – posts with the highest number of likes on the whole platform * My Bangers – your own posts with the most likes * Quotes – all quotes of your posts * Mentions – all replies to you or mentions of you * My Pins – your “bookmarks” made by commenting with the 📌 emoji * * * ## Safety & moderation Like on most other networks, you can mute someone if you find them annoying, or you can block them if you find them _really_ annoying. It’s also possible to mute words, phrases or hashtags. You can now also mute words for a specific period of time (there’s no way to mute an _account_ for a specific time yet). (Note, the blocking mechanism here is pretty aggressive, in that it also hides all previous interactions between the two users *for everyone*; so don’t be surprised if someone blocks you and your reply or quote “disappears” – it wasn’t deleted, just hidden.) There’s also a number of features added recently for controlling interactions with your posts or threads: * you can lock replies in a thread to your followers or disable them completely, also some time later after replies appear * you can choose to “hide” some specific negative replies, like on Twitter (they’re moved to a “hidden replies” section at the bottom of the thread) * you can disable the option of quoting your post * you can “detach” existing quote posts of your post – the quoting post stays visible, but without the embed showing your post * and finally, you can temporarily “deactivate” your whole account – this makes it appear deleted to others so people won’t bother you if you want to take a break You can also create “moderation lists” for muting or blocking some groups of people at once, which can be shared with others. This is meant to let various communities on Bluesky build their own “defences”, by collecting lists of people who are unpleasant or annoying in some way and letting others mute or block them all at once before they come across them. If you get added to a user list (the kind meant for following) or a starter pack that you don’t want to me on, and the author refuses to take you off it despite your request, you can solve the problem by blocking the author – you will “disappear” from their list then. For obvious reasons, this doesn’t apply to mute/block lists, since usually no one wants to be on those. Bluesky’s own moderation seems to generally work ok from my perspective – they usually get rid of obvious trolls pretty quickly, and I’ve personally only ever come across a few of those. They claim to have around 20 people in the moderation team right now (as of February), which is about half the total company size. The general high-level plan for moderation at Bluesky and on the AT Protocol is something they call “ _composable moderation_ ” (old blog post) or “ _stackable moderation_ ” (recent post). It’s an idea that moderation will have many layers – 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. ### Labellers A core part of this is a feature called “ _labellers_ ” that they’ve released in March, which are basically third-party moderation services. They work by assigning a set of labels/tags (manually or automatically) to accounts and posts – this can be because one of the labeller’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). All users on the platform can “subscribe” 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’t have the full power of a built-in platform moderation in that it can’t just ban someone from the site and delete their account – but they can make someone _effectively_ disappear for those users who trust and agree with the given service.) 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. 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. 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’t have to try to satisfy everyone (which is impossible) or always pick a side. They also don’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 “just” another labeller among many others, using the same API, and with only _some_ special powers). You can read more on this topic in the guide titled “Bluesky Crash Course: Labelers” written by Kairi, who used to run one of the most popular labellers called Aegis (now defunct). There’s no easy way to search for labellers in the app yet, but I’m keeping a rough list myself on this page. I also made a “Label Scanner” tool where you can find all labels assigned to a given account from any labeller. By the way, it’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 _themselves_ with some badges they want to show to others: there are labellers to let you assign a country flag, pronouns, RPG class, Zodiac sign, Hogwarts house and other things. Some other useful labellers: * XBlock, which lets you hide screenshots of posts from other platforms like Twitter * Profile Labeller, which marks e.g. accounts created recently, without an avatar, ones that changed handle recently etc. * No GIFs Please, which is exactly what it sounds like ;) * US Politics Labeler * Asuka’s Anti-Transphobia Field * * * ## Privacy of your data 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 _extremely_ 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 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. This has both advantages and disadvantages. For a developer, it means that the only limit is your time and imagination (and maybe API rate limits). You can build feeds that show all posts with cat photos, a bot that responds to the text “/honk” with a random photo of a goose, implement some new features before the Bluesky team gets around to that, count the statistics of how the percentage of languages used in posts has changed over time, and whatever else you can think of. You don’t have any monthly quotas or paid plans. For a user though, this means that everything you do is very public – kind of like it was on Twitter, just more so, because there are fewer restrictions. Specifically, all of these are **publicly accessible** (even if they aren’t all displayed in the official app): * your posts * your likes * the photos you’ve attached to posts * all the handles you’ve previously used (you can’t delete those) * the list of people you follow * the list of people you block (!) * the user lists and moderation lists (mute/block lists) that you’ve created * which moderation lists (yours or others’) you are blocking people with And these things are **private** and known only to you (and the apps and tools that you’ve explicitly granted access to your account): * the people you’re muting (individually or through lists) * the words, phrases, hashtags etc. that you’re muting * your selected languages and other preferences * your email address, birthday and phone number * who invited you and who you have invited * the moderation services you’re subscribed to * the custom feeds that you’ve saved or pinned * one caveat though: the provider of the feed knows when you are opening it DMs are private between you and the person/people you’re chatting with, but they’re **not** currently end-to-end encrypted, so the Bluesky team can theoretically access them. A fully encrypted version will come later. The difference between muting and blocking is because muting is simply a filter applied only for you – 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 – 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. Now, what all of this can mean in practice: * anyone can download anyone’s posts and do various targeted or global analysis on it, track your likes and contact graph and so on * 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) * some (any) companies can potentially train some kind of AI models on the data (speaking purely about technical possibility, not legality of course) * blocking someone only adds friction, but it can’t completely prevent them from seeing your posts (same as it always was on Twitter until recent changes, since you could always open a post in an “incognito” window) * 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 * copies of any posts you delete might still remain on some third party servers 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 – if you’re posting something publicly anywhere, you need to realize that anyone can record it forever. The main difference is that it’s _easier_ to do it here, because the API 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. * * * ## Account security For logging in to third party apps and tools, Bluesky has initially added a temporary system of “app passwords”. Recently, they’ve also implemented more convenient OAuth authorization, where you authorize a service to use your account like on Twitter and other sites. However, not all third party apps and tools support it yet, so you might still need to use the app passwords for some of those. 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: `abcd-ef56-vxyz-qq34`. You can generate a separate password for every tool and app that you log into. Such password grants _almost_ 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’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. For additional security, there is for now a simple email-based Two-Factor Authentication (2FA) feature. A more complete 2FA system is coming soon. * * * ## Handles & IDs Bluesky has a really cool system of handles. They couldn’t have just used single-element handles like “@donaldtusk”, 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. “ivory@tapbots.social” and you can’t change that unless you make a new account. So here’s what they’ve come up with: internally, your account is identified by a unique identifier called “DID” (Decentralized Identifier), which is an ugly string of random letters. 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. 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. “dril.bsky.social”. This is a real domain name, you can type it into the address bar of the browser and it will redirect you to your profile on Bluesky. 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 retr0.id, or something long with one of those quirky new TLDs like `.horse` or `.computer`, or it can even be a very official sounding domain like washingtonpost.com. 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 – the complete instructions are here. (BTW, Bluesky also runs a service that resells and automatically configures domains through a partnership with Namecheap, which is the first of possibly many premium services that they want to actually make money on.) This also means that you don’t need to rush to “reserve your handle” at ***.bsky.social – because custom handles are cooler anyway 😎 (although be aware that if you change your handle from *.bsky.social to a custom domain, that old handle becomes available for others again). This system also makes it much easier to move your account to a different server – you can take your content and connections with you and keep your existing identity, because your DID identifier never changes. There’s even a number of “handle services” now that let anyone use a subdomain of their domain as the handle – e.g. go to swifties.social if you want your handle to be “ _yourname.swifties.social_ ” 👩🏻‍🎤 #### Verification It’s hard to implement verification in a decentralized network, but the good news is – there’s no need to! The system of domain names in handles serves as a way of verifying accounts. 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. @wyden.senate.gov is definitely US Senator Wyden (or at least his staff), and @nytimes.com is definitely The New York Times – no one has to manually verify their documents. We also have here e.g. the European Commision @ec.europa.eu, government of Brazil @brasil.gov.br, and @interpol.int 👮🏼‍♂️ If you’re setting up an account for some well known organization, it’s highly recommended to switch to your domain as the handle from the start to make it clear that it’s an official account. * * * ## How are things called here? 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 team’s protests. 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. The timeline/home feed is also sometimes called a “skyline”. New users that have joined Bluesky recently are often called “newskies” – there is even a Newskies feed, 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 a labeller that gives you an elder label if you had an account before summer 2023. A “hellthread” is something that existed for some time in the spring of last year – 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. An “apocablock” or “nuclear block” is a name for the feature where when one user blocks another, it hides all previous replies between the two from everyone – it basically “nukes” the whole conversation to discourage other people from joining in (though you can still find the posts though if you’re determined, e.g. on the users' profile page feeds or in Skythread). A “contraption” 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). So e.g. trolls and haters who have crossed a line were told “ok, you’re going into the contraption”. * * * ## What is this federation thing? 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 – that’s roughly what federation means. Initially, all the critical pieces were controlled by Bluesky PBC; however, this is now starting to change. They’ve taken the first big step towards federation in February 2024, by letting people migrate their accounts to self-hosted servers. This is still a technically complex process, and there aren’t really any public non-Bluesky servers which allow open signup, but there are currently a few hundred mostly personal “PDS” servers, where people keep their accounts and data. If you’re worried that things will get more complicated, maybe you have some bad experiences from Mastodon – 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 – they’ve been migrated sometime in November 2023, and few people have noticed, because everything just kept working. Now, you will have an option to move your account to a server controlled by someone else, if you want to – but you’ll be able to just ignore the whole thing if you don’t care about it. (Fun fact: the Bluesky servers are all named after different kinds of mushrooms, e.g. mine is “amanita” 🍄 – so if you see someone talking about mushroom servers, they’re probably talking about those :) ### Bridgy Fed 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 third party “bridge” called Bridgy that has started operating recently. The way it works is that it “mirrors” Mastodon accounts and their posts to Bluesky or Bluesky accounts to Mastodon (for accounts that have enabled the bridge). It’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 Eugen Roshko (Gargron), creator of Mastodon, as seen on Bluesky. If you want your Bluesky profile to be visible on the Fediverse, you need to “opt in” by following the official Bridgy account on Bluesky, @ap.brid.gy. Your profile will be seen on the Fedi side as `@your.handle@atproto.brid.gy`. To follow Mastodon accounts that are already bridged (like Gargron’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, `@atproto.brid.gy@atproto.brid.gy`), or you can DM the @ap.brid.gy 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. * * * ## Search 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. 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’s no easy UI for everything yet – but the Bluesky team has posted a tutorial “Tips and Tricks for Bluesky Search” on their blog recently. ## Hashtags Support for hashtags in the app was added in February. 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. One thing that’s defined in the protocol but not implemented in the official app yet is that hashtags will eventually have two forms: inline tags like on Twitter, and external tags which are shown below the post, which I think is the way they work on Tumblr (Mastodon also recently started showing trailing hashtags at the end of a post as somewhat separated visually from the text). You will be able to use either or both in a post according to your preference, and they will be interchangeable, with both being returned in the same search. ## GIFs & video Videos are now available since first half of September. They’re currently limited to 1 minute, but this limit will likely be raised in the future. You can’t upload custom GIFs yet at the moment, but this should be coming soon too. For now 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. You can also embed e.g. YouTube videos in posts, which will play inline inside a post when clicked. (This whole feature was actually implemented by a third party developer.) ## Starter packs Starter packs are a fairly new feature (released in June) 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 AT Protocol developers, climate scientists and reporters, or astronomers, and then people can use it to find some interesting new accounts to follow, or just follow them all at once with one click. You can also share a link to a starter pack with people who don’t have a Bluesky account yet, and they can use it to sign up with to have some initial set of accounts to follow. (There’s no list of starter packs at the moment, you’ll just need to come across one shared in a post somewhere.) * * * ## Settings Some things that you may want to change in the settings: * _Languages_ – turn on all the languages that you understand or want to see; Bluesky generally hides posts in languages that you don’t have enabled from most places like your home timeline or feeds. For your own posts, you set a post’s language yourself in the compose post window – you can switch between a few languages, and if you have the wrong one set when writing, a popup with a warning should appear. * _Thread Preferences_ – 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 highly recommend enabling (although for longer threads you may want to use my tool Skythread instead :] * _Following Feed Preferences_ – choose if you want to see replies, quotes, reposts etc. in the home feed. ~~For replies, you can set a threshold of how many likes a reply needs to get to show up in the feed, and you can also choose to see all replies made by people you follow _to anyone_. This option is actually pretty nice when you don’t follow many people at the beginning, because it’s a great way to find new people to follow.~~ (Some more options here were removed recently, see the Feeds section for more info & workaround.) * _Chat Settings_ – 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’ve started before are accessible even if you change the setting * _Moderation » Content Filters_ – here you can set what kind of potentially objectionable content you may want to show or hide – 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.) In the “Advanced” 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 “Bluesky Moderation Service” labeller. * _Accessibility » Require alt text before posting_ – you can turn this on to always be reminded to set an alt text on photos (it’s generally considered nice to add the alt text to images whenever possible, for people who use tools like screen readers or VoiceOver) * _Accessibility » Disable autoplay for videos and GIFs_ – prevents videos and gifs from automatically playing in the feed, instead you get a play button on each that you have to press first to see it * * * ## Missing features 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 – 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. That said, in recent months they’ve started accepting outside contributions and some recent smaller features have actually been submitted as PRs from external developers. Some things that are still missing: #### Private profiles / circles Like I’ve mentioned in the Privacy section, 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 _some_ people but not others. There may very well be something like this in the future, because _a lot_ of people are asking for this and the team wants to look into it – 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. #### DMs on the protocol 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. But since *a lot* of people wanted to have _some_ way of talking privately with friends, even if it’s an imperfect one, the team has recently added a simple implementation of DMs that isn’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’t support group chats and images, only 1-to-1 text chat – but more features are coming soon. 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. #### Post editing 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. #### Soft-blocking / removing followers 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 – 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. I 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’re thinking about it. #### Polls 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 hope they’ll figure something out. #### Trends It would be nice to have some kind of “currently trending” screen like on Twitter. I think it would make Bluesky a better place for learning quickly about current events and hot topics, which was something that Twitter was always good at, and make it easier to find interesting things to read. It should be pretty easy to implement technically – there’s already a bot that someone made at @nowbreezing.ntw.app, which posts “tag clouds” of trending words & phrases every 10 minutes, other developers have also implemented it e.g. in Graysky, Skeets or SkyFeed. The main problem is probably in deciding (automatically) which trending words to promote and which should be hidden… it will probably require a lot of manual control at first to keep it safe. #### Two-factor authentication Coming soon, see the “Account security” section. #### Longer posts 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. (See a comment from lead dev about this.) So it looks like this won’t be changing – 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). #### Bookmarks It’s on the list, but no timeline at the moment. As a workaround, you can reply to posts with a comment “📌” and there’s a special bookmark feed which shows you all your comments with that emoji. #### Disabling reposts per user It’s blocked by some other things right now, but should come sometime next year. #### Links on the profile, scheduling, … I 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’re busy with now. * * * ## Other tools Finally, here are a few tools written by third party devs that you might find useful: * Firesky – a site that shows you a live feed of every single new post made on Bluesky – feels like watching the Matrix screen * Clearsky – 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 * wolfgang.raios.xyz – also shows your blockers, block statistics and can generate “interaction circle” images that show who you mostly keep in touch with * Jaz’s Profile Cleaner – lets you delete old data (old posts etc.) from your account * PLC handle tracker or internect.info – these let you look up the DID of an account and how the assigned handle has changed in the past * Jaz’s post stats (total count and daily posters), my stats (daily/weekly stats), and bskycharts (charts of firehose activity and daily/monthly active users) * Skythread – a thread reader by yours truly, mentioned earlier :] * Label Scanner, for checking if there are any labels assigned to an account or post * Bluesky quiet posters – shows the list of people you follow ordered by how long ago they’ve posted anything * Clean follow – lets you clean up your follows list of blocked and suspended accounts -- And some other guides (some mentioned earlier in the blog post): * Bluesky Social New User Guide (Kairi, Nov 2023 – archived) * How to get started on Bluesky (Emily Hunt, Nov 2023) * Bluesky user FAQ (Bluesky, May 2023) * The Newskies' Guide to Safety and Privacy on Bluesky (eepy, Oct 2023) * Bluesky Crash Course: Labelers (Kairi, Apr 2024 – archived) * The Guide to Feeds (Jerry Chen) * Algorithmic Choice with Custom Feeds (Bluesky, Jul 2023) * How to set your domain as your handle (Bluesky, Apr 2023) * Tips and Tricks for Bluesky Search (Bluesky, May 2024) * * * Thanks to Mozzius, Shreyan and Marshal for the feedback on the first draft :) ### Changelog: **12 Nov 2024** : added info about threads composer and the plans for disabling reposts **8 Nov 2024** : * added info or mentions of new features: videos, starter packs, pinned posts, listing quotes, timed word muting, new safety features, and OAuth * added a list of popular labellers * added a mention of handle services * expanded the “how are things called” section a bit * updated section about feeds, added a list of recommended feeds * updated section about federation, added info about Bridgy Fed * updated info about Jack Dorsey’s involvement **24 Jun 2024** : removed link to Aegis (rip) **19 Jun 2024** : * added info about built-in Tenor GIFs and DMs * added new section about labellers * Jack Dorsey is no longer on the board * updated some mentions about the Mastodon bridge, which is now live * some changes to feeds section – defaults for Following have changed, Discover is now much better, and you can remove the default feeds **26 Mar 2024** : added mention about handle history in the Privacy section **2 Mar 2024** : hashtags and word muting are now available, updated the part about longer posts **23 Feb 2024** : federation is live! **21 Feb 2024** : search now returns results when you search for a hashtag.
21.02.2024 18:05 — 👍 0    🔁 0    💬 0    📌 0
Preview
2023: Year of social media coding I had different plans for this year… then, Elon Musk happened. Elon took over Twitter in October last year, which set many different processes in motion. A lot of people I 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 was following ended up there. Then, Twitter started getting progressively worse under the new management. Elon’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. Changes, so many changes… and things changing meant that I 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: As usual, I 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 had a ton of fun doing it. So in this blog post, I wanted to share some of the things I’ve been working on lately. * * * ## The Dead Bird Site 🦤 I had a bunch of private tools written for the Twitter API. For example, I had a script that downloaded all tweets from my timeline and some lists to a local database. I 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. And then Elon shut off access to the API (unless you can afford $100 per month for a “hobbyist” plan), which meant I had to try to find other ways to get that data. I quickly got the idea that I could somehow intercept the JSON responses that the Twitter webapp (I 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 need. The problem is how to get them; I wanted to get data from my personal timelines, so I couldn’t do anonymous requests, and I didn’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. So the approach I settled on was to passively collect the requests in the browser, using Safari’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 can tell, there is no way for request monitoring extensions to look at the _content_ of responses, unless you inject scripts to the site.) I initially tried to implement it as a Mac app, which gave me a chance to start experimenting with Core Data a bit. But in the end, I rewrote it in Ruby and released it as gem I called “BadPigeon” – named after the friends who visit my balcony every day 🐦 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 twitter gem, so I could use all existing tools I had written with very little changes. The obvious downside is that it needs some manual help with the recording first, but I can live with that. I’ve been using this setup since June and it works pretty well for me so far. I had one more Twitter-related project that I sadly had to shut down though – the Rails Bot 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 to fetch its home timeline periodically from crontab, so I couldn’t make it work this way. ## mackuba ∕ bad_pigeon A tool for extracting tweet data from GraphQL requests made by the Twitter website 🐦 * * * ## Mastodon’t 🦣 As the migration of developer communities out of Twitter started, I was initially skeptical; looking back, I guess I just had to go through the “five stages of grief” at my own pace… I also didn’t initially see the change as _that_ bad as some others did, and to be honest I 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. So I 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 don’t need to pay absurd prices for an access key, don’t have monthly download limits and which can’t be taken over and locked down, was appealing to me. 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 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 thought that maybe in this new open world, where the only limit is my imagination, I could build any tools I ever wanted and share them with others. Well, turns out it’s not that simple… It’s true that the Mastodon APIs are completely open and generally permissionless; for example, you can easily download any account’s complete history 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. Sometime in winter I 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 got called out on “#fediblock” (Fediverse’s popular channel for warning about bad actors) as someone worthy of blocking. All of this has very quickly cured me of any ideas to build pretty much any public tool for the Mastodon API. I just don’t have the energy and mental strength to deal with people attacking me this way for simply building tools on an open API that they don’t like. What I ended up doing though was setting up my own personal Mastodon instance, martianbase.net. I joked that I’m probably the only person who hates Mastodon and also has their own Mastodon instance… But the first instance I signed up on last year was shut down unexpectedly, giving me no chance to migrate the account. That’s another thing I 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 decided not to trust another instance admin, but to set up my own place, so that I can have full control over it. * * * ## Blue skies ahead 🌤 And then, just as Twitter was slowly going down and Mastodon has disappointed me – I started hearing about Bluesky. Started as an idea of Jack Dorsey from Twitter back in 2019, 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. (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 Jay Graber 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 Nostr – to the point that he has even deleted his Bluesky profile.) 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. I got access to Bluesky in late April. I 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 was completely hooked from day one. 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. But the thing that excites me the most is the AT Protocol 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 “Decentralized IDs” (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 @mackuba.eu). The code it’s running on is open source, 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. 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 wanted to show you some of the things I’ve been busy building: ### Minisky On the first day after I got in, I already started digging in the API and I wrote a small Ruby script for archiving my timeline and likes (of course I did…). This eventually evolved into a Ruby gem I called Minisky, which provides a minimalistic API client that handles logging in, refreshing access tokens, making GET and POST requests to the API and returning parsed JSON responses. 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 use it as a base for some internal scripts, and for manually getting or sending some data to the API in the Ruby console. If you want to start playing with the Bluesky API or build some more specific tool that uses it, you can give this library a try (see the example folder for some ideas). It has no dependencies apart from Ruby stdlib. ## mackuba ∕ minisky A minimal client of Bluesky/AtProto API ### Custom feeds on Bluesky Bluesky has a really cool feature that I 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). Bluesky has both of these, but it also lets _anyone_ build a custom feed that selects and orders posts however you like, and most importantly, lets you make this feed available to everyone else. Custom feeds 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 “Blacksky”, general “top posts this week” feeds, or different variations of an algorithmic “home feed” using various approaches. 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. When the team launched this feature back in May, they included a sample feed service project implemented in TypeScript. But I’m not a big fan of JS/TS and Node, so of course I had to reimplement it all in Ruby :] 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). ### BlueFactory The first part is an implementation of the feed service itself. I based it on Sinatra, and it implements the three API 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. 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 JWT token 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). ## mackuba ∕ blue_factory A simple Ruby server using Sinatra that serves Bluesky custom feeds ### Skyfall 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. 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 IPLD/IPFS. The second Ruby gem is meant to simplify this for you. It uses an existing CBOR library to do some of the binary protocol parsing and faye-websocket 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. 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 connection graph of the whole network, or to track when a follower unfollows you), or blocks (to find out who is blocking you), or to monitor when you or your company or project are mentioned by anyone anywhere. I’ve also included an examples folder with some sample scripts in the repo. ## mackuba ∕ skyfall A Ruby gem for streaming data from the Bluesky/AtProto firehose ### Bluesky feeds template 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. 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. 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. ## mackuba ∕ bluesky-feeds-rb Template of a custom feed generator service for the Bluesky network in Ruby ### My feeds And now we get to the part that all of this was for – building my own custom feeds. I mentioned earlier that I often think about and experiment with various ways to find most relevant content to me on social media. So when I heard about the custom feeds feature, I 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 played with on Twitter). 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 _other_ Swift feed :] 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’ve left them running: * iOS & Mac Developers feed * macOS users feed * Linux feed * Star Wars feed * #buildinpublic feed ### Skythread The last one of my Bluesky-related projects, also with a “sky” in the name (most of the third party projects so far have either the word “blue” or “sky” as part of the name 😄). And this one is written in JavaScript for a change. 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 “hellthreads” that branch out endlessly. For me, a UI more like the one on Reddit would be ideal. (Bluesky has recently a thread view with limited nesting, as an experimental feature.) 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: The initial version of Skythread required logging in first to see anything, but I’ve recently switched it to a different API 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. Skyview) 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. ## mackuba ∕ skythread Thread viewer for Bluesky * * * ## One app to rule them all 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 don’t even have access to. But there’s something about Bluesky and the AT Protocol that really draws me to it… I think it’s some combination of a nicer UI/UX, tech/architecture that I like more, a new community that is only just forming, and having this feeling like I’m blazing the trail, being able to build all the tooling that doesn’t exist yet. I like being part of something that’s being created around me, flying on that plane that’s being built in the air, watching the devs build it live and feeling like I’m part of it all. I enjoy being there, I really want it to succeed, and I want to help with that as much as I can. So I have friends on all three platforms, and even though I spend most time on Bluesky, I 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 end up manually cross-posting a lot of things to 2 or 3 websites. Wouldn’t it be nice to have a tool, kind of like Buffer, 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 :] This one isn’t available yet and it still needs a lot of work before I can call it an “MVP”, but it’s going to look something like this: In the meantime, you can follow me here on any of these platforms – listed in the order of preference :) * 🦋 Bluesky: @mackuba.eu * 🦣 Mastodon: mackuba@martianbase.net * Twitter: @kuba_suder
09.11.2023 14:46 — 👍 0    🔁 0    💬 0    📌 0
Social media update - Elon's Twitter and Mastodon **Update 01.03.2023** : Updated Mastodon address - my previous instance has been unexpectedly shut down and I had to make a new account. I’ve decided to set up my own server to make sure it won’t happen again. **Update 09.11.2023** : I made a follow-up post which talks about the social media related projects I’ve been working on this year, and about Bluesky, where I’m spending most of the time now. * * * 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’ve surely noticed if you log in to these even occassionally. Twitter has been my internet home for over 13 years now. I started using it when my colleagues from Lunar Logic showed it to me, and especially in the recent years it’s been my main source of information and news. It’s where I went to keep track of what was happening in the Apple/Swift world, find useful tips about UIKit, SwiftUI 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 Covid pandemic and the Russian invasion of Ukraine this year. Like most of the people I’m following, I’m not very happy about Elon’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’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 wish this all hadn’t happened, and I’m angry at the people who made it happen for their own gain. 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’m not ready to give up on this site as long as the feed is loading and there are some tweets left to read. I’ve been trying out Mastodon like everyone else and I 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’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 had some ways of using Twitter that worked for me and a bunch of private tools I wrote for myself to help me automate some things - I will have to figure this all out again now. So I am on Mastodon, if only because I don’t want to miss out on things, but I am still on Twitter and I’m planning to stay and keep posting there, as long as it stays usable. I will probably be posting more on Twitter than Mastodon, because I feel more comfortable there. I hope some of my friends and people I follow stay on the platform, or at least check in from time to time. So here’s where you can find and follow me (**updated 09.11.2023**): * 🦋 Bluesky: @mackuba.eu * 🦣 Mastodon: mackuba@martianbase.net * Twitter: @kuba_suder * if you like to use RSS, check out the feeds for my blog
22.12.2022 16:21 — 👍 0    🔁 0    💬 0    📌 0
Preview
New edition of the "Guide to NSButton styles" **Note (Oct 2023):** The names of the buttons have been changed again in the SDK in macOS Sonoma - I will update the blog post again once I have Sonoma on one of my Macs :) Back in October 2014 I wrote a post about different styles of NSButtons. That was in the era of OS X Yosemite and Xcode 6. I started researching what each kind of button available in Interface Builder was for, because I couldn’t figure that out from Xcode and the built-in documentation - I dug a bit into the Human Interface Guidelines, some older documentation archives and into Apple apps themselves. I collected everything into a long post that went through all the button styles and described what I could find about each one. It seems that a lot of people also had the same problem, because the post turned out to be extremely popular. It’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’s clearly demand for this kind of information collected in one place. 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 didn’t get a Mac with a Retina screen until the end of 2016. 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’ve seen some people already call the Yosemite-Catalina era design “classic macOS”!). Some new button variants were added, some older buttons were no longer used in system apps the way I presented them, and the button styles available in Xcode were no longer shown and described as shown on screenshots from Xcode 6. The Big Sur launch seemed like a great moment to give that post a refresh, and I 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 suppose. I only managed to get back to this project this month. I 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 VirtualBox to get updated Retina screenshots from there, building a number of versions of a sample app full of all kinds of buttons that I 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 2006, looking through Apple apps searching for buttons, and view-debugging some of them with SIP turned off to check what controls were used there… whew 😅 I’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 expanded a lot of the content, adding some things I hadn’t thought about last time and clarifying some that I hadn’t fully understood after I found some new information - and each button now has three different screenshots, sometimes even four: Of course I’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 :) After Big Sur I don’t expect a next massive redesign for another few years, so I probably won’t need to repeat this anytime soon - but if macOS 13 or 14 changes some minor things here and there, I’ll try to keep the post up to date. Read the updated blog post here - if you want to see the old version for some reason, you can find it in the Web Archive.
30.12.2021 14:28 — 👍 0    🔁 0    💬 0    📌 0
Preview
TypeScript on Corona Charts Back in spring I built a website 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 spent about a month of time working on it, but I mostly stopped around early May, since I 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 was only checking the page myself now and then. So it seemed like it wouldn’t be needed for much longer… “Oh, my sweet summer child”, I kinda want to tell the June me 😬 So now that autumn is here and winter is coming, I suddenly found new motivation to work on the charts site again. But instead of adding a bunch of new features right away, I figured that maybe some refactoring would make sense first. I initially built this page as a sort of hackathon-style prototype (“let’s see if I can build this in a day”), but it grew much more complex since then, to reach around 2k lines of plain JavaScript – all in one file and on one level. I started thinking about how I can make this easier to manage, and somehow I got the idea to try TypeScript. ## Why add static typing? I used to believe that static typing was just unnecessary complication. 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 suddenly discovered Python and later Ruby, and it was like a breath of fresh air. I also read Paul Graham’s book “Hackers and Painters”, which made a big impression on me, steered me away from big corpos towards the startup world (for which I’m forever grateful), and also showed me how much better dynamically typed languages (specifically Lisp) were than statically typed ones. I spent the next few years writing mostly Ruby and some JavaScript for the frontend, and I loved it. I still love Ruby and to this day I use it for all my scripting and server-side code. However, at some point I 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’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 think it does. But after using Swift for a few years, I 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’s also great while refactoring. And most importantly, it makes it harder to break one part of the code while changing another – we don’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’re discovered. Long story short, I 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’m working on it, and not only the parts that are currently executed. ## Learning TypeScript TypeScript is not a difficult language, it’s not even a completely new language – it’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. The official TypeScript site has a great docs section, and you can learn everything you need there. Start with the TypeScript for JS programmers intro and then go through the whole handbook and possibly the reference part if you want more, and that’s it. ## Setting up the editor A normal person would just download VS Code… however, that’s not me. I just refuse to run any IDE or editor without a fully native UI look & feel, so that leaves me with very little choice for those moments when I’m not using Xcode. For Ruby and JavaScript, I use TextMate, which I’ve been using non stop since 2008 (now the version 2 for the last few years). There is a TextMate bundle for TypeScript, however, it only provides code highlighting and formatting. To simplify running the compiler while I’m in the editor, I manually added two actions using the bundle editor so that I don’t need to switch to the terminal to run `npx tsc` every time: 1) Compile to first error (shows output in a tooltip): #!/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 > 0 puts first_line if first_line =~ /\((\d+)\,(\d+)\)\:/ TextMate.go_to :line => $1.to_i end else puts "Build OK" end 2) Compile file (shows output in a special new window): #!/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') This is a bit hacky, but it works for me. It only supports a scenario with one TypeScript file for now – there’s apparently no way to make `tsc` both use the `tsconfig.json` config to configure compiler options, but also specify a specific filename on the command line. And I’d love to have real autocompletion too, but you can’t have everything… (If you know a good programmer’s editor with a native Mac UI, please let me know!) ## Setting up the compiler & build Once you download the TypeScript compiler node module, you can run `npx tsc --init` to create a `tsconfig.json` file at the root of your project. In this file you can specify where to look for `.ts` files, how modern JavaScript it should output (I chose `es2018` since I don’t need to support any old browsers), and turn specific checks on and off. I’ve experimented a lot with the compiler options, and eventually I’ve left everything on except `strict`, `strictNullChecks` and `strictPropertyInitialization` (which requires `strictNullChecks`). The null checks add a ton of additional errors everywhere, and would require me to unwrap everything with `!` like in Swift on every step (e.g. every call to `querySelector`, `querySelectorAll`, `parentNode` and such things), and I decided it’s just not worth the effort. It could possibly make sense if I was using some framework that was abstracting all interaction with DOM like React. If you use any external libraries, like Chart.js in my case, you will also want to download their `.d.ts` definition files before you start working on your code – otherwise the compiler will keep telling you “I have no idea what this `chart` thing is and whether it has such property”. As for running the build, `npx tsc` does everything once you configure it in `tsconfig.json` – the problem is how to make it run when it needs to, and what to do with what it outputs… 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 have a Ruby project built on top of Sinatra that has no JavaScript build system (since I have very little JavaScript in general outside of the Corona Charts page) and even no proper asset pipeline configured. So I could either set up some new build system just for this, or write some kind of hack. You can guess what I picked. Since I only have one TypeScript file for now, and it’s only used on one page, I realized I can just manually check the timestamp in the controller action and rebuild if the `.ts` file is newer: get '/corona/' do # ... unless production? original = 'public/javascripts/corona.ts' compiled = 'public/javascripts/corona.js' if File.mtime(original) > File.mtime(compiled) puts "Compiling #{original}..." `npx tsc` puts "Done" end end erb :corona, layout: false end That’s for development – for production, I simply run it as one of the deployment phases in my Capistrano script: 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 ## Updating the code 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’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. The changes I had to make can be grouped in a few categories: * deleting some unused code, variables and parameters (that I haven’t realized were unused) * creating type definitions for all informal data structures used in the code – e.g. declaring that a `DataSeries` is a hash mapping a `string` to an array of exactly 4 numbers, what the structure of the downloaded JSON is, or that the `valueMode` parameter can only be “confirmed”, “deaths” or “active” (it’s so cool that you can have such specific types!): 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<string, DataPoint>; interface DataItem { place: Place; data: DataSeries; ... } * defining all global variables as explicitly typed properties on `Window`: interface Window { colorSet: string[]; coronaData: DataItem[]; chart: Chart; ... } * declaring all custom properties I set on built-in objects like DOM elements, or method extensions added to built-in types like Object or Array: interface HTMLAnchorElement { country: string; region: string; place: Place; } * declaring class variables and their types: class Place { country?: string; region?: string; title?: string; ... } * declaring the type of all function parameters (you don’t usually need to specify return types, those are inferred): function datasetsForSingleCountry(place: Place, dates: string[], json: DataSeries) { ... } * casting some DOM objects to a more specific type like `HTMLInputElement` when I want to use the `value`: let checkbox = document.getElementById('show_trend') as HTMLInputElement; window.showTrend = checkbox.checked; * providing a type for local vars initialized with `[]` or `{}`: let ranking: RankingItem[] = []; let autocompleteList: string[] = []; It’s a lot of changes in total when you look at the diff, but most of it is things I needed to write once somewhere at the top. When I write a new function now, I usually just need to add types to the parameters in the function header. ## Was it worth it? So far – I’d say, absolutely. Like I wrote above, when adding new functions I usually just need to declare parameter types, unless I start adding completely new types, but most of the time I operate on the ones I already have. Like in most modern languages, you usually don’t need to define a type for a local variable like `let thing = getThing()`, because the compiler knows that it’s of type Thing. And if you return it, it knows this function always returns a Thing when it’s called elsewhere. So it doesn’t add much overhead for new code, but it does give me this nice feeling that someone is checking what I write. I’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 managed to compress it to 190 KB now using Brotli compression set at max level). I changed the declaration of `window.coronaData` at the top to be an object instead of an array, and the `DataSeries` 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 didn’t have to use the editor search to hunt down every single place of use, and worry that I might have missed one that I’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. So am I 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 in JavaScript? I think it does.
15.10.2020 15:43 — 👍 0    🔁 0    💬 0    📌 0
Preview
WatchKit Adventure #4: Tables and Navigation < Previously on WatchKit Adventure… Two weeks ago I posted the first part of a tutorial about how to build an Apple Watch app UI using WatchKit, using a `WKInterfaceController` and a storyboard. We’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. Here’s the second part: today we’re going to add a second screen that lets the user choose which station they want to load the data from. So far I’ve used a hardcoded ID of the station that’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. (I initially wanted to also include a selection of the measured pollutant – from things like sulphur oxides, nitrogen oxides, benzene etc. – and I’ve actually mostly implemented it, but that turned out to be way more complex than I thought, so I dropped this idea.) The starting point of the code (where the previous part ends) is available here. * * * ### Preparing the data Since the list of stations doesn’t change often, we can hardcode a list of stations with their names, locations and IDs in a plist file that we’ll bundle inside the app. The list is generated using a Ruby script, in case it needs to be updated later – you can just download the plist and add it to the Xcode project. At runtime, the list will be available in the `DataStore`: 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() -> [Station] { let stationsFile = Bundle.main.url(forResource: "Stations", withExtension: "plist")! let data = try! Data(contentsOf: stationsFile) return try! PropertyListDecoder().decode([Station].self, from: data) } } ### Handling secondary screens 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: * we can add an explicit button somewhere on the screen that opens it (usually in the bottom part) * we can put it on another page in the page-based layout (e.g. like sharing and awards in the Activity app) * or we can put it as an action in the menu accessed through Force Touch The third option (Force Touch menus) is going away now. In the watchOS 7 betas, Apple has removed all Force Touch interactions from the OS and their own apps, the APIs for using it in third party apps (`addMenuItem` in `WKInterfaceController`) are deprecated, and it’s highly likely that the upcoming Series 6 watch will not include it as a hardware feature. Hiding some actions in a menu had the advantage that it didn’t clutter the main view, but it also made those actions less discoverable and harder to use for those who need them. I personally always had a problem with the Force Touch menus in that I rarely remembered that they exist, and I often not realized that an app had some extra features if it put them there… So I guess it’s for the better, although it will probably take some time to adjust. Using a page view controller and putting settings on the second page could work, but it doesn’t feel to me like this feature is important enough to get its whole new section in the main app navigation. I don’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 “Page View Controller” in the library, but instead you drag a segue from the first screen to the second and choose “Next page”.) So I’ve decided that in this case it’s not a problem to have an additional button at the bottom of the main screen, which opens the list in a separate view. The second choice is how to show the screen: do we show it as a modal, or push it onto the navigation stack (the latter only possible if we aren’t using a page-based layout)? Again, both could work and it’s mostly a matter of preference. But I think in this case a pushed view integrates better with the rest of the app. ### Opening the list view So let’s look at the storyboard again. We’d like to have a button below the chart that looks like a single table cell, which says “Choose Station” and shows the current station below, and opens a selection dialog when pressed. 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’m not sure what happens if you put a button into a group in a button – it might be like when you type Google into Google…). By the way, SwiftUI, which started its life on watchOS, kind of took over this idea – the `Button` takes a closure that returns its label and you can also put almost anything there. So let’s add a button at the bottom of the view here, and change its **Content** type to **Group**. The group is horizontal by default, so change its **Layout** to **Vertical**. Next, drag two labels inside: a top label “Choose Station”, with a **Body** font, and a bottom label that shows the name of a station, with a **Footnote** font and **Light Gray color**. Like on iOS, you can also configure labels so that they’re able to shrink if the text is too long – lower the **Min Scale** slightly to 0.9, since the station names are kind of long. 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’d like it to look like the standard buttons used e.g. in the Settings app’s various dialogs, but I don’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.) So here’s how we’ll do it: add another plain button to the view. Select the new group, open the select field for the **Color** property (not Background – that’s used when you have an image background) and choose “Custom”. Now, in the system color picker use the “eyedropper” tool at the bottom to read the color value from the standard button: Now you can delete the plain button. Let’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 `InterfaceController`, since we’re going to need it later: @IBOutlet var stationNameLabel: WKInterfaceLabel! ### Pushing the list view Now, we’re going to add a second screen to our app. Drag a new **interface controller** from the library and put it on the storyboard on the right side of the main screen. Then, drag a **segue** from the button to the new screen – you’ll probably have to use the element sidebar on the left, since if you drag from the button’s rendering in the scene, it selects the group and not its parent button. Select the “**Push** ” segue type (for a modal, you’d do it the same way, but with a “**Modal** ” type segue instead). Alternatively, we could leave the new screen disconnected, assign it an identifier, and then open the screen manually in code using the `pushController(withName:context:)` method, or `presentController(withName:context:)` in case of a modal, from the `IBAction` triggered from the button. But this sounds like more work, and I generally like to use segues whenever possible, since the storyboard then shows a clearer picture of the whole flow of the app. The pushed view shows a “<” back button at the top, and you can put a title there. However, **these titles work differently than on iOS** : on iOS, you usually have a “< Back” button on the left, and a title in the center. On the Watch, there isn’t enough space for that – so the title shown after the “<” sign is supposed to be the name of the _current_ view, not the view that you can get back to. In this case, let’s make it say simply “Station”, since “Choose Station” is a bit too long (you need to leave space for the clock on the right). You can type it into the **Title** field, or just double-click the top area where the title should be (though it won’t be displayed on the storyboard). ### Designing table cells 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’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 `UITableViewCell`, here they’re called “Row Controllers”; cells also have identifiers, and you can use different kinds of cells in one table. To start, drag a **table** from the library into the second view. You automatically get one standard row type created for you, but you can add more. Add a label to the table row’s group, use a standard **Body** font, but set **Min Scale** to 0.8 to accomodate longer names. By default the label will put itself in the top-left corner, so set its **Vertical Alignment** to center. 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: Unfortunately, there doesn’t seem to be any equivalent of `UITableViewCell.AccessoryType` here – if you want to have a checkmark, you need to add it manually as a normal label. So, add another label to the same row group, set its title to the unicode symbol “✓” (which looks very similar to the checkmark in the settings), and “borrow” the green color from the system checkmark in the Settings using the same eyedropper method as before. Set its **Vertical Alignment** to center too. We have one problem though: the first label takes all available space, and the checkmark is pushed to the edge: Sadly, there are no “compression priorities” here like in AutoLayout, so there’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 **Fixed width** which is then enforced – 15 seems about right; it’s not an elegant solution, but it works. Set also its internal **Alignment** (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. ### Handling the channel ID Let’s look at our data & networking code for a moment. When the user picks a station, we’re going to store the channel ID in the `DataStore`. 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’s no longer relevant: 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) } } 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’s the relevant change in the `KrakowPiosDataLoader` class. Instead of the hardcoded ID of one specific station that I’ve used before, we’ll now be passing the ID of the selected station to the query. ### The table controller We’ll handle the table view in code in a new interface controller, which we’ll call `StationListController`. Create such class in Xcode (inherit from `WKInterfaceController` – there’s no special “table view controller”) and assign it to the new scene on the storyboard. Also add this outlet and connect it: @IBOutlet weak var table: WKInterfaceTable! We’ll also need a row controller class (it’s required, and there is no default base class with standard outlets like `UITableViewCell` that we could use anyway). Use `NSObject` as the base class and call it `StationListRow` – like table view cells, it will be very simple: 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) } } Assign the class name to the row on the storyboard and connect the outlets. A row also needs an identifier – call it `BasicListRow` (yes, there will be another :). Now, in the `StationListController`, the interesting stuff happens in the `awake(withContext:)` method. What is a context, you might ask? It’s a cool idea that Apple kind of expanded on in the iOS 13 SDK, in the form of `UIStoryboard.instantiateViewController`, which lets you have a custom initializer in a `UIViewController` in which you can receive any required data, while still using storyboards and segues to navigate to the view controller. 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 `awake(withContext:)`. We’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’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 “delegate protocol” to pass the response back. Let’s prepare a simple struct for the context data: struct StationListContext { let items: [Station] let selectedId: Int? let onSelect: ((Station) -> ()) } The selection controller will get this data from the main interface controller, which assigns it in the `contextForSegue(withIdentifier:)` method: override func contextForSegue(withIdentifier segueIdentifier: String) -> Any? { if segueIdentifier == "ChooseStation" { return StationListContext( items: dataStore.stations, selectedId: dataStore.selectedChannelId, onSelect: { _ in } ) } return nil } For this to work, you need to select the segue on the storyboard and assign it the identifier `ChooseStation`. In the `StationListController`, we’ll receive the data from the context in `awake(withContext:)`: class StationListController: WKInterfaceController { var selectedRowIndex: Int? = nil var items: [Station] = [] var selectionHandler: ((Station) -> ())? override func awake(withContext context: Any?) { let context = context as! StationListContext items = context.items selectionHandler = context.onSelect ... Notice that we don’t need to call `super()` in `awake(withContext:)` – the same is true for `willActivate`, `didAppear` etc. If you look at the documentation of those, it always says “ _The super implementation of this method does nothing_ ” there. To initialize the table, we call the `setNumberOfRows` method to set the row count, and then we iterate over the rows to initialize their contents (there is no “cell reuse” and initializing cells during scrolling, it’s all done up front). If you wanted to have multiple types of rows in one table, then you need to call `setRowTypes` instead and pass it an array with as many repeated identifiers as you want to have rows. table.setNumberOfRows(items.count, withRowType: "BasicListRow") for i in 0..<items.count { let row = table.rowController(at: i) as! StationListRow row.showStation(items[i]) } We’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’ll store the index of the row, so that we can later deselect it easily). 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 } } To handle row selection, we’ll implement `table(_:didSelectRowAt:)` (you don’t need to assign any delegate/data source properties to the table or add any protocols, it works automatically): 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) -> StationListRow { return table.rowController(at: index) as! StationListRow } As you can see: we manually hide the checkmark in the previously selected row whose index we’ve saved, we show the checkmark on the current row, we store the row index, and we pass the `Station` back through the callback block. Finally, we need to handle the selection in the parent controller when the callback is called. Specifically, we’ll need to: * save the channel ID of the new station in the `DataStore`, so that further requests to the web service will load data from that station * update the displayed station ID in the button at the bottom * show in the UI that we have no data from that station yet * request to reload the data immediately Here’s how we do it: func setSelectedStation(_ station: Station) { dataStore.selectedChannelId = station.channelId stationNameLabel.setText(station.name) updateDisplayedData() gradeLabel.setText("Loading") KrakowPiosDataLoader().fetchData { success in self.updateDisplayedData() } } And remember to call this in the callback block from `StationListContext`: return StationListContext( items: dataStore.stations, selectedId: dataStore.selectedChannelId, onSelect: { station in self.setSelectedStation(station) } ) The end result should look like this 🙂 And one more thing: we need to also remember to initialize the selected station label when the view is first loaded. We’ll make it say “not selected” if nothing was selected yet: // 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") } } * * * ## User location There’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’ll only be interested in the one that’s closest to them? And why make them scroll through the whole list, if some of the stations are 100 km away from them? 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’t are connected to an iPhone that has one. On watchOS we ask for location exactly like on iOS – so we can follow the instructions I wrote here a few years ago: * add an `NSLocationWhenInUseUsageDescription` key to the `Info.plist` (e.g. “ _SmogWatch uses location data to pick a station that ’s closest to you._”) * add a reference to a `CLLocationManager` in the `InterfaceController` and make it its delegate * ask for location access when the main screen opens: 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 } } We’re going to store the location in `userLocation` when we find it, so that we can use it in the station selection screen. ⚠️ One warning here – I initially added `askForLocationIfNeeded()` to `didAppear` so that we only ask for location once the UI appears, and I was expecting `didAppear` to always be called following `willActivate` – but it doesn’t seem to work this way. From my testing right now, it seems that `didAppear` 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. Next, if the user grants us location access after the launch, we ask for location data then: func locationManager( _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { switch CLLocationManager.authorizationStatus() { case .authorizedAlways, .authorizedWhenInUse: locationManager.requestLocation() default: break } } We only ask for a single location, we don’t need to track it continuously. And we can also set `desiredAccuracy` to `kCLLocationAccuracyHundredMeters` when setting up the `CLLocationManager` – we won’t need more precision than that, and we should get the location much faster this way. When we get the location, we save it in the property `userLocation` mentioned earlier (we also need to handle an error case – you actually get an exception immediately if you don’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’s very likely it will be exactly the data that they want 👍 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) -> [Station] { return dataStore.stations.sorted { (s1, s2) -> 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 < d2 } } We can then pass the saved location to the stations list, where we’ll use it to show the distance to each station, and we can also pass it a list of locations sorted by location: override func contextForSegue(withIdentifier segueIdentifier: String) -> 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 } Add a `userLocation: CLLocation?` property to the `SelectionListContext`, from which we’ll read it in the controller’s initializer. ### Showing distances in the list Let’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’s location, otherwise we show the old version. It’s possible that we could somehow make this work with a single cell type, but I figured that a much easier way would be to have two different cells managed by the same class. So make a duplicate of our `BasicListRow`, give it an identifier `ListRowWithDistance` and keep the same class name. In order to have 3 elements in the cell positioned correctly, we’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: * Wrap the left label in a **Vertical group**. * Add a second label inside that inner group. Make sure it’s below the first one (you can set their vertical alignment, or you can just make sure they’re in the right order in the view tree). * The table row’s main group has a fixed “Default” height configured when created – but with two labels, this default height is too little. So change the height setting to **Size to fit**. * Give the lower label a **Light Gray color** and a **Footnote** font. Make it say e.g. “3.2 km”, and assign it to an outlet in the `StationListRow`: @IBOutlet weak var distanceLabel: WKInterfaceLabel! * Give the vertical group **Insets** of 3 at the top and bottom, and change its **Width** setting to “Size to fit content” – otherwise it will take whole cell width by default and push the checkmark out. * Customize the outer (horizontal) group’s **Spacing** 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. * The checkmark’s properties should stay as before. We can then add another helper method to `StationListRow` to set the distance. We’re going to use `MeasurementFormatter` here in order to automatically display kilometers or miles, and we’ll also make sure to only print 1 decimal digit, since we asked for a slightly less precise location (the default is something like “2.456 km”): 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) } And now in the `awake(withContext:)` method in `StationListController` 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: let rowType = (context.userLocation == nil) ? "BasicListRow" : "ListRowWithDistance" table.setNumberOfRows(items.count, withRowType: rowType) for i in 0..<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)) } } You should now see something like this: * * * We’ve reached the end of this tutorial. For the next episode, I will try to rewrite the whole UI again from scratch in SwiftUI and compare how much effort it requires to build the same kind of UI in the new framework :) The final version of the code after a completed tutorial is available on a branch here, and the slightly different real version on the master would be more or less at this commit.
10.09.2020 11:36 — 👍 0    🔁 0    💬 0    📌 0
Preview
WatchKit Adventure #3: Building the App UI < Previously on WatchKit Adventure… This is the third part of my series about building a WatchKit app that shows current air pollution level on the watch face (it started here). In this episode, we’re going to build the app’s main UI. I will be building on top of some data handling & networking code written in the previous episode about complications, so if you haven’t seen that one, you might want to at least skim through it to get some idea about what this is about. Browse through the WatchKit category to see the whole list. We’re venturing into a somewhat uncharted territory now… The WWDC talks about WatchKit are an amazing source of information and they’re great to get started (I definitely recommend watching them, especially the earlier ones, from 2015 & 2016), but once you actually start building things and run into a problem, there’s surprisingly little help available. Even StackOverflow isn’t of much use. There aren’t many books out there either that are up to date – I got one from raywenderlich.com, but it doesn’t really answer the hard questions, and it wasn’t updated since watchOS 4; Paul Hudson has another, and that’s pretty much it. I’ve tried to figure out some things myself, but some questions are left unanswered. If you know how to solve anything better than I did, please let me know in the comments. * * * ## The two frameworks watchOS SDK launched first in 2015 with a new UI framework called WatchKit. It was a very different framework than what we knew from macOS and iOS, a framework specifically designed for the Watch and all its inherent and temporary limitations – and also limited in what it could do and what you could do with it. It got people excited, but also very quickly frustrated, once they’ve run into these limitations. It didn’t help that Apple’s own apps were very obviously doing some things in the UI that weren’t possible to external developers, clearly using some internal APIs Apple needed to build more powerful apps, but which they didn’t want to share with us. So of course the hearts of Watch developers started beating faster when we heard the brief mention “… and a new native UI framework” during the 2019 keynote – said almost as if we were supposed to miss it. Of course about two hours later we’ve learned that this new framework was SwiftUI, built not only for watchOS (although that’s how the whole thing started, apparently!), but for all Apple platforms. A thing that would completely change Apple platform development. However, as people who have rushed to try out this new framework quickly discovered, SwiftUI as released in the iOS 13 SDK was “a pretty solid version 0.7” – a massive step forward of course, especially on watchOS, but still more of a beta. The “version 2.0” released this June seems like a very decent update, but it’s not stable yet and at this point it’s still unclear if it solves most of the issues that people had with the first release. So here we are, with two frameworks, an old one that’s very limited, and a new one that’s still kind of unfinished. Which one should I use to build an app right now? Which one should I learn? Well… ¿Por qué no los dos? 😎 Seriously speaking, my intuition tells me that if you’re starting to build a new Watch app right now, it probably makes more sense to go with SwiftUI. Since the platform was much more limited previously, the gain from using SwiftUI compared to the old way is probably much bigger than on iOS, and while on iOS you’re likely to often run into things you can’t do with SwiftUI that you could with UIKit, it’s probably more of the opposite on watchOS. But since I haven’t really built anything with the plain old WatchKit before, I still want to have this experience, if only just to have a broader knowledge of the platform. So let’s build the app in the classic WatchKit now, and then we’ll do the same thing again in SwiftUI and compare. * * * ## Design Before you start writing code, it’s good to spend some time first designing and thinking about what you’re actually planning to build, how it should look and work and why. Perhaps even away from the computer, with a pen and a piece of paper. This is true for any kind of app, but especially for Apple Watch apps. Watch apps are designed for extremely quick interactions, on the order of a few seconds – the user should be able to open your app, find the information they need and leave, without spending too long trying to understand your app’s layout and navigation. So it’s worth spending some time to make sure that your app really works this way. I started by listing the things the user might want to see and do in this app: * see the current pollution level * view a history chart with earlier values * select the monitoring station * select the specific parameter Next, I looked through the apps I have installed on my Watch (mostly first-party ones) to get some idea and inspiration about what layout and navigation they use and find something somewhat similar to what I want to build. In the end, I think the Activity app looked closest to what I imagined: a big circle with the 3 colored arcs, showing you the most important information at a glance, and then by scrolling down you can access some additional information like per-hour charts, steps count and so on. I got a piece of paper and did some planning and drawing, and ended up with something like this :) The option 2 is what I went with – I figured that having a big colored circle with the PM value on the first screen would fit the idea of making the app “glanceable” by providing the most important information first, same as in the Activity app. I managed to build the complete app in about 4-5 days. I want to describe the whole process here, but this got a bit long so I broke it into two parts: the main screen and the list that lets you select the measuring station. * * * ## The status screen Like from the beginning, I’m doing anything in the open, in a repo on GitHub – licensed under WTFPL, so you’re free to reuse any piece of code for anything you want. If you want to follow with me in Xcode, here is the commit from which we start, and here’s the complete version (after this first part of the tutorial). Open our `Interface.storyboard`. Let’s remove the notification scenes added by the template for now to clear up some space. Let’s start with something I completely missed until the moment I had the app finished and this blog post ready to ship – you can learn from my mistakes 😅 Here it is: a Watch app needs to have a title label! Look at some of the system apps – they all show their name in the top bar next to the clock: This is not `CFBundleDisplayName` or something else that happens automatically – you need to set it as the title of your main interface controller. Set it either in the Attributes inspector (the “Title” field), or by double-clicking the top bar area on the storyboard (the title won’t be displayed there, only when the app runs). If you have some kind of brand color that you use in your icon and logo that the users associate with your app or service, you should also set it on the storyboard as the “Global Tint” (in the File inspector – first tab), then it will be used to color that title label. I’m going to keep the default gray tint color. ### The value circle Now, we’re going to work on the thing that the user will see when they first open the app – the big circle showing the measured value. Drag the first item from the Library to the view – a **label**. Make it say **“ PM10”** and use the **Title 3** font style. In general, you should try to use one of the 10 standard semantic font styles if possible, to take advantage of Dynamic Type and have all fonts adapt to user’s chosen text size. Notice that you can’t really position the label wherever you want in the view by dragging – the way you position things in WatchKit is by using the “**Alignment** ” and “**Size** ” properties in the Inspector, and by wrapping items in **Groups** which act like stack views on macOS/iOS. Position this label to **Center** horizontally. (In this case, you could achieve the same effect by setting its size to 100% of the container width and then setting its internal text alignment – the one in the “Label” section – to centered.) Add another **label** (or you can make a copy of the first one), make it say **“ Good”**, use the same **Title 3** font and also position it to **Center** horizontally. Next, we need the main circle. We could use an image, but we can also use a group and set its background and corner radius so that the four corners form a circle. Drag a **group** into the view between the two labels, and a **label** inside it. Set the group’s **Color** to e.g. light gray or green, and the label’s text to some number like “32”, its font to **System 42** (we won’t be using Dynamic Type for this one since it should be large enough for everyone), its **Text Color** to **black with 80% alpha** , and position it to **Center/Center** inside the group. Now, we run into two problems: * first, I’d like to position the top & bottom labels and the circle so that they always take whole height of the screen: the two labels take as much as they need, and the circle takes whatever is left * second, I’d like to have the circle always keep a 1:1 aspect ratio, so that its width is equal to whatever height it’s allowed to have Unfortunately, this doesn’t seem to be possible in WatchKit. You can position something at the top and bottom, but there isn’t really such width or height setting as “take whatever is left”. There is also no aspect ratio constraint (since there are no constraints in general). You can only size a thing to fit its content (not helpful here, since we want to make the circle big, with plenty of space around the number), size it relatively to the container – but ignoring whatever else is inside it, or use a fixed size. So I think the best we can do here is to use a fixed size. Make the circle group **100×100** in size and give it a **Corner Radius** of 48. Also **center** the group horizontally in the view. Notice that it would probably be nice to have some more spacing between the labels and the circle. We can add spacing using groups. The top level view is actually a group itself – if you select the **Interface Controller** , you can set e.g. its background, insets and spacing – but you can only set one consistent spacing per group, and we might want to use different spacings further down. So instead lets wrap the three elements in a new **vertical group** – you can do that by selecting them and using the “embed” button in the bottom toolbar: Now give the group a **Spacing** of 7. Also, override the default **Insets** and set **Top** to 8 in order to add some margin from the title in the title bar – which you don’t see now, but you will once you run the app. Looks good! Because of the limitations I’ve mentioned it won’t always look perfect depending on the watch and selected text size, but it seems to work well enough in most cases. The difference between the smallest and largest text sizes isn’t as drastic here as on iOS. ### Interface controller Now, let’s write some code to show the right values. We’ll be adding it in our `InterfaceController`, which is WatchKit’s equivalent of a view controller. First, add these 3 outlets: @IBOutlet var valueCircle: WKInterfaceGroup! @IBOutlet var valueLabel: WKInterfaceLabel! @IBOutlet var gradeLabel: WKInterfaceLabel! Connect them on the storyboard to the elements we’ve added: the circle, the label inside it, and the bottom label, respectively. We’ll also need a reference to the `DataStore` that we’ll load values from (see Episode 2). let dataStore = DataStore() Now we need to have some logic for how to interpret the raw numbers we get from the web service – how much is good enough, and how much is not? Let’s add an enum `SmogLevel` which will cover this. ⚠️ Note: the ranges configured below are my personal subjective interpretation of how I feel about the given pollution levels. They are somewhat skewed by the fact that the smog levels in southern Poland during winter are more often above the safety limits than within them (although it got much better in the last year or two, fortunately). In theory, anything above 50 µg/m3 should be considered bad. enum SmogLevel: Int, CaseIterable { case great = 30, good = 50, poor = 75, prettyBad = 100, reallyBad = 150, horrible = 200, extremelyBad = 10000, unknown = -1 static func levelForValue(_ value: Double) -> SmogLevel { let levels = SmogLevel.allCases return levels.first(where: { Double($0.rawValue) >= value }) ?? .unknown } var title: String { switch self { case .great: return "Great" case .good: return "Good" case .poor: return "Poor" case .prettyBad: return "Pretty Bad" case .reallyBad: return "Really Bad" case .horrible: return "Horrible" case .extremelyBad: return "Extremely Bad" case .unknown: return "Unknown" } } } We’ll also use different colors for each range – for simplicity, we’ll use the HSB system and use colors of the same saturation and brightness, differing only in the hue. var color: UIColor { let hue: CGFloat switch self { case .great: hue = 120 case .good: hue = 80 case .poor: hue = 55 case .prettyBad: hue = 35 case .reallyBad: hue = 10 case .horrible: hue = 0 case .extremelyBad: hue = 280 case .unknown: hue = 0 } if self == .unknown { return UIColor.lightGray } else { return UIColor(hue: hue/360, saturation: 0.95, brightness: 0.9, alpha: 1.0) } } Finally, let’s add a method that reloads the values in the UI, and call this method from `awake(withContext:)`: func updateDisplayedData() { let smogLevel: SmogLevel if let amount = dataStore.currentLevel { let displayedValue = Int(amount.rounded()) valueLabel.setText(String(displayedValue)) smogLevel = SmogLevel.levelForValue(amount) } else { valueLabel.setText("?") smogLevel = .unknown } valueCircle.setBackgroundColor(smogLevel.color) gradeLabel.setText(smogLevel.title) } Looks pretty nice already, doesn’t it? (This is a real live value from a nearby air monitoring station.) ### Last update time We could also add a label showing last update time, so that you can easily see if the value is up to date. Add another group below the previous one (it’s sometimes hard to position a new thing in the right place, so that it’s added at the root level and not into an exising group – you need to have some patience), make sure that its **Layout** is **Horizontal**. Add two labels inside, which will be arranged left to right. Give them a font style of **Caption 1** and make them say “Updated:” and e.g. “15:00” (we’ll use a formatter to print time in the right format). Change the group’s **Width** to **Size to fit content** and its **Horizontal Alignment** to **Center**. Note, that’s something different than horizontal layout: layout means how the group arranges _its child elements_ , and alignment means how _the group positions itself_ inside the parent, which is the root container here. (For extra confusion, labels also have an additional text alignment property…) Override also the group’s **insets** and set the **Top** and **Bottom** to 2 to keep some spacing from the “Good” label and from what we’ll add below. You might notice a `WKInterfaceDate` item in the library that seems to be a customized label for showing date: Why not use that one, sounds like a perfect place to use it? I’ve actually tried to do that at first, but I couldn’t find a way to set its date… Turns out, if you read the description carefully, you can see it says that it’s only meant for showing _current_ date 😉 It’s a way to avoid using an `NSTimer` to keep the date label updated if it’s meant to show the current time which constantly changes – but in our case, we’re showing a past time that will only change when the data is refreshed. So we can just use a plain label and manually format the time once using a `DateFormatter` – which also gives us more options for customizing the format than the date label would. In the interface controller, add two more outlets and connect them on the storyboard to the right label and the whole group: @IBOutlet var updatedAtLabel: WKInterfaceLabel! @IBOutlet var updatedAtRow: WKInterfaceGroup! We’ll also need the date formatter. I’ve been thinking what would be the best way to show the time: should I include just the hour, day, or full date? But then I realized: if the data is more than a few hours old, it’s useless anyway! What does it matter if the pollution was high or low a week ago? So there are really two cases: either the data was updated at most a few hours ago today, or it’s e.g. 2am and it was updated shortly before midnight yesterday. If it’s more than a few hours old, we’re going to treat it as if we had no data at all, because doing otherwise could just be misleading. So we’re going to use two different time formats: when the data was updated earlier today, we’ll only show the time, and if it was yesterday, we’ll add the short day name for clarity. We’re using the `DateFormatter.dateFormat` method here which generates an appropriate specific format string that includes the listed fields for your current locale – so depending on your settings the hour might be in the 24- or 12-hour system, 0-padded or not, and so on. let dateFormatter = DateFormatter() let shortTimeFormat = DateFormatter.dateFormat( fromTemplate: "j:m", options: 0, locale: Locale.current ) let longTimeFormat = DateFormatter.dateFormat( fromTemplate: "E j:m", options: 0, locale: Locale.current ) We’ll also hide the whole `updatedAtRow` if we have no data to show. The updated `updateDisplayedData()` method will look like this: func updateDisplayedData() { var smogLevel: SmogLevel = .unknown var valueText = "?" if let updatedAt = dataStore.lastMeasurementDate { updatedAtRow.setHidden(false) dateFormatter.dateFormat = isSameDay(updatedAt) ? shortTimeFormat : longTimeFormat updatedAtLabel.setText(dateFormatter.string(from: updatedAt)) if let amount = dataStore.currentLevel, Date().timeIntervalSince(updatedAt) < 6 * 3600 { smogLevel = SmogLevel.levelForValue(amount) valueText = String(Int(amount.rounded())) } } else { updatedAtRow.setHidden(true) } valueCircle.setBackgroundColor(smogLevel.color) valueLabel.setText(valueText) gradeLabel.setText(smogLevel.title) } func isSameDay(_ date: Date) -> Bool { let calendar = Calendar.current let updatedDay = calendar.component(.day, from: date) let currentDay = calendar.component(.day, from: Date()) return updatedDay == currentDay } The app should now look like this: * * * ## History chart Now we’re getting to the exciting part: we’re going to do some drawing! At this point I took a break from coding to research the options. The main problems: there is no `UIView.drawRect:` in WatchKit, and there’s no `UIScrollView` that would let you scroll parts of the screen independently. I initially imagined some kind of chart that can be scrolled horizontally to show more points than fit on the screen. I could possibly simulate the scrolling with some horrible hacks, but I’ve realized that this would probably be both unnecessary and possibly confusing in terms of UX. I usually don’t care about the numbers from yesterday or earlier, and if I do, I can check them on the web – it would be enough to show the last few points, say, 6-8, so that you get the idea of what the trend is and if you should expect the value to rise or fall. Like I said, there is no `drawRect:` – however, there is a `WKInterfaceImage` and it accepts dynamically generated images, and we can generate one with Core Graphics. `UIGraphicsImageRenderer` is not available, but you can use the older function-based API and capture an image using `UIGraphicsGetImageFromCurrentImageContext()`. Alternatively, I could use a `WKInterfaceSKScene` and draw the chart using SpriteKit – it’s a framework made mainly for games, but Apple have said explicitly in their WatchKit talks that it can be used for things like animations inside apps. That said, I have zero experience with SpriteKit, so I would have to spend some additional time learning it from scratch – and I have a feeling that this would be an overkill for something like this. However, it’s still something to keep in mind if you need to do some more advanced drawing on watchOS, or especially animations. (Although at this point building it in SwiftUI might be a better idea – even if you just embed one tiny piece of it in a classic WatchKit interface.) ### Getting the data First, we’ll need to store some more data – right now we only store a single point (a value and a date). Luckily, we don’t need to change that much – we’re already getting all points from the given day in the response, it’s just that we’re discarding all except the last one, and now we need to keep them. In some cases we might also need to load data from the previous day, so that you don’t see an empty chart at 2am – normally we’ll simply remember old points from previous requests, but this will be needed later once we add a way to change the station we get data from. However, this is a lot of code and it’s kind of not relevant to the topic of building a UI, so just assume that we now have an updated `DataStore` with an interface like this: struct DataPoint { let date: Date let value: Double } class DataStore { var currentLevel: Double? { points.last?.value } var lastMeasurementDate: Date? { points.last?.date } var points: [DataPoint] { ... } // keeps last 8 points } You can see the full (final) implementation on GitHub here: * DataStore – stores and retrieves the data to/from `UserDefaults` * KrakowPiosDataLoader loads the data from Krakow’s regional air monitoring service * DataManager decides when to load which data, and makes sure that complications are reloaded when needed Or alternatively, copy the version of `DataStore` and `KrakowPiosDataLoader` from this commit that adds just the changes needed for this part. It would also be nice to be notified in the UI when the loader loads the data in the background. To achieve that, we’ll send a notification from `ExtensionDelegate` when the data is received: NotificationCenter.default.post(name: DataStore.dataLoadedNotification, object: nil) And in the InterfaceController, we’ll subscribe to this notification: override func awake(withContext context: Any?) { super.awake(withContext: context) updateDisplayedData() NotificationCenter.default.addObserver( forName: DataStore.dataLoadedNotification, object: nil, queue: nil ) { _ in self.updateDisplayedData() } } ### Adding a chart container Add another group into the view, below the update time label, and add an **Image** into the group. Set the group’s **Insets** to 15 at the top and 15 at the bottom. Leave the size settings at the default (size to fit content + the group filling whole container width) – we’ll specify the image dimensions in the code. Bind the image on the storyboard to an outlet in the `InterfaceController`: @IBOutlet var chartView: WKInterfaceImage! One problem with WatchKit that I’ve run into a few times is that all the view objects **only have setters and no getters**. It might have something to do with the fact that the UI is technically running in another process, or rather a subprocess now since watchOS 4. So reading information from the view would involve something more than simply accessing some place in the process memory. If you look at the documentation of e.g. WKInterfaceLabel, you can see that it has methods like `setText`, `setTextColor` and other inherited from WKInterfaceObject – but they don’t have matching getters like `textColor`. Which means that you can’t ask any view element at runtime what its current text or color is, and specifically you can’t ask it what its current size is. So if we want to render the image in code for a specific size, this size needs to be hardcoded in the code. The only exception is that an interface controller can call the method `self.contentFrame`, which returns the frame of the whole view it manages – which we will be using here to at least get the width of the rendered image, since that will depend on the size of the watch (we’ll keep the same height for all sizes). ### Drawing the chart This will be quite a lot of code, so let’s put it in a separate class to avoid the Massive View Controller pattern. However, there’s an important difference here from iOS and UIKit – **you ’re not really supposed to subclass view classes in WatchKit**. Again, if you look at the documentation for WKInterfaceLabel, WKInterfaceImage etc., they all say: “ _Do not subclass or create instances of this class yourself_ ”. But we can always have a separate class that just handles rendering a chart to a `UIImage`, and that’s what we’re going to do: class ChartRenderer { let chartFontAttributes: [NSAttributedString.Key: Any] = [ .foregroundColor: UIColor.lightGray, .font: UIFont.systemFont(ofSize: 8.0) ] let leftMargin: CGFloat = 17 let bottomMargin: CGFloat = 10 let rightMargin: CGFloat = 10 func generateChart(points: [DataPoint], size chartSize: CGSize) -> UIImage? { ... } } We’re going to call this class from `InterfaceController`, at the end of the `updateDisplayedData` method: let chartRenderer = ChartRenderer() let points = dataStore.points let chartSize = CGSize(width: self.contentFrame.width, height: 65.0) if points.count >= 2, let chart = chartRenderer.generateChart(points: points, size: chartSize) { chartView.setImage(chart) chartView.setHidden(false) } else { chartView.setHidden(true) } In the `generateChart` method, first we need some boilerplate to get the context and then capture the image at the end: UIGraphicsBeginImageContextWithOptions(chartSize, true, 0) guard let context = UIGraphicsGetCurrentContext() else { return nil } let width = chartSize.width let height = chartSize.height // ... drawing ... let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image Next, let’s draw the Y axis on the left and the X at the bottom: context.setStrokeColor(UIColor.lightGray.cgColor) context.move(to: CGPoint(x: leftMargin, y: 0)) context.addLine(to: CGPoint(x: leftMargin, y: height - bottomMargin)) context.addLine(to: CGPoint(x: width - rightMargin + 2, y: height - bottomMargin)) context.drawPath(using: .stroke) Next, we’ll print the min and max value on the left side of the Y axis. For that we’ll make a helper function `drawText` that can print text towards the left or right, or centered on the given point (in this case, these two labels will be right-aligned): enum TextAlignment { case left, right, center } func drawText(_ text: String, x: CGFloat, y: CGFloat, alignment: TextAlignment = .left) { var leftPosition = x if alignment != .left { let width = text.size(withAttributes: chartFontAttributes).width leftPosition -= (alignment == .right) ? ceil(width) : ceil(width / 2) } text.draw(at: CGPoint(x: leftPosition, y: y), withAttributes: chartFontAttributes) } And we print the values like this: let values = points.map { $0.value } let minValue = Int(values.min()!.rounded()) let maxValue = Int(values.max()!.rounded()) drawText(String(maxValue), x: leftMargin - 2, y: -2, alignment: .right) drawText(String(minValue), x: leftMargin - 2, y: height - bottomMargin - 10, alignment: .right) Finally, we’ll calculate the position of each point based on the values from the `DataStore` and draw a line through them, and print matching hour labels exactly below the points, below the X axis. We’ll need two more helper functions for this: func chartPosition(forPointAt index: Int, from values: [Double], chartSize: CGSize) -> CGPoint { let xPadding: CGFloat = 3 let yPadding: CGFloat = 3 let innerWidth = chartSize.width - leftMargin - 2 * xPadding - rightMargin let innerHeight = chartSize.height - bottomMargin - 2 * yPadding let minValue = values.min()! let maxValue = values.max()! let xOffset = innerWidth * CGFloat(index) / CGFloat(values.count - 1) let yOffset = innerHeight * CGFloat(values[index] - minValue) / CGFloat(maxValue - minValue) return CGPoint( x: leftMargin + xPadding + xOffset, y: chartSize.height - bottomMargin - yPadding - yOffset ) } func hour(for point: DataPoint) -> Int { return Calendar.current.component(.hour, from: point.date) } Next, we set some line properties to make it look better at the joints: context.setLineWidth(1.0) context.setLineCap(.round) context.setLineJoin(.bevel) And we draw the line by starting at the first point and then jumping through the rest one by one (while also printing the hours below): let firstPosition = chartPosition(forPointAt: 0, from: values, chartSize: chartSize) context.move(to: firstPosition) drawText(String(hour(for: points[0])), x: firstPosition.x, y: height - bottomMargin, alignment: .center) for i in 1..<values.count { let position = chartPosition(forPointAt: i, from: values, chartSize: chartSize) context.addLine(to: position) drawText(String(hour(for: points[i])), x: position.x, y: height - bottomMargin, alignment: .center) } context.setStrokeColor(UIColor.white.cgColor) context.drawPath(using: .stroke) Voila 😄 (I suppose we could use a time formatter to print the hour labels here in a 12-hour format if the device uses one… but let’s leave that as an exercise for the reader ;) * * * ## Testing on different screen sizes There are currently 4 different Apple Watch screen sizes, and we need to make sure that our app works on each of them. Fortunately, in our case it seems to mostly work fine on any screen and also with different text sizes set in the Settings. I’ve built the storyboard and tested the app mostly on the 42mm variant, since I’m using a Series 3 42mm Watch right now, and I think that in general, just like on iOS, it’s a good strategy to design for compact/medium-sized devices first, and then scale up to larger ones and down to the smallest ones. 40mm is more or less the same, 44mm has more space but usually fills it in the right way automatically, and 38mm might require some minor tweaks. In this case, we’ll adapt a few things on the main screen for the 38mm: * The main circle is a bit too large there. Select the circle group, and in the inspector where you have the width & height set to 100, there is a tiny “+” on the left side – if you press it, you can add an exception for any property. Override the circle’s size to 90×90 on the 38mm (it’s good to do this when you have the storyboard set to render the scenes on this specific device, in the toolbar at the bottom, so that you see the effects immediately). * Also, in the circle group, make the radius 43 on the 38mm watch, otherwise the circle is going to look funny. * For the number label inside the circle, make the font slightly smaller too, 40pt let’s say. Left: before the change, right: after the tweaks Another tiny change I made was that on the new-style watches (40mm and 44mm) I changed the top spacing for the first group from 8 to 5 – these watches seem to automatically have some larger margin at the top, so we don’t need to add that much, and on the 44mm watch this allows us to fully see the update time label below. The rest should look ok – 38mm watches also have a smaller default text size than the larger ones (although you can change it of course), and 44mm has a larger default text size. We could possibly make the chart height slightly smaller or larger depending on the width (that would have to be changed in code, since that’s where we hardcoded it), but it looks ok as it is. 42mm, 40mm & 44mm You’re going to have a bit more work if you use static images in your app. In that case, images will usually be scaled exactly as they are saved in the file, and they will not change their size automatically based on the screen size (unless you set their widths relatively to the container). So it would probably make sense to have different variants of the same image for each screen size. Since watchOS 6, App Store uses app thinning to only download assets for a given Watch size, so it doesn’t make the bundle larger for your users, and by providing smaller image variants you can save some space on smaller devices. There are also some gotchas related to the rounded corners, safe areas and margins on the Series 4+ watches, but this should mostly be handled automatically if you’re using system controls, and you might only run into problems if you’re using SpriteKit/SceneKit views. You can learn about that from this 9-minute talk on Apple’s site. * * * The main app screen is complete now and we’ve reached the end of the first part of the tutorial. If you want to look at the code on GitHub, the version after this part is available here. In the second part, we’ll build a second screen for choosing the station providing the data from a list of available stations. Next post: #4 Tables and Navigation >
26.08.2020 13:25 — 👍 0    🔁 0    💬 0    📌 0
SwiftUI betas - what changed before 1.0 In the last few weeks I’ve been trying to catch up on SwiftUI - watching WWDC videos, reading tutorials. Not the new stuff that was announced 2 months ago though - but the things that people have been using for the past year. Last June, like everyone else I immediately started playing with SwiftUI like a kid with a new box of Legos. In the first month I managed to build a sample Mac app for switching dark mode in apps. However, after that I got busy with some other things, and never really got back to SwiftUI until recently, so by the time the “version 2” was announced at the online-only WWDC, I’ve already forgotten most of it. So in order to not get this all mixed up, I decided to first remember everything about the existing version, before I look at the new stuff. Back then, when I was watching all the videos and doing the tutorial, I was taking a lot of notes about all the components, modifiers and APIs you can use, every single detail I noticed on a slide. However, I was surprised to see how many of those things I wrote down don’t work anymore. After the first version that most people have played with and that the videos are based on, there were apparently a lot of changes in subsequent betas (especially in betas 3 to 5). Classes and modifiers changing names, initializers taking different parameters, some things redesigned completely. And the problem is that all those old APIs are still there in the WWDC videos from last year. But WWDC videos are usually a very good source of knowledge, people come back to them years later looking for information that can’t be found in the docs, Apple even often references videos from previous years in new videos, because they naturally can’t repeat all information every year. This was bothering me enough that I decided to spend some time collecting all the major changes in the APIs that were presented in June 2019, but were changed later in one place. If you’re reading this in 2021 or 2022 (hopefully that damn pandemic is over!), watching the first SwiftUI videos and wondering why things don’t work when typed into Xcode - this is for you. Here’s a list of what was changed between the beta 1 from June 2019 and the final version from September (includes only things that were mentioned in videos or tutorials): * * * ### NavigationButton Appeared in: “Building Lists and Navigation” tutorial, “Platforms State of the Union” ForEach(store.trails) { trail in NavigationButton(destination: TrailDetailView(trail)) { TrailCell(trail) } } Replaced with: `NavigationLink` ForEach(store.trails) { trail in NavigationLink(destination: TrailDetailView(trail)) { TrailCell(trail) } } * * * ### PresentationButton / PresentationLink Appeared in: “Composing Complex Interfaces” tutorial, “Platforms State of the Union” .navigationBarItems(trailing: PresentationButton( Image(systemName: "person.crop.circle"), destination: ProfileScreen() ) ) Replaced with: `PresentationLink`, which was later removed and replaced with `.sheet`: .navigationBarItems(trailing: Button(action: { self.showingProfile.toggle() }) { Image(systemName: "person.crop.circle") } ) .sheet(isPresented: $showingProfile) { ProfileScreen() } * * * ### SegmentedControl Appeared in: “Working With UI Controls” tutorial SegmentedControl(selection: $profile.seasonalPhoto) { ForEach(Profile.Season.allCases) { season in Text(season.rawValue).tag(season) } } Replaced with: `Picker` with `SegmentedPickerStyle()` Picker("Seasonal Photo", selection: $profile.seasonalPhoto) { ForEach(Profile.Season.allCases) { season in Text(season.rawValue).tag(season) } } .pickerStyle(SegmentedPickerStyle()) * * * ### TabbedView and tabItemLabel Appeared in: “SwiftUI Essentials”, “SwiftUI on All Devices” TabbedView { ExploreView().tabItemLabel(Text("Explore")) HikesView().tabItemLabel(Text("Hikes")) ToursView().tabItemLabel(Text("Tours")) } Replaced with: `TabView` and `tabItem`: TabView { ExploreView().tabItem { Text("Explore") } HikesView().tabItem { Text("Hikes") } ToursView().tabItem { Text("Tours") } } * * * ### DatePicker(selection:minimumDate:maximumDate:displayedComponents:) Appeared in: “Working With UI Controls” tutorial DatePicker( $profile.goalDate, minimumDate: startDate, maximumDate: endDate, displayedComponents: .date ) The `minimumDate` and `maximumDate` parameters were replaced with a `ClosedRange<Date>`, and a `label` parameter was added: DatePicker( selection: $profile.goalDate, in: startDate...endDate, displayedComponents: .date ) { Text("Goal Date") } You can also use this shorthand variant with a string label: DatePicker( "Goal Date", selection: $profile.goalDate, in: startDate...endDate, displayedComponents: .date ) * * * ### TextField(_:placeholder:), SecureField(_:placeholder:) Appeared in: “Platforms State of the Union”, “Working With UI Controls” tutorial TextField($profile.username, placeholder: Text(“Username”)) SecureField($profile.password, placeholder: Text(“Password”)) Replaced with: TextField("Username", text: $profile.username) SecureField("Password", text: $profile.password) * * * ### ScrollView(showsHorizontalIndicator: false) Appeared in: “Composing Complex Interfaces” tutorial ScrollView(showsHorizontalIndicator: false) { HStack(alignment: .top, spacing: 0) { ForEach(self.items) { landmark in CategoryItem(landmark: landmark) } } } Replaced with: `ScrollView(.horizontal, showsIndicators: false)` ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 0) { ForEach(self.items) { landmark in CategoryItem(landmark: landmark) } } } It doesn’t seem to be possible now to have a scroll view that scrolls in both directions, but only shows indicators on one side (?) * * * ### List(_:action:) Appeared in: Keynote, “Platforms State of the Union” List(model.items, action: model.selectItem) { item in Image(item.image) Text(item.title) } Removed sometime in later betas - you can use `selection:` instead: List(model.items, selection: $selectedItem) { item in Image(item.image) Text(item.title) } * * * ### Text.color() Appeared in: “Composing Complex Interfaces” tutorial, Keynote, “Platforms State of the Union” Text(item.subtitle).color(.gray) Replaced with: `.foregroundColor()` Text(item.subtitle).foregroundColor(.gray) * * * ### Text.lineLimit(nil) Appeared in: “Platforms State of the Union”, “SwiftUI on All Devices” Text(trail.description) .lineLimit(nil) `Text` used to have a default line limit of 1, so if you wanted to have a multi-line text control showing some longer text, you had to add `.lineLimit(nil)`. This was changed later and now no limit is the default. Instead, you may need to add `.lineLimit(1)` if you want to make sure that label contents don’t overflow into a second line if it’s too long. * * * ### Text(verbatim:) Appeared in: “Building Lists and Navigation” tutorial, “SwiftUI on All Devices” Text(verbatim: landmark.name) It’s unclear if and when anything has changed in this API since beta 1. The current docs say that: * `Text("string")` used with a literal string is automatically localized * `Text(model.field)` used with variable is _not_ localized * `Text(verbatim: "string")` should be used with a literal string that should not be localized So `verbatim:` shouldn’t (or can’t) be used with variables like in the code above anymore, since in this variant the text will not be translated anyway. The parameter was removed from later versions of the tutorial code. If you do want to localize a text that comes from a model property, use `Text(LocalizedStringKey(value))`. * * * ### .animation(…) Appeared in: “Animating Views and Transitions” tutorial, “Introducing SwiftUI”, “SwiftUI on All Devices” The `.animation` modifier has a number of different animation styles that you can choose from. This set of options has changed between the first beta and the final version. In beta 1 you could do: .animation(.basic()) .animation(.basic(duration: 5.0, curve: .linear)) .animation(.basic(duration: 5.0, curve: .easeIn)) .animation(.basic(duration: 5.0, curve: .easeInOut)) .animation(.basic(duration: 5.0, curve: .easeOut)) .animation(.default) .animation(.empty) .animation(.fluidSpring()) .animation(.fluidSpring( stiffness: 1.0, dampingFraction: 1.0, blendDuration: 1.0, timestep: 1.0, idleThreshold: 1.0 )) .animation(.spring()) .animation(.spring(mass: 1.0, stiffness: 1.0, damping: 1.0, initialVelocity: 1.0)) The `.basic` animations were replaced with options named after the selected curve: .animation(.linear) .animation(.linear(duration: 1.0)) .animation(.easeIn) .animation(.easeIn(duration: 1.0)) .animation(.easeInOut) .animation(.easeInOut(duration: 1.0)) .animation(.easeOut) .animation(.easeOut(duration: 1.0)) (I’m not sure what `.basic()` without any parameters used to do exactly.) What was called `.spring` is now `.interpolatingSpring`: .animation(.interpolatingSpring(mass: 1.0, stiffness: 1.0, damping: 1.0, initialVelocity: 1.0)) And `.fluidSpring` is now either `.spring` or `.interactiveSpring` .animation(.spring()) .animation(.spring(response: 1.0, dampingFraction: 1.0, blendDuration: 1.0)) .animation(.interactiveSpring()) .animation(.interactiveSpring(response: 1.0, dampingFraction: 1.0, blendDuration: 1.0)) `.default` is still available (I’m not sure what it does though) and `.empty` was removed. * * * ### .background(_:cornerRadius:) Appeared in: “Platforms State of the Union”, “SwiftUI Essentials” Text("🥑🍞") .background(Color.green, cornerRadius: 12) Removed later - you can use separate `.background` and `.cornerRadius` modifiers to achieve the same effect: Text("🥑🍞") .background(Color.green) .cornerRadius(12) * * * ### .identified(by:) method on collections Appeared in: “Building Lists and Navigation” tutorial, “Platforms State of the Union”, “SwiftUI on All Devices” ForEach(categories.keys.identified(by: \.self)) { key in CategoryRow(categoryName: key) } Replaced with: `id:` parameter ForEach(categories.keys, id: \.self) { key in CategoryRow(categoryName: key) } * * * ### .listStyle, .pickerStyle etc. with enum cases Appeared in: “Introducing SwiftUI”, “SwiftUI Essentials” .listStyle(.grouped) .pickerStyle(.radioGroup) .textFieldStyle(.roundedBorder) Replaced with: creating instances of specific types .listStyle(GroupedListStyle()) .pickerStyle(RadioGroupPickerStyle()) .textFieldStyle(RoundedBorderTextFieldStyle()) * * * ### .navigationBarItem(title:) Appeared in: “Platforms State of the Union” NavigationView { List { ... } .navigationBarItem(title: Text("Explore")) } Replaced with: `.navigationBarTitle` NavigationView { List { ... } .navigationBarTitle("Explore") } * * * ### .onPaste, .onPlayPause, .onExit Appeared in: “Integrating SwiftUI” .onPaste(of: types) { provider in self.handlePaste(provider) } .onPlayPause { self.pause() } .onExit { self.close() } Replaced with `.onPasteCommand`, `.onPlayPauseCommand`, `.onExitCommand` .onPasteCommand(of: types) { provider in self.handlePaste(provider) } .onPlayPauseCommand { self.pause() } .onExitCommand { self.close() } * * * ### .tapAction Appeared in: “Platforms State of the Union”, “Introducing SwiftUI”, “SwiftUI on All Devices” Image(room.imageName) .tapAction { self.zoomed.toggle() } MacLandmarkRow(landmark: landmark) .tapAction(count: 2) { self.showDetail(landmark) } Replaced with: `.onTapGesture` Image(room.imageName) .onTapGesture { self.zoomed.toggle() } MacLandmarkRow(landmark: landmark) .onTapGesture(count: 2) { self.showDetail(landmark) } * * * ### BindableObject and didChange Appeared in: “Handling User Input” tutorial, “Introducing SwiftUI”, “Data Flow Through SwiftUI” class UserData: BindableObject { let didChange = PassthroughSubject<UserData, Never>() var showFavorites = false { didSet { didChange.send(self) } } } Replaced with: `ObservableObject` with `objectWillChange` (needs to be called _before_ the change!). `objectWillChange` is automatically included, so you don’t need to declare it. class UserData: ObservableObject { var showFavorites = false { willSet { objectWillChange.send(self) } } } In simple cases, you can use the `@Published` attribute instead which handles this automatically for you: class UserData: ObservableObject { @Published var showFavorites = false } BindableObject was used together with: #### @ObjectBinding Appeared in: “Handling User Input” tutorial, “Introducing SwiftUI”, “Data Flow Through SwiftUI” @ObjectBinding var store = RoomStore() Replaced with: `@ObservedObject` @ObservedObject var store = RoomStore() * * * ### Collection methods on Bindings I don’t think this was mentioned in any talks or tutorials, but there were some tweets going around last June showing how you can do some cool tricks with bindings by calling methods on them, e.g.: Toggle(landmark.name, isOn: $favorites.contains(landmarkID)) Sadly, this was removed in a later beta. The release notes include some extension code that you can add to your project to reimplement something similar. * * * ### Command Appeared in: “SwiftUI on All Devices” extension Command { static let showExplore = Command(Selector("showExplore")) } .onCommand(.showExplore) { self.selectedTab = .explore } Replaced with: using `Selector` directly .onCommand(Selector("showExplore")) { self.selectedTab = .explore } * * * ### Length Appeared in: “Animating Views and Transitions” tutorial var heightRatio: Length { max(Length(magnitude(of: range) / magnitude(of: overallRange)), 0.15) } Replaced with: `CGFloat` var heightRatio: CGFloat { max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15) } * * * ### #if DEBUG Appeared in: all tutorials, “Platforms State of the Union”, “Introducing SwiftUI” This was automatically added around the preview definition in all SwiftUI view files created in beta versions of Xcode 11: #if DEBUG struct CircleImage_Previews: PreviewProvider { static var previews: some View { CircleImage() } } #endif It was removed from the templates in one of the final betas - you no longer need to add that: struct CircleImage_Previews: PreviewProvider { static var previews: some View { CircleImage() } }
17.08.2020 12:26 — 👍 0    🔁 0    💬 0    📌 0
Preview
Photo library changes in iOS 14 I’m the kind of person who cares a lot about their digital privacy. It makes me very uncomfortable when I see ads on Facebook for something I opened on another site a moment ago, and I generally don’t like it when companies are learning more about me than they should, even if the effects of that tracking aren’t as obvious. That’s why for example I’ve been trying to move away from Google services as much as possible (I use ProtonMail as my main email and Apple’s iWork for documents), I also started using Tresorit and iCloud1) for file sync instead of Dropbox. That’s also one of the reasons why I’ve always used some kind of ad & tracker blocker in my browsers – previously Ghostery, now I also use Brave and I’ve been experimenting with making my own ad blocker. So it always makes me happy when Apple introduces another change to their OSes that limits the kinds of data that Mac and iOS apps can use without our permission. I especially liked: * when iOS 11 introduced the “While Using” option for location access that was non-optional for apps * the “Allow Once” option for location access in iOS 13 * permissions to things like camera, microphone or screen recording on the Mac This year Apple made another batch of changes that limit apps' access to data. The most interesting ones are the approximate location access and the limited photo library – in this post I’ll talk about the latter. * * * Most of us have thousands of photos on our phones, often going a few years back – after all, our iPhones are our primary cameras these days. These photos and videos capture everything we do, the places we go to, who we meet with and what we do together. They also include location info in their metadata. This is all possibly extremely sensitive data. So far however if you wanted to upload a single photo or screenshot to e.g. Twitter or Facebook or send it to a friend through a messaging app, you had to grant them access to your whole photo library – it was all or nothing. And you could never be sure what they do with it – are they just looking at this single picture, or maybe looking through your whole 30 GB library for any interesting stuff they can find there, and uploading that to their servers? Hopefully they aren’t, but you just had to trust them on this. Apple had previously provided a system image picker (`UIImagePickerController`) that lets the user choose a photo from their library and pass it to the app without giving it access to the library, as well as a way to save photos to the library without seeing what else is there (`UIImageWriteToSavedPhotosAlbum()`). However, for various reasons these don’t seem to be widely used in popular apps – most apps that do anything with photos currently ask for full read-write access to the whole library, just because they can. So this year Apple is taking a bit of a carrot and stick approach: the carrot is a new improved system photo picker, while the stick is a new way for the user to only give the app access to selected photos. * * * ## PHPicker PHPicker (not an actual name you can find in the docs, but a general name Apple uses for this new API) is a new system photo picker, a replacement for the old `UIImagePickerController`. The two most important differences are: * it has an integrated search, so it can help you find some specific photos that may not be recent * unlike `UIImagePickerController` it allows multiple selection It also has an updated design, and while you’re scrolling the photo grid you can zoom in and out to see more or less photos at the same time: ### How to use the picker The API consists of a few types with the `PHPicker*` prefix, and most of them are surprisingly simple. The main class that handles the picker screen is `PHPickerViewController`. It has a delegate protocol, `PHPickerViewControllerDelegate`, which you need to implement. You also need a `PHPickerConfiguration` object to pass it to the picker controller, in which you can set a few options for the picker. You create a picker controller like this: var config = PHPickerConfiguration() // ... let picker = PHPickerViewController(configuration: config) picker.delegate = self There are currently two options you can set in the picker configuration: **1) selectionLimit** This is the maximum number of items that the user can pick. The default is 1, and you can set it to some specific number, or to 0 to allow unlimited selection. config.selectionLimit = 0 **2) filter** The filter can be one of `.images`, `.livePhotos`, `.videos`, or a subset of those created using the `.any(of:)` helper: config.filter = .images config.filter = .any(of: [.images, .livePhotos]) Once the picker is configured, you can present it in the usual way: present(picker, animated: true) The last thing to do is to implement `PHPickerViewControllerDelegate`, which includes literally a single method: `picker(_: didFinishPicking results:)`. This method is called with a list of one or more `PHPickerResult` objects in the response when the user confirms their selection. With single selection it returns immediately when the user taps a photo, and with multi-selection they need to confirm it with a toolbar button when they finish selecting. The only part here that is not simple is that the photos are returned wrapped in `NSItemProvider` objects (used e.g. in the drag & drop API, or in some kinds of extensions). You need to get that item provider and first call `canLoadObject(ofClass:)` and then `loadObject(ofClass:)` (though I’m not 100% sure if the first is technically required).2) You also need to dismiss the picker view – it doesn’t hide itself automatically: func picker( _ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) for result in results { let provider = result.itemProvider if provider.canLoadObject(ofClass: UIImage.self) { provider.loadObject(ofClass: UIImage.self) { image, error // ... save or display the image, if we got one } } } } Apple is expecting that most apps that only access the photo library to attach one or two pictures to a post will switch to this new system picker now. (The old `UIImagePickerController` is deprecated – that is, the class itself is not, but it’s only keeping the camera part of its functionality.) And if they don’t like the carrot… well, then there’s still the stick. * * * ## Limited photo library The stick is that there is now a standard way for the user to only grant an app access to a selected subset of photos (most likely just a few, since they need to manually tap each one). This is *not opt-in* for apps – it affects every app immediately, even those that have been built on older SDKs. The way it works is that when the app tries to access the photo library (or explicitly asks for authorization), the user will now see a popup that looks like this: The top option leads them to a selection dialog which is the same new picker you’ve seen above. When the user confirms the selection, the app gets access to a kind of “virtual” photo library that only contains those few photos they’ve selected. To the app it looks almost like a normal photo library, it just has 5 photos in it instead of 2000 – that’s how it can work in existing apps. The app can’t access the remaining photos in any way, or even have any idea how many there are in total. It can however tell whether it got access to the full library, or some limited subset. You can use the `PHPhotoLibrary.authorizationStatus` method for this (which has an updated API – it now requires an `accessLevel` parameter, which is `.addOnly` or `.readWrite`): switch PHPhotoLibrary.authorizationStatus(for: .readWrite) { case .notDetermined: // ask for access case .restricted, .denied: // sorry case .authorized: // we have full access // new option: case .limited: // we only got access to a part of the library } To ask for access, you also need to pass the accessLevel parameter (remember to include a `NSPhotoLibraryUsageDescription` key in your Info.plist): PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in // … } For backwards compatibility, the old (deprecated) versions without the parameter return `.authorized` even when you get limited access. ### Updating the selection You’re probably asking now: how can the user update the selection? If we’re talking about an app like Twitter or Facebook Messenger, the user will only select a few photos that they want to share, but next time when they want to post a photo, they will already be authorized – so the popup won’t appear, and they will just be choosing from the same few photos they chose last time. Not good. So there are a few ways to solve this: **1) Settings** The user can always go to the Settings app, the Privacy section and update their selection there. However, they need to first know that there is such option and where to find it (the app can’t even deep-link to this specific page), so this is more like a last resort fallback. **2) Repeated alert** By default if the user grants limited access to the photo library to an app, they will see the same popup again after the app is restarted, and through that popup they can update their selection. This is also more of a way to somehow imperfectly support apps that haven’t been updated to the latest SDK – it solves the problem, but in an awkward way and only partially, since the popup won’t appear if you just hide the app, take a few more photos and open it again to share them, and the app doesn’t restart in the meantime. **3) Showing the selection UI  again** If you want handle this properly, the recommended way is to manually request to show the selection UI again. Apple explains that if you have an app that requires full access to the photo library (e.g. some app whose main purpose is to let you browse and organize the photo library), you should add some kind of button in your UI that triggers the selection screen again. This button should only appear if `authorizationStatus` is `.limited`, and hide if the user grants the app full access. To show the selection UI, call this new method: PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self) The selection screen hides automatically when the user completes selection. You will not be notified of the result through any delegate method – you need to use a “change observer” to track when the set of available photos changes. Implement the protocol `PHPhotoLibraryChangeObserver` and call the `register(_ observer:)` method on the `PHPhotoLibrary`: PHPhotoLibrary.shared().register(self) func photoLibraryDidChange(_ changeInstance: PHChange) { // ... } Once you do that, it makes sense to disable that automatic alert mentioned in point 2 above – to do that, add the key `PHPhotoLibraryPreventAutomaticLimitedAccessAlert` to your Info.plist. **4) Using a system picker** The best option though is that you don’t ask for access to the photo library at all 😏 Remember the carrot? If you use the new system picker, you don’t need to ask for photo library authorization. The picker runs in a separate process, it handles the selection for you and sends you back only what the user selected, so they implicitly grant you access to those photos they picked. No other popups, no checking for authorization. So if you have an app that currently uses some kind of sliding sheet showing recent photos from which the user picks one to attach it to a post, you really, really should consider just using the system picker, instead of keeping the sheet as a kind of “staging area” and adding another unnecessary step to the flow. ### Saving to the library One special case is when you only need to save to the library, but don’t need to read from it – e.g. you want to let your users save some pictures from a feed or a website. In this case, you only need to ask for an “add only” access, which users may be more likely to grant if it’s obvious that your app doesn’t have any legitimate need for a read access. This is mostly unchanged from earlier iOS versions. To save a picture to user’s library, you can use this method: UIImageWriteToSavedPhotosAlbum(image, self, #selector(onImageSaved), nil) Or just pass nils if you don’t need a callback: UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) If you don’t have any authorization at this point yet, it will trigger a popup asking about one – but it uses a different wording and options that the one for read-write access, making it clear that this is about add-only access: You also need to include the usage key `NSPhotoLibraryAddUsageDescription` in your Info.plist. If you’d prefer to ask the user for write access explicitly, you can use the same `requestAuthorization` method: PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in … } * * * It will be interesting to see in the coming months how popular apps like Twitter, Facebook, Messenger etc. react to this new situation. The ideal scenario would be that they all switch to PHPicker and avoid the trouble with limited access – this is also, I believe, better UX for the users than if they insist on using library access and `presentLimitedLibraryPicker`. The worst case scenario is that they do nothing, assume that most users don’t care about privacy or will be too lazy and will just grant them full access, and those who insist on protecting their private photos will be left with working but kinda awkward user experience. Or maybe they’ll figure out something that works well – we’ll see. 1) Yes, I know that iCloud Drive is not end-to-end encrypted – but I trust Apple infinitely more than I trust Google and Dropbox. Hopefully they will add full encryption at some point – they are slowly expanding the range of things that are end-to-end encrypted, e.g. last year they’ve added some synced Safari data to the list. ↩︎ 2) This doesn’t seem to currenly work in the simulator in beta 1, including in Apple’s sample code from the talk about the picker. I haven’t tried on a real device. ↩︎
07.07.2020 14:36 — 👍 0    🔁 0    💬 0    📌 0