Skip to content
VoteFirst Docs
Dashboard

Swift

Calls

One call for every route the API has, for an app that would rather draw its own board. Same key, same voter, same offline queue as the built in screens.

The shape of it

There is one call per route and nothing else. Each one names what it acts on the way the request does, and hands back what that request answered.

CallRequest, under /api/v3/projects/{project}Answers
me()POST /identityIdentity
board(refresh:)GET /Board
features(_:)GET /featuresPage<Feature>
feature(id:)GET /features/{id}Feature
comments(featureID:page:perPage:)GET /features/{id}/commentsPage<Comment>
changelog(page:perPage:)GET /changelogPage<Release>
release(id:)GET /changelog/{id}Release
messages(page:perPage:)GET /messagesPage<Message>
dismiss(messageID:)POST /messages/{id}/dismissMessageDismissed
upvote(featureID:)POST /features/{id}/voteVoteResult
unvote(featureID:)DELETE /features/{id}/voteVoteResult
suggest(_:details:)POST /featuresFeature
comment(_:featureID:replyingTo:)POST /features/{id}/commentsComment
like(commentID:)POST /comments/{id}/likeLikeResult
delete(commentID:)DELETE /comments/{id}CommentDeleted

Two more that are not routes: waiting, how many writes are queued, and send(), which puts them out.

The project is never an argument, because the key names it.

The voter is never an argument either, and cannot be. An argument naming the voter would let anyone vote as anyone. One is minted on first use, kept on the device, and sent in a header on every request after it. me() reports who that is.

Where they run

Every call is @MainActor, and every one that reaches the network is async throws. waiting and send() are async and cannot throw, because a queue that is already on this device has nothing left to fail at.

Every write is @discardableResult, so a call whose answer you do not need is one line with no warning on it. Every model is Sendable, so a Feature crosses actors without one either.

In your app
Task { @MainActor in
    let page = try await VoteFirst.features()
    self.features = page.items
}

Cancelling the task cancels the request under it. A cancelled call throws CancellationError rather than a VoteFirstError, so a view that disappears mid load does not draw an error about it.

What one press sends

Voting for feature 21519 is one line, and this is all of what goes on the wire.

In your app
let vote = try await VoteFirst.upvote(featureID: 21519)

The first write of the app's life mints the voter.

Request
POST /api/v3/projects/demo-product/identity

X-API-Key:  vf_pk_31e91b2c...
Response
{
  "data": {
    "voter_id": "anon_41bdabf9...",
    "kind": "anonymous",
    "anon_token": "c08571fdafe994...",
    "display_name": "VelvetOwl_3069",
    "banned": false,
    "voted_feature_ids": []
  }
}

The token from that answer is kept in the keychain, and every request after it carries it.

Request
POST /api/v3/projects/demo-product/features/21519/vote

X-API-Key:        vf_pk_31e91b2c...    which board
X-VF-Anon:        c08571fdafe994...    who is voting
Idempotency-Key:  4F2A9C7B-1D3E...     so a retry cannot vote twice
Response
{
  "data": {
    "feature_id": 21519,
    "has_voted": true,
    "vote_count": 61,
    "score": 61
  }
}

Every response has the same three keys, data, meta and error, and the package unwraps them for you. meta carries the paging numbers on a list and is null everywhere else.

Reading the board

In your app
let board = try await VoteFirst.board()

One document describing the project rather than the voter: its name and slug, its boards and their columns, its tags, its display settings, its theme, and what it allows. It is what you need before drawing anything, because it says which columns exist, what they are called, and whether a vote on one would be accepted.

It is held after the first call, because a custom interface asks for it on every screen and it changes only when the owner changes it.

Ask again anyway
let fresh = try await VoteFirst.board(refresh: true)

Every field is in the reference.

Reading features

In your app
let page = try await VoteFirst.features()

The default is the roadmap, most wanted first, twenty at a time.

Everything you can ask
public struct FeatureQuery: Sendable, Equatable {
    public init(sort: Sort = .top,
                scope: Scope = .roadmap,
                column: Int? = nil,
                tagIDs: [Int] = [],
                statuses: [FeatureStatus] = [],
                search: String? = nil,
                page: Int = 1,
                perPage: Int = 20)
}
SortOrder
.topMost voted first. The default
.votesThe plain headcount, whatever the project weights by
.trendingWhat has been voted for lately
.newNewest first
.oldestOldest first
.commentsMost discussed first
.boardThe order the owner arranged, which is what the roadmap draws
ScopeWhat is in view
.roadmapThe roadmap, which is the board every project has. The default
.board("q3")One custom board, by slug
.everyBoardEvery public feature, wherever it is filed. The question a most wanted list asks

.everyBoard cannot be narrowed by column, and the package does not send a column with it. The server refuses the two together, and a shape that cannot express the refusal is better than one that sends it and reads the complaint back.

Asking a narrower question

In your app
let query = FeatureQuery(
    sort: .trending,
    scope: .board("q3"),
    column: 4,
    tagIDs: [7, 9],
    statuses: [.open, .planned],
    search: "dark mode",
    perPage: 50
)
let page = try await VoteFirst.features(query)
FieldWhat it does
columnOne column of whichever board is in view. -1 is the roadmap's archive
tagIDsFeatures carrying any of these tags. The ids are in board.tags
statuses.open, .planned, .inProgress, .done, .denied, .archived
searchWords in the heading or the description. An empty string is not sent
pageFrom 1. Anything lower is sent as 1
perPageFrom 1 to 100. Anything higher is sent as 100

A status the board hides answers with nothing rather than with an error. Denied and archived features are only there when the project publishes them.

Paging

Every list answers a Page, which is the items and the numbers around them.

In your app
let page = try await VoteFirst.features()
page.items        // [Feature]
page.meta.total   // how many there are in all
page.hasMore      // whether there is another page
page.nextPage     // the number to ask for, or nil
All of them
let query = FeatureQuery(sort: .top, statuses: [.open])
var page = try await VoteFirst.features(query)
var everything = page.items
while let next = page.nextPage {
    page = try await VoteFirst.features(query.page(next))
    everything += page.items
}

query.page(_:) returns a copy with the page changed and the rest of the query intact, so there is no way to turn a page and lose a filter.

Reading one feature

In your app
let feature = try await VoteFirst.feature(id: 21519)

The same object a list carries. Ask for it when you have an id from somewhere else, such as a deep link, or when you want the current counts for one thing without fetching a page.

This is the one read that changes something: it counts a view, at most once an hour for the same voter and the same feature. Counting a view does not move the feature's updatedAt.

A feature that is hidden, unapproved, or on another project answers notFound, so guessing ids reaches nothing a list would not have shown.

Reading comments

In your app
let thread = try await VoteFirst.comments(featureID: 21519)
let more = try await VoteFirst.comments(featureID: 21519, page: 2, perPage: 50)

Newest first. Replies are not separate items: each comment carries its own replies, in the order they were written, and replies are one level deep. meta.total counts the top level comments, not the replies under them.

comment.isOwn is what tells you which ones this voter may delete.

Reading the changelog

In your app
let shipped = try await VoteFirst.changelog()
let release = try await VoteFirst.release(id: 4)

Published releases, most recent first, featured ones before the rest. Each carries the features it shipped, its body as the markdown the owner wrote, and the same body as HTML for an app that would rather show a web view than lay markdown out.

release(id:) counts a view the same way feature(id:) does.

Reading your own messages

In your app
let mine = try await VoteFirst.messages()
try await VoteFirst.dismiss(messageID: 9)

What you wrote in the dashboard after this build shipped, highest priority first. Only the messages this reader should see come back: the platform and the build are read from the bundle and sent for you, and what this voter pays comes from their identity, which is minted first.

A message carries a title, a plain text body, a tone, whether it means to interrupt, whether it may be put away, and at most one action. message.action answers the label and the URL together, and only when both arrived and the address is one worth opening.

dismiss(messageID:) is recorded for the voter rather than for the device, so it holds after a reinstall and on their other devices. It is refused for a message you marked as one people must keep seeing. Made with no connection, it waits in the same queue every other write does.

Most apps need neither call: .voteFirstMessages() draws them. These are for an app that would rather draw its own.

Voting

In your app
let vote = try await VoteFirst.upvote(featureID: 21519)
let back = try await VoteFirst.unvote(featureID: 21519)
What comes back
vote.featureID    // 21519, the feature this answer is about
vote.hasVoted     // true
vote.voteCount    // 61, or nil
vote.score        // 61, or the weighted score on a project that weights votes

Two calls rather than a toggle, so which one ran is never a guess.

Voting twice is not an error, and neither is taking back a vote that is not there. Either one comes back as the state you asked for, with voteCount nil, which means the count you already have still stands. The server answers 409 for both and the package settles it, because nothing is wrong and nothing changed.

voteCount is also nil on a project that hides vote counts. Both cases mean the same thing to a caller: draw the count you already had.

A feature that does not exist is a 404 and does throw. Same code, different status, and the package tells them apart so a real mistake does not look like a settled one.

A closed column throws too. Ask the board first, below.

Suggesting

In your app
let filed = try await VoteFirst.suggest("Dark mode", details: "The white burns at night.")
HeadingRequired. 254 characters at most
DetailsRequired. 2000 characters at most

Both are required by the server. details has a default of "" so the call reads as a sentence, and sending it empty answers validationFailed on the description field, so check before you send rather than after.

A suggestion is held for the owner to approve before anyone sees it, including the person who wrote it. filed.isApproved is false until they do, and until then it is not on the board and takes no comments. Reading it back by its own id answers notFound, even for the voter who just filed it, so keep the value you were handed rather than fetching it again.

Say so in your interface. Somebody who files a suggestion, cannot find it, and gets a not found when they look assumes it was lost. The built in form ends in a confirmation for exactly this reason.

Commenting

In your app
let said = try await VoteFirst.comment("Yes please", featureID: 21519)
let reply = try await VoteFirst.comment("Agreed", featureID: 21519, replyingTo: said.id)

2000 characters at most. Replies are one level deep: replying to a reply answers validationFailed on parent_id, and the message says to reply to the comment that one replies to instead.

A voter the project has stopped writing gets voterBanned here. They can still read and vote.

Liking

In your app
let like = try await VoteFirst.like(commentID: said.id)
like.hasLiked    // which of the two just happened
like.likeCount   // where the count stands now

One route does both. It likes a comment this voter has not liked, and takes the like back if they have.

Deleting a comment

In your app
let gone = try await VoteFirst.delete(commentID: said.id)
gone.deleted        // true
gone.commentCount   // where the feature's comment count stands now

Only a comment this voter wrote, which Comment.isOwn reports. Somebody else's answers notAllowed.

Deleting a comment that has replies deletes the replies with it, which is why the count can drop by more than one.

Who is voting

In your app
let voter = try await VoteFirst.me()
voter.voterID            // what every vote and comment of theirs is recorded against
voter.displayName        // what their comments are signed with
voter.votedFeatureIDs    // what they have already voted for
voter.banned             // the project has stopped them writing; they still read
voter.kind               // .anonymous or .sso

Nothing has to be called first. The voter is minted on the first write that needs one and the same one is used from then on. me() is how you learn who that is before drawing a board, so the vote buttons are in the right state on the first render rather than after the first press.

Voters is the whole of it, including signing your own users in.

Writes waiting to send

In your app
let waiting = await VoteFirst.waiting   // how many writes are queued
let sent = await VoteFirst.send()       // put them out, and how many left the queue

A write made with no connection throws and is kept. The built in screens send the queue when one appears; an app drawing its own board calls send() when the connection comes back. Offline and retries has the rest.

Ask before you draw the button

An owner can close one column to votes while the rest of the board stays open, and can turn comments or suggestions off entirely. A button drawn without asking is refused when it is pressed, so ask the board first.

In your app
if board.allowsVoting(on: feature) { /* draw the vote button */ }
if board.allowsComments { /* draw the comment field */ }
if board.allowsSuggestions { /* draw the suggest button */ }

allowsVoting(on:) answers for one feature, because that is the question. It reads the project's setting, this board's setting and this column's own, which are three different places a vote can be turned off.

Folding a result back in

If you hold Feature and Comment values in your own state, applying puts a result into one.

In your app
feature = feature.applying(try await VoteFirst.upvote(featureID: feature.id))
comment = comment.applying(try await VoteFirst.like(commentID: comment.id))

It is the one convenience here, and it is deliberate: without it every caller keeps a second table of vote state beside the features and has to remember to keep the two in step. A result for another id is ignored rather than applied to the wrong row, and a result with no count leaves the count alone.

Read the result yourself if you keep your own model. There is nothing in applying that you cannot do with three assignments.

A whole board

MyBoard.swift
struct MyBoard: View {
    @State private var features: [Feature] = []

    var body: some View {
        List($features) { $feature in
            HStack {
                Text(feature.heading)
                Spacer()
                Button {
                    Task {
                        let vote = feature.hasVoted
                            ? try await VoteFirst.unvote(featureID: feature.id)
                            : try await VoteFirst.upvote(featureID: feature.id)
                        feature = feature.applying(vote)
                    }
                } label: {
                    Label("\(feature.voteCount ?? 0)",
                          systemImage: feature.hasVoted ? "heart.fill" : "heart")
                }
            }
        }
        .task { features = (try? await VoteFirst.features().items) ?? [] }
    }
}

That is a working board. A real one asks board() first so the vote button is drawn in the right state, catches the error rather than swallowing it, and pages. All three are above.

A vote made this way reaches a built in screen that is already on a tab, so mixing the two is fine.

What each call can throw

Every call throws VoteFirstError. These are the ones worth handling per call rather than in one place; the whole list is in Errors.

CallWhat it answers when it is refused
upvote, unvotenotAllowed on a closed column, notFound on a feature that is gone
suggestvalidationFailed on heading or description, notAllowed when the project takes no suggestions, voterBanned
commentvalidationFailed on content or parent_id, notAllowed when comments are off, voterBanned
deletenotAllowed on somebody else's comment
any of themnetwork with no connection, rateLimited over budget, invalidKey on a rotated key

Next