Skip to content
VoteFirst Docs
Dashboard

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.

MemberWhat 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
clientThe VoteFirstClient underneath, or nil before start. private(set)
setupErrorWhy start refused the key, or nil. private(set)
themeYour overrides. See Theme
configSupport 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
diagnosticsWhat 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.

Diagnostics
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.

Both initialisers
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.

MemberWhat it is
publishableKeyPrefix"vf_pk_"
defaultHosthttps://app.votefirst.app
projectWhat 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
hostWhere it points. nonisolated
currentIdentityThe voter, if one has been resolved
isBannedWhether 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çadeClient
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.waitingclient.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.

FieldTypeJSON
projectProjectproject
boards[BoardEntry]boards
tags[Tag]tags
displayDisplaySettingsdisplay
themeBoardThemetheme
capabilitiesCapabilitiescapabilities

Three questions it answers, as an extension rather than as raw fields, because each one has more than one setting behind it.

In your app
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

FieldTypeJSONNotes
nameStringname
slugStringslugWhat the routes are built from
descriptionString?descriptionNull when empty
logoURLURL?logo_url
bannerURLURL?banner_url
currencySymbolString?currency_symbol
showBrandingBoolshow_brandingTrue on a plan that has not paid the footer away
voterCountIntvoter_countHow 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.

BoardEntry
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
BoardKind.roadmap    // "roadmap"
BoardKind.custom     // "custom"

Column and ColumnRef

FieldTypeJSONNotes
idIntid-1 is the roadmap's archive
nameStringnameWhatever the owner called it
colorString?colorA CSS colour. Custom board columns have one, roadmap columns do not
positionIntpositionFrom 0, in the order the owner arranged
statusString?statusThe stage this column means, on the roadmap. Null on a custom board
isVotableBoolis_votableWhether 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.

FieldJSONNotes
showVotesshow_votesWhen false, every voteCount and score is null
showViewsshow_viewsWhen false, every viewCount is null
showCommentsshow_commentsWhen false, every commentCount is null and the comment routes are closed
showProgressshow_progressWhen false, every progress is null
showDeniedshow_deniedWhether denied features are published at all
showArchivedshow_archivedWhether the archive is published at all
allowVotingallow_voting
allowCommentsallow_comments
allowSuggestionsallow_suggestions
scoreIsWeightedscore_is_weightedWhether score is the weighted number or the plain count
defaultModedefault_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

The board's own colours
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

FieldTypeJSONNotes
idIntid
headingStringheading254 characters at most
descriptionString?descriptionNull when empty. 2000 characters at most
developerResponseString?developer_responseThe project owner's own answer to this feature. Null when there is none
responseIsDenialBool?response_is_denialWhether that answer is a refusal. Nil from a server older than the field; read isDenial, which falls back to the status
statusFeatureStatusstatus
boardBoardRefboardWhich board it is filed on
columnColumnRefcolumnWhich column of it
tags[Tag]tagsA list because the shape allows one, and a feature carries at most one today
progressInt?progress0 to 100. Null when the project hides progress
voteCountInt?vote_countNull when the project hides votes
scoreInt?scoreThe weighted number on a project that weights votes, the plain count otherwise. Null with the count
scoreIsWeightedBoolscore_is_weightedWhich of the two score is
hasVotedBoolhas_votedWhether the voter on this request has voted for it
viewCountInt?view_countNull when the project hides views
commentCountInt?comment_countNull when the project hides comments
isPinnedBoolis_pinnedThe owner pinned it to the top
isApprovedBoolis_approvedFalse on a suggestion still waiting for the owner
targetDateDate?target_date
startDateDate?start_date
releaseReleaseRef?releaseThe release it shipped in
changelogTypeString?changelog_typeWhat kind of change it was, on a shipped feature
linkedFeatureFeatureRef?linked_featureThe original feature this one was linked to, when the owner linked them
createdAtDatecreated_at
updatedAtDateupdated_atNot moved by somebody looking at it
Signature
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
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

FieldTypeJSONNotes
idIntid
parentIDInt?parent_idThe comment this one replies to
featureIDIntfeature_id
contentStringcontent2000 characters at most
authorNameStringauthor_nameA generated handle, or a real name for a signed in voter
authorRoleString?author_roleowner or editor, set when the author speaks for the project. Null for everybody else, project members who can only read included
isOwnBoolis_ownWhether the voter on this request wrote it. What tells you it can be deleted
likeCountIntlike_count
hasLikedBoolhas_liked
createdAtDatecreated_at
replies[Comment]repliesOne level deep. A reply's own replies is empty
Signature
public func applying(_ result: LikeResult) -> Comment

Release and ReleaseFeature

FieldTypeJSONNotes
idIntid
nameStringname
slugStringslug
bodyString?bodyThe markdown the owner wrote
bodyHTMLString?body_htmlThe same body rendered, for an app showing a web view
publishedAtDate?published_at
isFeaturedBoolis_featuredFeatured releases come first
viewCountInt?view_countNull when the project hides views
features[ReleaseFeature]featuresWhat shipped in it

ReleaseFeature is id, heading, voteCount and changelogType. Only completed features appear in a release.

Message

FieldTypeJSONNotes
idIntid
titleStringtitle
bodyStringbodyPlain text. Markup is never sent to a native client
kindMessageKindkindinfo, warning or critical
presentationMessagePresentationpresentationbanner or alert, a hint rather than a command
actionLabelString?action_label
actionURLString?action_url
isDismissibleBoolis_dismissibleFalse means draw no way to put it away
priorityIntpriorityHigher first. The list arrives in this order
startsAtDate?starts_at
endsAtDate?ends_atAlready gone from the list once it passes
createdAtDatecreated_at
updatedAtDateupdated_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

FieldTypeJSONNotes
voterIDStringvoter_idanon_... or sso_...
kindIdentityKindkind.anonymous or .sso
anonTokenString?anon_tokenThe bearer value. Stored for you. Null on an SSO identity
displayNameStringdisplay_name
bannedBoolbanned
votedFeatureIDs[Int]voted_feature_idsEvery feature on this project this voter has voted for

Write results

What a write answers
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

Signature
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:

FieldQuery itemNotes
sortsortAlways sent
scopeboard or scope=projectNothing is sent for .roadmap, which is the default
columncolumnNot sent with .everyBoard, which the server refuses
tagIDstag, repeated
statusesstatus, repeated
searchqNot sent when empty
pagepageHeld to 1 or more
perPageper_pageHeld between 1 and 100

The sorts and the scopes are listed in Calls.

Page and PageMeta

Every list answers one of these
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

The network seam
public protocol Transport: Sendable {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

The seam a test replaces. URLSessionTransport is the one that ships.

URLSessionTransport
public init(memoryCapacity: Int = 4 * 1024 * 1024, diskCapacity: Int = 20 * 1024 * 1024)
public init(session: URLSession)

See Testing for the stub.

TokenStore

The storage seam
public protocol TokenStore: Sendable {
    func token(forProject project: String) -> String?
    func setToken(_ token: String?, forProject project: String)
}
StoreWhere 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

One entry in the queue
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

Where the package keeps what it owns
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}/:

FileWhat is in it
voter.tokenThe anonymous token, only when the keychain refused
outbox.jsonWrites made with no connection
view.jsonWhich stages this reader turned on, and their sort
hidden.jsonWhat 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.

Next