Swift
Reference
Every public type in the Swift package, every field on it, and the JSON each one came from.
Every value the server sends decodes into a model that is Codable, Sendable and Equatable. Anything with an id is Identifiable, so it goes straight into a ForEach. Page is the one exception: it is Sendable and Equatable, and it is assembled from the response rather than decoded whole.
Dates arrive as RFC 3339 in UTC and are decoded to Date. Fractional seconds are accepted. Nothing else reaches this wire: no date only strings, no local offsets.
An optional field is null on purpose, and the reason is always a project setting. A hidden count is null rather than a misleading zero.
VoteFirst
@MainActor public enum VoteFirst. The façade. Everything on it is static.
| Member | What it is |
|---|---|
start(_ key: String, host: URL) | The whole of setup |
start(project: String, key: String, host: URL) | The same, with the slug already known |
client | The VoteFirstClient underneath, or nil before start. private(set) |
setupError | Why start refused the key, or nil. private(set) |
theme | Your overrides. See Theme |
config | Support address, terms and privacy links |
signIn(ssoToken:) | Names the person using your app |
signOut() | Drops the sign in, keeps the anonymous voter |
forget() | Drops this device's voter and its queue |
diagnostics | What the package currently knows, or nil before a screen has appeared |
viewController(board:title:) | The board as a UIViewController. iOS only |
The calls, me() through send(), are in Calls. The views are in Screens.
public struct Diagnostics: Sendable, Equatable {
public let project: String?
public let isLoading: Bool
public let failure: String?
public let pendingWrites: Int
}
Worth printing when a board is empty and you want to know why.
VoteFirstClient
public actor VoteFirstClient. One project, one voter, one queue.
An actor because its state is the anonymous token, the resolved slug and the cached identity, and every screen touches all three at once. That is why every method on it is async, and not because every method makes a request.
public init(key: String,
host: URL = VoteFirstClient.defaultHost,
transport: Transport = URLSessionTransport(),
tokenStore: TokenStore = defaultTokenStore()) throws
public init(project: String,
key: String,
host: URL = VoteFirstClient.defaultHost,
transport: Transport = URLSessionTransport(),
tokenStore: TokenStore = defaultTokenStore()) throws
Both throw VoteFirstError.secretCredentialRefused for a key without the vf_pk_ prefix. That is the only reason either one throws.
| Member | What it is |
|---|---|
publishableKeyPrefix | "vf_pk_" |
defaultHost | https://app.votefirst.app |
project | What this client's stored files and keychain items are named after. The slug when you supplied one, a digest of the key when you did not. nonisolated |
host | Where it points. nonisolated |
currentIdentity | The voter, if one has been resolved |
isBanned | Whether the project has stopped this voter writing |
ensureIdentity() | Mints or renews, and returns the voter |
signIn(ssoToken:), signOut(), forget() | As on the façade |
board() | The board document. Also what resolves the slug |
features(_:), feature(id:) | Features |
comments(featureID:page:perPage:sort:) | One page of comments, newest, oldest or most liked |
changelog(page:perPage:), release(id:) | Releases |
messages(page:perPage:), dismiss(messageID:) | Your messages, and putting one away |
suggest(heading:description:) | Files a suggestion |
upvote(featureID:), unvote(featureID:) | Votes |
comment(featureID:content:parentID:) | Comments and replies |
likeComment(id:), deleteComment(id:) | A comment's like and its removal |
pendingWrites() | What is queued |
flushOutbox() | Sends the queue, and answers how many left it |
The client's method names differ from the façade's in seven places, because the façade reads like the route table and the client reads like a client.
| Façade | Client |
|---|---|
VoteFirst.suggest(_:details:) | client.suggest(heading:description:) |
VoteFirst.comment(_:featureID:replyingTo:) | client.comment(featureID:content:parentID:) |
VoteFirst.like(commentID:) | client.likeComment(id:) |
VoteFirst.delete(commentID:) | client.deleteComment(id:) |
VoteFirst.me() | client.ensureIdentity() |
VoteFirst.waiting | client.pendingWrites() |
VoteFirst.send() | client.flushOutbox() |
Everything else is spelled the same on both.
Board
GET /api/v3/projects/{slug}, and the document everything else is drawn against.
| Field | Type | JSON |
|---|---|---|
project | Project | project |
boards | [BoardEntry] | boards |
tags | [Tag] | tags |
display | DisplaySettings | display |
theme | BoardTheme | theme |
capabilities | Capabilities | capabilities |
Three questions it answers, as an extension rather than as raw fields, because each one has more than one setting behind it.
board.allowsVoting(on: feature) // this project, this board, and this column board.allowsComments // the project allows them and this plan has them board.allowsSuggestions // the project takes them and the form is on
allowsVoting(on:) treats a feature on a board it does not describe as votable, because the server is the authority and guessing no would hide a working button.
Project
| Field | Type | JSON | Notes |
|---|---|---|---|
name | String | name | |
slug | String | slug | What the routes are built from |
description | String? | description | Null when empty |
logoURL | URL? | logo_url | |
bannerURL | URL? | banner_url | |
currencySymbol | String? | currency_symbol | |
showBranding | Bool | show_branding | True on a plan that has not paid the footer away |
voterCount | Int | voter_count | How many distinct people have voted on anything here. The denominator behind N% of voters |
BoardEntry, BoardRef and BoardKind
A project has the roadmap, which every project has, and any custom boards it made public.
public struct BoardEntry {
public var id: Int
public var slug: String
public var name: String
public var kind: BoardKind
public var columns: [Column]
}
The roadmap is always id 0, slug "roadmap", kind .roadmap. A custom board carries its own id and slug, and kind .custom.
BoardRef is the same thing without the columns, which is what a feature carries: id, slug, name, kind.
BoardKind is a string with two known values on it rather than an enum, so a kind added later does not fail the decode.
BoardKind.roadmap // "roadmap" BoardKind.custom // "custom"
Column and ColumnRef
| Field | Type | JSON | Notes |
|---|---|---|---|
id | Int | id | -1 is the roadmap's archive |
name | String | name | Whatever the owner called it |
color | String? | color | A CSS colour. Custom board columns have one, roadmap columns do not |
position | Int | position | From 0, in the order the owner arranged |
status | String? | status | The stage this column means, on the roadmap. Null on a custom board |
isVotable | Bool | is_votable | Whether a vote on a feature in this column would be accepted |
ColumnRef is what a feature carries: id, name, color.
A custom board decides voting for the whole board at once, so every column on it carries the same isVotable. The roadmap decides per column.
Tag
id and name. The ids are what FeatureQuery(tagIDs:) takes.
DisplaySettings
What the owner publishes. Every one of these is a Bool except the last.
| Field | JSON | Notes |
|---|---|---|
showVotes | show_votes | When false, every voteCount and score is null |
showViews | show_views | When false, every viewCount is null |
showComments | show_comments | When false, every commentCount is null and the comment routes are closed |
showProgress | show_progress | When false, every progress is null |
showDenied | show_denied | Whether denied features are published at all |
showArchived | show_archived | Whether the archive is published at all |
allowVoting | allow_voting | |
allowComments | allow_comments | |
allowSuggestions | allow_suggestions | |
scoreIsWeighted | score_is_weighted | Whether score is the weighted number or the plain count |
defaultMode | default_mode | "dark" or "light", what the board's own page opens in, and what the screens open in when Theme.appearance is .board |
Capabilities
What this caller may do, which is the project's settings folded with its plan.
canVote, canComment, canSuggest, canLike, all Bool, from can_vote and so on.
Use board.allowsVoting(on:) rather than these directly. It reads both these and the display settings, which is what the server does before it accepts a vote.
BoardTheme and ThemePalette
public struct BoardTheme {
public var dark: ThemePalette
public var light: ThemePalette
public var font: String?
public var radius: String?
}
public struct ThemePalette {
public var background: String
public var text: String
public var textSecondary: String // text_secondary
public var accent: String
public var cardBackground: String // card_background
public var border: String
}
Colours are CSS strings, #RGB, #RRGGBB or #RRGGBBAA. The radius is written the way CSS writes it, "8px". The built in views read all of it for you; a custom interface can too.
Feature
| Field | Type | JSON | Notes |
|---|---|---|---|
id | Int | id | |
heading | String | heading | 254 characters at most |
description | String? | description | Null when empty. 2000 characters at most |
developerResponse | String? | developer_response | The project owner's own answer to this feature. Null when there is none |
responseIsDenial | Bool? | response_is_denial | Whether that answer is a refusal. Nil from a server older than the field; read isDenial, which falls back to the status |
status | FeatureStatus | status | |
board | BoardRef | board | Which board it is filed on |
column | ColumnRef | column | Which column of it |
tags | [Tag] | tags | A list because the shape allows one, and a feature carries at most one today |
progress | Int? | progress | 0 to 100. Null when the project hides progress |
voteCount | Int? | vote_count | Null when the project hides votes |
score | Int? | score | The weighted number on a project that weights votes, the plain count otherwise. Null with the count |
scoreIsWeighted | Bool | score_is_weighted | Which of the two score is |
hasVoted | Bool | has_voted | Whether the voter on this request has voted for it |
viewCount | Int? | view_count | Null when the project hides views |
commentCount | Int? | comment_count | Null when the project hides comments |
isPinned | Bool | is_pinned | The owner pinned it to the top |
isApproved | Bool | is_approved | False on a suggestion still waiting for the owner |
targetDate | Date? | target_date | |
startDate | Date? | start_date | |
release | ReleaseRef? | release | The release it shipped in |
changelogType | String? | changelog_type | What kind of change it was, on a shipped feature |
linkedFeature | FeatureRef? | linked_feature | The original feature this one was linked to, when the owner linked them |
createdAt | Date | created_at | |
updatedAt | Date | updated_at | Not moved by somebody looking at it |
public var isDenial: Bool public func applying(_ result: VoteResult) -> Feature
isDenial says whether the owner's answer turned this feature down, which the status alone cannot: archiving a declined feature reports it as archived and takes the refusal with it.
applying returns the same feature carrying the vote the server just recorded. A result for another id is ignored. A result with no count leaves the count alone.
FeatureRef and ReleaseRef are the two shorthands: an id with a heading, and an id with a name.
FeatureStatus
A string with six known values, not an enum, so a status added later does not fail the decode.
FeatureStatus.open // "open" FeatureStatus.planned // "planned" FeatureStatus.inProgress // "in_progress" FeatureStatus.done // "done" FeatureStatus.denied // "denied" FeatureStatus.archived // "archived" FeatureStatus.allCases // the six, in that order
The column's own name is what a person should read. The status is what code should branch on, because an owner can rename a column and the status stays put.
Comment
| Field | Type | JSON | Notes |
|---|---|---|---|
id | Int | id | |
parentID | Int? | parent_id | The comment this one replies to |
featureID | Int | feature_id | |
content | String | content | 2000 characters at most |
authorName | String | author_name | A generated handle, or a real name for a signed in voter |
authorRole | String? | author_role | owner or editor, set when the author speaks for the project. Null for everybody else, project members who can only read included |
isOwn | Bool | is_own | Whether the voter on this request wrote it. What tells you it can be deleted |
likeCount | Int | like_count | |
hasLiked | Bool | has_liked | |
createdAt | Date | created_at | |
replies | [Comment] | replies | One level deep. A reply's own replies is empty |
public func applying(_ result: LikeResult) -> Comment
Release and ReleaseFeature
| Field | Type | JSON | Notes |
|---|---|---|---|
id | Int | id | |
name | String | name | |
slug | String | slug | |
body | String? | body | The markdown the owner wrote |
bodyHTML | String? | body_html | The same body rendered, for an app showing a web view |
publishedAt | Date? | published_at | |
isFeatured | Bool | is_featured | Featured releases come first |
viewCount | Int? | view_count | Null when the project hides views |
features | [ReleaseFeature] | features | What shipped in it |
ReleaseFeature is id, heading, voteCount and changelogType. Only completed features appear in a release.
Message
| Field | Type | JSON | Notes |
|---|---|---|---|
id | Int | id | |
title | String | title | |
body | String | body | Plain text. Markup is never sent to a native client |
kind | MessageKind | kind | info, warning or critical |
presentation | MessagePresentation | presentation | banner or alert, a hint rather than a command |
actionLabel | String? | action_label | |
actionURL | String? | action_url | |
isDismissible | Bool | is_dismissible | False means draw no way to put it away |
priority | Int | priority | Higher first. The list arrives in this order |
startsAt | Date? | starts_at | |
endsAt | Date? | ends_at | Already gone from the list once it passes |
createdAt | Date | created_at | |
updatedAt | Date | updated_at |
message.action answers the label and the URL together, and only when both halves arrived and the address carries a scheme worth opening. MessageKind and MessagePresentation are raw value structs rather than enums, for the same reason BoardKind is: a value added later must not fail the whole decode.
MessageDismissed is messageID and dismissed, which is always true.
Identity
| Field | Type | JSON | Notes |
|---|---|---|---|
voterID | String | voter_id | anon_... or sso_... |
kind | IdentityKind | kind | .anonymous or .sso |
anonToken | String? | anon_token | The bearer value. Stored for you. Null on an SSO identity |
displayName | String | display_name | |
banned | Bool | banned | |
votedFeatureIDs | [Int] | voted_feature_ids | Every feature on this project this voter has voted for |
Write results
public struct VoteResult {
public var featureID: Int // feature_id
public var hasVoted: Bool // has_voted
public var voteCount: Int? // vote_count
public var score: Int? // score
}
public struct LikeResult {
public var commentID: Int // comment_id
public var hasLiked: Bool // has_liked
public var likeCount: Int // like_count
}
public struct CommentDeleted {
public var featureID: Int // feature_id
public var deleted: Bool // deleted
public var commentCount: Int? // comment_count
}
VoteResult.voteCount is nil in two cases and they mean the same thing to a caller: the project hides vote counts, or nothing changed because the vote was already in the state you asked for. Either way, the count you already have still stands.
FeatureQuery
public init(sort: Sort = .top,
scope: Scope = .roadmap,
column: Int? = nil,
tagIDs: [Int] = [],
statuses: [FeatureStatus] = [],
search: String? = nil,
page: Int = 1,
perPage: Int = 20)
public func page(_ number: Int) -> FeatureQuery
What reaches the wire:
| Field | Query item | Notes |
|---|---|---|
sort | sort | Always sent |
scope | board or scope=project | Nothing is sent for .roadmap, which is the default |
column | column | Not sent with .everyBoard, which the server refuses |
tagIDs | tag, repeated | |
statuses | status, repeated | |
search | q | Not sent when empty |
page | page | Held to 1 or more |
perPage | per_page | Held between 1 and 100 |
The sorts and the scopes are listed in Calls.
Page and PageMeta
public struct Page<Element> {
public let items: [Element]
public let meta: PageMeta
public var hasMore: Bool
public var nextPage: Int?
}
public struct PageMeta {
public let page: Int
public let perPage: Int // per_page
public let total: Int
public let totalPages: Int // total_pages
public let hasMore: Bool // has_more
}
Errors
VoteFirstError, APIFailure and ErrorCode are in Errors, field by field.
Transport
public protocol Transport: Sendable {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
The seam a test replaces. URLSessionTransport is the one that ships.
public init(memoryCapacity: Int = 4 * 1024 * 1024, diskCapacity: Int = 20 * 1024 * 1024) public init(session: URLSession)
See Testing for the stub.
TokenStore
public protocol TokenStore: Sendable {
func token(forProject project: String) -> String?
func setToken(_ token: String?, forProject project: String)
}
| Store | Where it keeps the token |
|---|---|
KeychainTokenStore(service:) | The data protection keychain, readable after first unlock. Falls back to the file store if the keychain refuses, which happens to unsigned builds |
FileTokenStore() | A file in the application support directory, 0600, with complete file protection |
InMemoryTokenStore() | Memory. For tests |
defaultTokenStore() | The keychain one where Security exists, the file one otherwise |
PendingWrite
public struct PendingWrite: Codable, Sendable, Equatable, Identifiable {
public let id: String
public let method: String
public let path: String
public let body: Data?
public let queuedAt: Date
public var idempotencyKey: String { id }
}
The id is the idempotency key the write was first attempted with, which is what makes replaying it safe.
VoteFirstStorage
public static func directory(project: String) -> URL? public static func cacheDirectory() -> URL?
Application support rather than user defaults, because reading user defaults is a required reason API and putting anything there would oblige your app to declare it. See Privacy.
Theme, Palette and Configuration
Theme is your overrides, Palette is what the views draw with once the overrides, the board's palette and the system colours have been laid over one another, and Configuration is the support address and the two links. All three are in Screens.
What is on disk
Under Application Support/VoteFirst/{project}/:
| File | What is in it |
|---|---|
voter.token | The anonymous token, only when the keychain refused |
outbox.json | Writes made with no connection |
view.json | Which stages this reader turned on, and their sort |
hidden.json | What this reader reported or blocked |
And Caches/VoteFirst/ for the HTTP cache.
{project} is your slug, or key-{digest} when the key named its own board. Everything but the cache is per project, which is why two projects in one app keep two voters.