The Buffer Retreat App Version 2: Migrating Tech Stacks, New Features and More!

Apr 6, 2017 17 min readWorkplace of the future

As a globally distributed team at Buffer, our company retreats are a key part of our identity. We’re meeting coworkers for the first time in person, putting our heads together to shape the future of Buffer and making sure we put aside time to laugh and hang out with one another.

There’s a lot going on with these retreats, and they’ve only become bigger in both scale and attendance with each one. As we recognized last year, we needed a solution to help us coordinate. That’s when we created the Buffer Retreat app!

For version 2, we took what we learned from the last retreat to try and improve it. Here’s what happened.

The Great Migration

If you followed along last year, you may remember we built the whole app using Parse. During the retreat, I remember getting pinged on Twitter that Parse was going to be sunset. While it was a surprise to many in the community, it meant one thing to me:

Immediately for version 2, our priority shifted from improving the experience to just making the app work again!

As Parse’s servers would be offline by the time our retreat was scheduled, we opted to shift over to Firebase. Parse has done a great job of open sourcing their platform, but after weighing the pros and cons of running it ourselves we landed on using another BaaS option.

So, before anything else, I had to mirgate our code base over to Firebases’s API, while removing any Parse code.

Database Shift

While both services are developer friendly, they sit on opposite ends of the spectrum in terms of implementation.

Specifically, Parse’s API and database management works closer to a traditional relational database. Firebase, on the other hand, uses a JSON document to store data geared towards realtime data. This meant we had to redesign our data structure. Since we had the benefit of not needing to import data from the previous retreat, we started from scratch.

For the rewrite, I was charged with pioneering the changes so Android could quickly implement them in their migration process since they would begin shortly after iOS. I had no previous experience with a NOSQL database and wasn’t sure how data should be structured. Luckily I was able to move fast to either enforce or disprove assumptions I had made.

And the first mistake I made was felt quickly, and it had to do with how I structured leaving comments for an event:

Comments

With Parse, an Event model could find out about its comments with a relational query, strongly typed on the model as a PRRelation:

PFQuery *query = [self.event.comments query];
[query includeKey:@"created_by"];
[query orderByAscending:@"createdAt"];
[query findObjectsInBackgroundWithBlock:^(NSArray *objects, NSError *error){
    // Handle comments
}];

When I restructured the data over to a JSON document, I originally had an Event contain an array of comment identifiers that belonged to it:

@property (strong, nonatomic, nullable) NSArray <NSString *> *commentIds;

This proved to be a mistake, as I realized that each time an event wanted to get its comments, it required pulling down the entire Comments node to match against the correct identifiers.

After reading more on the importance of denormalization of data, it began to click. At first, it felt like I was doing something wrong when I duplicated data, but I learned that it’s just part of the process when using JSON Document databases. The read for an Event became simple as well, as it went from two or three reads on the database to just one. It also eliminated nested callbacks that were forming prior, which was the first clue I had taken a wrong turn.

The end result was a property that ended up looking like this instead:

@property (strong, nonatomic) NSMutableArray <Comment *> *comments;

Fixing this served as a good foundation to refactor other models. It also facilitated important conversations on the retreat, such as this ?:

importantConvo

Fortunately, once I figured out how to model data on the backend, things started moving quickly.

Model Handling

By default, Firebase’s API can be quite stringy if you let it. That’s not a discredit to their design, it’s just a byproduct of writing data as JSON. I wanted to keep things strongly typed with our models for several reasons, so I needed to come up with a quick design to facilitate that. With Parse, our models were very straight forward:

#import <Parse/Parse.h>

@interface User : PFUser

@property (strong, nonatomic) PFFile *FBAvatar;
@property (assign) BOOL isBuffer;
@property (strong, nonatomic) NSString *displayName;
@property (strong, nonatomic) NSString *bio;
@property (strong, nonatomic) NSString *bufferRole;
@property (nonatomic) BOOL userLocationEnabled;
@property (strong, nonatomic) PFGeoPoint *userLocation;

@end

Since I wanted to move fast, I thought the best way to migrate model code would be to keep the models as they are, and just pump Firebase’s API into them. For the most part, this worked – but it required more work in the implementation of the models.

Pre Firebase, the User model’s implementation was simple:

#import "User.h"

@implementation User

@dynamic FBAvatar;
@dynamic isBuffer;
@dynamic displayName;
@dynamic bio;
@dynamic bufferRole;
@dynamic userLocationEnabled;
@dynamic userLocation;

#pragma mark - PFSubclassing
+ (void)load {
    [User registerSubclass];
}

@end

Parse’s API made things lightweight and easy to use with models. To facilitate the same feel with C.R.U.D. operations with Firebase, I created a base class to handle things:

#import <Foundation/Foundation.h>
#import <FirebaseDatabase/FirebaseDatabase.h>

@interface RetreatBaseModel : NSObject

@property (strong, nonatomic) NSString *objectId;
@property (strong, nonatomic, readonly) NSString *dbKey;
@property (strong, nonatomic) FIRDatabaseReference *modelRef;

- (BOOL)valueIsSafe:(id)obj;

// Overrides
- (instancetype)initWithDictionary:(NSDictionary *)dictionary;
- (void)saveWithBlock:(void (^)(NSError *error, FIRDatabaseReference *ref))block;
- (void)removeWithBlock:(void (^)(NSError  *error, FIRDatabaseReference *ref))block;

@end

The implementation would get a reference to the database that each model would need, and then the dirty work was mostly finished for most models:

#import "RetreatBaseModel.h"
#import <FirebaseDatabase/FirebaseDatabase.h>

@interface RetreatBaseModel()

@property (strong, nonatomic) FIRDatabaseReference *feedItemRef;

@end

@implementation RetreatBaseModel

#pragma mark - Properties
- (NSString *)dbKey {
    return NSStringFromClass([self class]).lowercaseString;
}

#pragma mark - Initializers
- (instancetype)initWithDictionary:(NSDictionary *)dictionary {
    self = [self init];
    return self;
}

- (instancetype)init {
    self = [super init];
    
    if (self) {
       self.modelRef = [[[FIRDatabase database] reference] child:[self.dbKey stringByAppendingString:@"s"]];
    }
    
    return self;
}

- (BOOL)valueIsSafe:(id)obj {
    return obj != nil && [obj isKindOfClass:[NSNull class]] == NO;
}

#pragma mark - Overrides
- (void)saveWithBlock:(void (^)(NSError *error, FIRDatabaseReference *ref))block { }

// This one is the same for any model
- (void)removeWithBlock:(void (^)(NSError *error, FIRDatabaseReference *ref))block {
    [[self.modelRef child:self.objectId] removeValueWithCompletionBlock:^(NSError *error, FIRDatabaseReference *ref) {
        if (block) {
            block(error, ref);
        }
    }];
}

@end

While it’s more code than Parse, using it was simple and easy to understand. Since speed was important, a common theme I’ve mentioned, I did some quick glances to see if there was established patterns when it came to model objects with Firebase. I ended up going this route, since in theory it made the migration process easier and in practice the code was flexible to adapt to other models.

Here is how our Venue model end up looking as a subclass of the Retreatbase class:

#import "Venue.h"
#import "CLLocation+Utils.h"

@implementation Venue

+ (NSString *)key {
    return @"venues";
}

#pragma mark - Initializers
- (instancetype)init {
    self = [super init];
    
    if (self) {
        self.objectId = @"";
        self.latitude = @0;
        self.longitude = @0;
        self.name = @"";
        self.address = @"";
    }
    
    return self;
}

- (instancetype)initWithDictionary:(NSDictionary *)dictionary {
    self = [super initWithDictionary:dictionary];
    
    if (self) {
        self.objectId = [self valueIsSafe:dictionary[@"objectId"]] ? dictionary[@"objectId"] : @"";
        self.latitude = [self valueIsSafe:dictionary[@"latitude"]] ? dictionary[@"latitude"] : @0;
        self.longitude = [self valueIsSafe:dictionary[@"longitude"]] ? dictionary[@"longitude"] : @0;
        self.name = [self valueIsSafe:dictionary[@"name"]] ? dictionary[@"name"] : @"";
        self.address = [self valueIsSafe:dictionary[@"address"]] ? dictionary[@"address"] : @"";
        
        if (self.longitude.integerValue != NAN && self.longitude.integerValue != NAN) {
            self.location = [[CLLocation alloc] initWithLatitude:self.latitude.doubleValue longitude:self.longitude.doubleValue];
        }
    }
    
    return self;
}

#pragma mark - Overrides
- (void)saveWithBlock:(void (^)(NSError *, FIRDatabaseReference *))block {
    [super saveWithBlock:nil];
    
    NSDictionary *modelValues = @{@"objectId":self.objectId,
                                  @"latitude":self.latitude,
                                  @"longitude":self.longitude,
                                  @"name":self.name,
                                  @"address":self.address};
    
    // New, or existing?
    if ([self.objectId isEqualToString:@""]) {
        self.modelRef = [self.modelRef childByAutoId];
        self.objectId = self.modelRef.key;
        
        NSMutableDictionary *modelValuesWithObjectId = [[NSMutableDictionary alloc] initWithDictionary:modelValues];
        [modelValuesWithObjectId setObject:self.objectId forKey:@"objectId"];

        [self.modelRef setValue:modelValuesWithObjectId withCompletionBlock:^(NSError *error, FIRDatabaseReference *ref) {
            if (block) {
                block(error, ref);
            }
        }];
    } else {
        // Existing
        [[self.modelRef child:self.objectId] setValue:modelValues withCompletionBlock:^(NSError *error, FIRDatabaseReference *ref) {
            if (block) {
                block(error, ref);
            }
        }];
    }
}

- (void)removeWithBlock:(void (^)(NSError *, FIRDatabaseReference *))block {
    [super removeWithBlock:block];
}

@end

Going this route was nice because I didn’t have to change too many call sites for C.R.U.D. operations with the models. It was akin to having a data layer pattern, where only the plumbing changed and the app was none the wiser. However, this also meant I had a codebase geared towards a relational database paradigm that was now powered by a realtime database API. But, it worked – so I moved on!

Other key takeaways were that Firebase’s JSON database only accepted simple types such as a string, number, array or dictionary. This meant I had to retool things like locations and timestamps that were previously stored differently. In the end, getting the models working let me transition naturally into the rest of the codebase to finish the migration.

Speed Tradeoffs

So far, the code wasn’t exactly beautiful and their were more best practices I surely could’ve followed. But, by and large the migration worked and was stable – which was priority number one. Aside from skipping on some other niceties like a JSON parsing framework, I also had to accept some early technical debt and work around it.

The major one was that the whole app was architected with the assumption that each User model was fully hydrated. There was simply too much to change to refactor this, but thanks to Firebase’s realtime features, it ended up only “costing” me a larger than ideal read on launch. In other words, in the app delegate I had to grab each user in the database. This was acceptable to me, as we only would have around ~100 users.

To optimize the database hit, I moved this code to occur on launch in the background instead of when the user had actually logged in. The end result was that most people would never “feel” this in the U.X. anyways. Further, thanks to Firebase’s realtime callbacks, I only had to do the read once during the lifecycle of the app and I would get any edits to a user instance like this:

// Going forward, let's see what changes occur as well
[[self.dbRef child:[User key]] observeEventType:FIRDataEventTypeChildChanged withBlock:^ (FIRDataSnapshot *snap) {
    if ([snap.value isKindOfClass:[NSDictionary class]]) {
        User *u = [[User alloc] initWithDictionary:snap.value];
        [self.cachedUsers setObject:u forKey:u.displayName];
     }
}];

For the next version, that’s one architectural change I’d love to fix. The migration was done though, which meant I had time to implement the features we had originally wanted to before we knew Parse was sunset!

Shifting to Version 2 Improvements

We sent out a survey after the Hawaii retreat asking fellow coworkers what they would’ve liked to have seen in the app. Internally, we also noticed that there was high usage in the beginning on the retreat, but it tailed off in lieu of Facebook Messenger towards the end. This lead to a fragmented experience, with information about retreat events scattered between two different sources.

To that end, we had the following goals in the mind:

  • Design tweaks
  • Adding a chat room
  • Adding “@” style mentions
  • Points of interest for the map
  • Client side push notifications
  • Lightweight news feed

Luckily, we were able to deliver on these. Here’s a short look at each one:

Design Changes

There were some design changes I had really wanted to get in. In version 1, an event’s detail view had comments for it tacked on to the bottom of it. This lead to awkward jump in the interface when the comments loaded and the containing scroll view’s content size changed. For version two, I was able to split this out and have a tab for the event’s details and another for comments as you can see towards the top:

eventSplit

On the topic of comments, I also made some subtle tweaks to the table view cells themselves. Here is version 1 on the left, with version 2 on the right:

Some other small things included changing the font size of text in various places, editing the copy of alerts and similar things. Luckily, Andy was able to lend a hand here since he has a great eye for things like this.

Chat Room

To avoid splitting the experience between Facebook Messenger or Slack, we thought a central chat room might help. With Firebase, it was incredibly easy to implement. Both Android and iOS had it up and running quickly, and it helped us keep all communication within the app:

chatroom

Keen readers may notice that my strings showing how old the comment was has a bug, looks like I’ll need to fix that for version 3 ?!

In the end, a chat room was definitely a great addition, but being able to mention someone felt key, too.

Mentions

This one ended up being more work than I had anticipated. If you’re an iOS developer, you may know that working with strings can be a bit cumbersome. Mentions had a lot of logic around string manipulation and keeping track of ranges.

Little nuances like if a user “mentions” someone and then moves the cursor to the beginning of the comment and deletes characters, the mention’s range needed to be updated for text styling. Or, if a user deletes the last character in a mention, then it should delete the whole name. Things like this led me down some tricky scenarios, but it was a fun challenge to solve.

It ended up being a high impact benefit though, and it worked as you might expect (i.e. typing “@” brought up a view to select someone to mention):

mention


We were also able to fire off notifications for any mention, which led to even more engagement in the app:

mentionNotifcation

Points of Interest

One request the People team had was to be able to add points of interest on the map. Previously, only venues and users had shown on the map. While it was certainly fun to see everyone moving towards Madrid for the retreat (like in the image below) – we needed to toggle over to just important places as well as creating an interface to add them:

locations

Luckily, Andy was able to tackle the whole implementation very quickly. Using the base class I had put in place, he ended up adding creating a new model and a simple option to add points of interest in the “More” portion of the app:

pointOfInterest

This was helpful to quickly find important places during the retreat, like where our coworking place was (Google Campus Madrid)

showPointsOfInterest

Client Side Push Notifications

This was probably the most requested feature from the People team, who more or less acted like Product Owners this time around. They would be using the app before the retreat to add events, and would rely on it to send important messages to the team with push notifications. On the first version, Andy or I would just pop into the Parse backend and manually send global push notifications.

I ended up creating a “admin” dashboard that easily allowed this:

authorPush

These would then fire off to all of us on the retreat:

clientPush


Again, Firebase made this trivial. I ended up creating a small class that took care of all of it:

#import <Foundation/Foundation.h>

@interface BFRClientPush : NSObject

+ (void)sendPushWithContent:(NSString *)messageContent completion:(void (^) (NSError *))completion;
+ (void)sendPushWithContent:(NSString *)messageContent toUsers:(NSArray <User *> *)users completion:(void (^) (NSError *))completion;

@end

Then, in the controller, it was as simple as taking the text and filling in some details before sending it off:

#pragma mark - Push Logic
- (void)sendPush {
    [MBProgressHUD showHUDAddedTo:self.view animated:YES];
    
    __weak AdminDashViewController *weakSelf = self;
    [BFRClientPush sendPushWithContent:self.pushContentTextView.text completion:^ (NSError *error) {
        [MBProgressHUD hideHUDForView:weakSelf.view animated:YES];
        
        if (error) {
            [weakSelf showAlertControllerWithTitle:@"Yikes ?" message:[NSString stringWithFormat:@"Show Andy or Jordan this error:%@", error.localizedDescription]];
            return;
        }
        
        FeedItem *feedItem = [FeedItem new];
        [feedItem addFeedItemWithContent:self.pushContentTextView.text detail:[NSString stringWithFormat:@"%@ posted an announcement!", currentUser.displayName] createdBy:currentUser.displayName];
        
        UIAlertAction *doneAction = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^ (UIAlertAction *action) {
            [weakSelf.navigationController popViewControllerAnimated:YES];
        }];
        [weakSelf showAlertControllerWithTitle:@"Woohoo!" message:@"Push notification sent!" actions:@[doneAction]];
    }];
}

Lightweight News Feed

This last one came from people getting push notifications, and then accidentally dismissing them and losing any context they provided. To help with this, we made a small feed section that would show not only previous push notifications, but also any events that were made:

newsFeed

Before I coded it, I wasn’t sure if this would end up being as helpful as I’d hoped, but the feedback was great and I’m glad we were able to get it in.

The implementation details required nothing more than hooking into the save methods any model had that we wanted to create a FeedItem for. So, for events, when a new one was made we just created a FeedItem in the completion handler that was populated by the event itself.

The Result: Time for the Retreat!

Once the retreat hit, everyone at Buffer and anyone else that was attending had access to the app! Fortunately, most of the growing pains were already addressed from the previous version of the app – but since both Android and iOS basically had a new codebase from the migration I was still on high alert.

Early Bugs

The good news was that all core functionality was working. People could sign up, see events and generally do all they needed. The first gotcha’ that we encountered was that Marcus and I were saving timestamps differently. That meant if someone left a comment on Android, it would show on iOS with a crazy time (i.e. “Sent 4345 days ago“). We both ensured we used UNIX timestamps, and we were good to go!

On iOS, some user’s events were showing out of order. This one was tricky, as I couldn’t recreate it on my own device. I first thought it had to do with some using military time – but it ended up being centered around the device locale! NSDate instances were behaving differently since locales formatted dates differently, and I had only accounted for U.S. based dates.

Luckily, these were the only speed bumps we hit aside from some easily addressed crashes. All told, I think we deployed two releases during the retreat.

It Would be Cool If…

Of course, it was thrilling to not only be on the retreat but also see so many of my friends using the retreat app we worked so hard on! Naturally, I tried to ask around and get a feel for how it was working for everyone. I ended up finding some useful potential features and additions through these conversations, and I was thankful to be able to have them.

Our ace designer Dave wanted to be able to add events from the app to his Google Calendar to get notifications when they were close, and to stay on top of things easier. I was able to get this working towards the end of the retreat, so I’m hoping people find it useful next time around.

We’ll surely learn more when our survey results come in from the retreat, so we’ll continue to refine the experience based on that input.

The Future of the Buffer Retreat App

Now that we’ve got it migrated to Firebase and it’s in stable condition, we hope to improve the app even more for the next retreat. We should also be able to move a bit quicker now that the migration is behind us. Some things I personally want to do next time are:

  • React to users opening a notification
  • Speed up the “snappiness” of the interface
  • Refactor the cached users dependency
  • …and lots more!

It’s been a blast seeing the app through to completion, and it was great being able to provide a helpful outlet for all the Bufferoos on the retreat!

Wrapping Up

We had such a great time hacking on the app! I also want to give a huge shoutoutmato all who helped make it happen and the incredible work from Marcus, Joe and Andy. Stephanie, Nicole and Courtney were all amazing in helping us shape the direction of the app! It was a huge team effort, and I’m happy to report that the app ended being quite handy on our retreat.

It took a API and database migration, new features and several tweaks – but I’m thankful for all we picked up along the way.

I’d love to hear from you – do you have any fun stories of creating internal software? Anything we could’ve done better with our Firebase code? Feel free to sound off in the comments below, and thanks for reading! Until next year, and version 3 ?!

Brought to you by

Try Buffer for free

140,000+ small businesses like yours use Buffer to build their brand on social media every month

Get started now

Related Articles

Why and How We Close Buffer For The Last Week Of The Year

Every year since 2016 we've closed Buffer for a week at the end of the year. It’s like a reset, except across the whole company.

ai in content
OpenMar 14, 2024
How Buffer’s Content Team Uses AI

In this article, the Buffer Content team shares exactly how and where we use AI in our work.

OpenNov 9, 2023
Buffer is Remote but not Async-First, Here's Why

With so many years of being remote, we’ve experimented with communication a lot. One conversation that often comes up for remote companies is asynchronous (async) communication. Async just means that a discussion happens when it is convenient for participants. For example, if I record a Loom video for a teammate in another time zone, they can watch it when they’re online — this is async communication at its best. Some remote companies are async first. A few are even fully async with no live ca

140,000+ people like you use Buffer to build their brand on social media every month