This post is the ninth installment of a tutorial series focused on how to set up a full-stack application using Flask and Stream. This week, we’re going to be creating everything we need to make immersive social experiences for our app using follow relationships! Follow relationships includes the following of users, collections, as well as building a customized homepage timeline and notifications screen for each user. Be sure to check out the Github repo to follow along!
Getting Started
Our first step is to create our feeds. We are going to be using the aggregate feed type for our homepage, and a notifications feed (go figure) for our notifications page. Navigating to the Stream dashboard, click the add feed group button. Next, add the feeds:
Many-To-Many
Follow relationships, whether between users and collections or users and users, can be complicated structures to model. In the past, we have connected relationships between tables purely in the User, Collection, or Content tables. For this type, however, we will need to create an entirely new table to connect them. The nice part is that both of them will be remarkably similar, as what they are trying to accomplish is the same. We are going to start by creating those tables ( in app/models.py
).
#... class Follow(db.Model): __tablename__ = 'follows' follower_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True) followed_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True) timestamp = db.Column(db.DateTime, default=datetime.utcnow) class CollectionFollow(db.Model): __tablename__ = 'collection_follows' follower_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True) collection_id = db.Column(db.Integer, db.ForeignKey('collections.id'), primary_key=True) timestamp = db.Column(db.DateTime, default=datetime.utcnow) class User(db.Model, UserMixin): #... followed = db.relationship('Follow', foreign_keys=[Follow.follower_id], backref=db.backref('follower', lazy='joined'), lazy='dynamic', cascade='all, delete-orphan') followers = db.relationship('Follow', foreign_keys=[Follow.followed_id], backref=db.backref('followed', lazy='joined'), lazy='dynamic', cascade='all, delete-orphan') followed_collection = db.relationship('CollectionFollow', foreign_keys=[CollectionFollow.follower_id], backref=db.backref('c_follower', lazy='joined'), lazy='dynamic', cascade='all, delete-orphan') #... class Collection(db.Model): #... user_followers = db.relationship('CollectionFollow', foreign_keys=[CollectionFollow.collection_id], backref=db.backref('following', lazy='joined'), lazy='dynamic', cascade='all, delete-orphan') #...
There is a lot of information there, so I will break it down. The Follow and CollectionFollow tables include the ID number for the follower, as well as the ID for the followed user, or collection (respectively). It also includes a timestamp so we can see when that association was formed. The additions to the User and Collection tables for following a collection should seem pretty familiar; we are just proxying that relationship to the new table. For the user-to-user follow, we are using a self-referential relationship. This means that the table is referencing itself for both the follower and the followee.
Methods to the Madness
Now that our models are created, we need ways to interact with it using methods. We will want to add a follow relationship, remove it, as well as check if one already exists, so we don't accidentally create multiple. We'll also make sure that these actions are updated to our Stream feeds as they happen. Once again, the actions will be similar between the two, so we will create them all together (in app/models.py
).
#... class User(db.Model, UserMixin): #... def is_following(self, user): if user.id is None: return False return self.followed.filter_by( followed_id=user.id).first() is not None def is_followed_by(self, user): if user.id is None: return False return self.followers.filter_by( follower_id=user.id).first() is not None def follow(self, user): client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) user_feed = client.feed("Notifications", str(self.id)) if not self.is_following(user): user_feed.follow("User", str(user.id)) f = Follow(follower=self, followed=user) db.session.add(f) return True def unfollow(self, user): client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) user_feed = client.feed("Notifications", str(self.id)) f = self.followed.filter_by(followed_id=user.id).first() if f: user_feed.unfollow("User", str(user.id)) db.session.delete(f) return True def is_following_collection(self, collection): if collection is None: return False if collection.id is None: return False return self.followed_collection.filter_by( collection_id=collection.id).first() is not None def follow_collection(self, collection): client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) user_feed = client.feed("Timeline", str(self.id)) if not self.is_following_collection(collection): user_feed.follow("Collections", str(collection.id)) f = CollectionFollow(c_follower=self, following=collection) db.session.add(f) return True def unfollow_collection(self, collection): client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) user_feed = client.feed("Timeline", str(self.id)) f = self.followed_collection.filter_by( collection_id=collection.id).first() if f: user_feed.unfollow("Collections", str(collection.id)) db.session.delete(f) return True #...
The is_following
, follow
, and unfollow
for both users and collections accomplish what we outlined above. Still, I added a new method that checks if the following relationship for users extends the other way. This will let us have a “follows you” tag in a user’s profile if the relationship exists.
Follow, Unfollow
Our next move is creating the endpoints for the follow and unfollow relationships (in app/main/views.py
).
#... from ..decorators import admin_required, permission_required #... @main.route('/follow/<username>') @login_required @permission_required(Permission.FOLLOW) def follow(username): user = User.query.filter_by(username=username).first() if user is None: flash('Invalid user.') return redirect(url_for('.index')) if current_user.is_following(user): flash('You are already following %s.' % user.username) return redirect(url_for('.user', username=username)) current_user.follow(user) db.session.commit() flash('You are now following %s.' % user.username) return redirect(url_for('.user', username=username)) @main.route('/unfollow/<username>') @login_required @permission_required(Permission.FOLLOW) def unfollow(username): user = User.query.filter_by(username=username).first() if user is None: flash('Invalid user.') return redirect(url_for('.index')) if not current_user.is_following(user): flash('You are not following %s.' % user.username) return redirect(url_for('.user', username=username)) current_user.unfollow(user) db.session.commit() flash('You are no longer following %s.' % user.username) return redirect(url_for('.user', username=username)) @main.route('/follow-collection/<int:id>') @login_required @permission_required(Permission.FOLLOW) def follow_collection(id): collection = Collection.query.filter_by(id=id).first() if collection is None: flash('Invalid collection.') return redirect(url_for('.index')) if current_user.is_following_collection(collection): flash('Your are already following %s.' % collection.name) return redirect(url_for('.collection', id=id)) current_user.follow_collection(collection) db.session.commit() flash('You are now following %s.' % collection.name) return redirect(url_for('.collection', id=id)) @main.route('/unfollow-collection/<int:id>') @login_required @permission_required(Permission.FOLLOW) def unfollow_collection(id): collection = Collection.query.filter_by(id=id).first() if collection is None: flash('Invalid collection.') return redirect(url_for('.index')) if not current_user.is_following_collection(collection): flash('You are not following this collection') current_user.unfollow_collection(collection) db.session.commit() flash('You are not following %s anymore.' % collection.name) return redirect(url_for('.collection', id=collection.id))
The functions themselves once again work in a very similar fashion to each other. They first check to see if the object to be followed exists (a sensible start), before moving into ensuring the relationship hasn’t already been made. After passing those checks, it creates the following and uploads the activity to Stream, and commits the entry. For unfollowing, it is almost the same, except that it checks to make sure the relationship exists before trying to delete it.
It’s All About Me
Most users will expect to see their posts along with their friends when scrolling through a timeline or even notification screen. For this, we are going to add some self follow functions to the User class as well as adding this function to the deploy CLI method. We’ll start by creating the functions (in app/models.py
).
#... class User(db.Model, UserMixin): #... @staticmethod def add_self_follows(): for user in User.query.all(): if not user.is_following(user): user.follow(user) db.session.add(user) db.session.commit() @staticmethod def add_self_collection_follows(): for user in User.query.all(): for collection in user.collections: if not user.is_following_collection(collection): user.follow_collection(collection) db.session.add(user) db.session.commit()
Our next step is to add it to the deploy CLI command ( in application.py
).
#... @app.cli.command() def deploy(): #... # ensure all users are following their collections User.add_self_collection_follows() # ensure all users are following themselves User.add_self_follows()
Testing 1, 2
Now that we’ve created some new models and functionality, we should take some time to develop tests for them to ensure everything is working as it should. We’ll start with the collection follows (in tests/test_collection_model.py
)
#... class UserModelTestCase(unittest.TestCase): #... def test_collection_follow(self): client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) u1 = User(id=9999999999, username='john', email='john@example.com', password='test') u2 = User(id=9999999998, username='mary', email='mary@example.org', password='test1') db.session.add(u1) db.session.add(u2) db.session.commit() token1 = u1.generate_confirmation_token() token2 = u2.generate_confirmation_token() self.assertTrue(u1.confirm(token1)) self.assertTrue(u2.confirm(token2)) c = Collection(id=9999999999, name='test', description='test description', author=u1) db.session.add(c) db.session.commit() self.assertTrue(c.add_to_stream()) self.assertFalse(u2.is_following_collection(c)) u2.follow_collection(c) db.session.commit() self.assertTrue(u2.is_following_collection(c)) self.assertTrue(u2.unfollow_collection(c)) self.assertFalse(u2.is_following_collection(c)) self.assertTrue(c.delete_from_stream()) client.users.delete(str(u1.id)) client.users.delete(str(u2.id)) db.session.delete(c) db.session.commit()
Here we create two new users with the first creating a collection. We then check to make sure the second user isn’t already following it, before creating, checking, and destroying that relationship. Cleaning up, we erase the users from Stream. Next, we will do the same thing for user follows ( in tests/test_user_model.py
)
#... class UserModelTestCase(unittest.TestCase): #... def test_follows(self): client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) u1 = User(id=9999999999, username='john', email='john@example.com', password='test') u2 = User(id=9999999998, username='mary', email='mary@example.org', password='test1') db.session.add(u1) db.session.add(u2) db.session.commit() token1 = u1.generate_confirmation_token() token2 = u2.generate_confirmation_token() self.assertTrue(u1.confirm(token1)) self.assertTrue(u2.confirm(token2)) self.assertFalse(u2.is_following(u1)) u2.follow(u1) db.session.commit() self.assertTrue(u2.is_following(u1)) self.assertTrue(u2.unfollow(u1)) client.users.delete(str(u1.id)) client.users.delete(str(u2.id)) db.session.commit()
Before we move, be sure to give a quick flask test
in the CLI to make sure that everything is working!
Next Steps
At this point, if a new user were to join, we wouldn't be able to find them, nor them us. We need to make a way to have users find each other on our app. Stream does not provide the functionality to retrieve a list of all the users in our app, but in my mind, that is a lot better than potentially having a malicious actor use a token to get all of our user info. Stream rightly assumes that we have our users stored on our system, where we can use permissioned access to that resource using login_required. Since we don't have access to Stream’s lightning-quick data retrieval, we’ll be switching the pagination method from infinite scroll to a button-based system. I find that if you can't do something correctly, don’t do it at all. If a user perceives an infinite scroll to be clunky and slow to load, that impression is quickly spread across the rest of the site. If at a certain point, you notice that it’s taking a long time to find someone, you can also quickly scale up and down the number of returned results (in config.py
) to reduce the number of pages. We will start this section by creating a view for all users (in app/main/views.py
)
from flask import render_template, flash, redirect, url_for, abort, request, current_app #... @main.route('/users') def users(): page = request.args.get('page') pagination = User.query.filter(User.confirmed == True).filter(User.id != current_user.id).paginate( page, per_page=current_app.config['OFFBRAND_RESULTS_PER_PAGE'], error_out=False) users = pagination.items return render_template('users.html', users=users, pagination=pagination) #...
Next is creating a pagination tool to render the pages at the bottom of the screen. This will be kept in a new file that will be imported to the pages that use it (in app\templates_macros.html
).
{% macro pagination_widget(pagination, endpoint, fragment='') %} <ul class="pagination"> <li{% if not pagination.has_prev %} class="disabled"{% endif %}> <a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{{ fragment }}{% else %}#{% endif %}"> « </a> </li> {% for p in pagination.iter_pages() %} {% if p %} {% if p == pagination.page %} <li class="active"> <a href="{{ url_for(endpoint, page = p, **kwargs) }}{{ fragment }}">{{ p }}</a> </li> {% else %} <li> <a href="{{ url_for(endpoint, page = p, **kwargs) }}{{ fragment }}">{{ p }}</a> </li> {% endif %} {% else %} <li class="disabled"><a href="#">…</a></li> {% endif %} {% endfor %} <li{% if not pagination.has_next %} class="disabled"{% endif %}> <a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{{ fragment }}{% else %}#{% endif %}"> » </a> </li> </ul> {% endmacro %}
Now we will create a page to render the list of returned results on the page. As this template will only ever be imported and never be rendered as an independent page, we will use an underscore preceding its name (in app/templates/_users.html
)
<ul class="users"> {% for user in users %} <li class="user"> <div class="user-thumbnail"> <a href="{{ url_for('.user', username=user.username) }}"> <img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=40) }}"> </a> </div> <div class="user-info"> <div class="user-username"> <a href="{{ url_for('.user', username=user.username) }}">{{ user.username }}</a> </div> </div> </li> {% endfor %} </ul>
After that, we will create the user's page that is currently being returned by the view and import the _users template inside (in app/templates/users.html
)
{% extends "base.html" %} {% import "_macros.html" as macros %} {% block title %}Offbrand - Users{% endblock %} {% block page_content %} <div> <div class="page-header"> <h2 id="page-title">Users</h2> </div> </div> {% include '_users.html' %} {% if pagination %} <div class="pagination"> {{ macros.pagination_widget(pagination, '.users') }} </div> {% endif %} {% endblock %}
Finally, we need to create a link to the page on the navbar to allow us to navigate to the user's page (in app/templates/base.html
)
#... <ul class="nav navbar-nav"> <li><a href="{{ url_for('main.index') }}">Home</a></li> {% if current_user.is_authenticated %} <li><a href="{{ url_for('main.user', username=current_user.username) }}">Profile</a></li> <li><a href="{{ url_for('main.users') }}">Users</a></li> <li><a href="{{ url_for('main.new_collection') }}">Add Collection</a></li> <li><a href="{{ url_for('main.new_content') }}">Add Content</a></li> {% endif %} </ul> #...
F4F
Most social networks give you the ability to see who is following who, and who is followed by whom. We will implement this ourselves, as well as showing a count of those followers on the user/collection page.
Since it is the more basic one, we will start with creating the collection followers template (in app/templates/collection_followers.html
).
{% extends "base.html" %} {% import "_macros.html" as macros %} {% block title %}Offbrand - Followers{% endblock %} {% block page_content %} <div class="page-header"> <h2 id="page-title">{{ title }}</h2> </div> <table class="table table-hover followers"> <thead><tr><th>User</th><th>Since</th></tr></thead> {% for follow in follows %} {% if follow.user != collection.author %} <tr> <td> <a href="{{ url_for('.user', username = follow.user.username) }}"> <img class="img-rounded" src="{{ follow.user.gravatar(size=32) }}"> {{ follow.user.username }} </a> </td> <td>{{ moment(follow.timestamp).format('L') }}</td> </tr> {% endif %} {% endfor %} </table> <div class="pagination"> {{ macros.pagination_widget(pagination, endpoint, id=collection.id) }} </div> {% endblock %}
User followers will be slightly more complicated, as unlike collections, users will both follow and be followed. We want to try and keep the number of pages to a minimum (DRY!), so we will need to use a little creativity with our templating (in app/templates/followers.html
)
{% extends "base.html" %} {% import "_macros.html" as macros %} {% block title %}Offbrand - {{ title }} {{ user.username }}{% endblock %} {% block page_content %} <div class="page-header"> <h1>{{ title }} {{ user.username }}</h1> </div> <table class="table table-hover followers"> <thead><tr><th>User</th><th>Since</th></tr></thead> {% for follow in follows %} {% if follow.user != user %} <tr> <td> <a href="{{ url_for('.user', username = follow.user.username) }}"> <img class="img-rounded" src="{{ follow.user.gravatar(size=32) }}"> {{ follow.user.username }} </a> </td> <td>{{ moment(follow.timestamp).format('L') }}</td> </tr> {% endif %} {% endfor %} </table> <div class="pagination"> {{ macros.pagination_widget(pagination, endpoint, username = user.username) }} </div> {% endblock %}
Now that the templates are finished, we create the views to return them. Since the many to many relationships separate the classes from each other, we will need to use a list comprehension to get the details on each user and pass them through as a variable in the template (in app/main/views.py
)
#... @main.route('/followers/<username>') def followers(username): user = User.query.filter_by(username=username).first() if user is None: flash('Invalid user.') return redirect(url_for('.index')) page = request.args.get('page', 1, type=int) pagination = user.followers.paginate( page, per_page=current_app.config['OFFBRAND_RESULTS_PER_PAGE'], error_out=False) follows = [{'user': item.follower, 'timestamp': item.timestamp} for item in pagination.items] return render_template('followers.html', user=user, title="Followers of", endpoint='.followers', pagination=pagination, follows=follows) @main.route('/followed_by/<username>') def followed_by(username): user = User.query.filter_by(username=username).first() if user is None: flash('Invalid user.') return redirect(url_for('.index')) page = request.args.get('page', 1, type=int) pagination = user.followed.paginate( page, per_page=current_app.config['OFFBRAND_RESULTS_PER_PAGE'], error_out=False) follows = [{'user': item.followed, 'timestamp': item.timestamp} for item in pagination.items] return render_template('followers.html', user=user, title="Followed by", endpoint='.followed_by', pagination=pagination, follows=follows) @main.route('/collection-followers/<int:id>') @login_required def collection_followers(id): collection = Collection.query.filter_by(id=id).first() if collection is None: flash('Invalid User.') return redirect(url_for('.index')) page = request.args.get('page', 1, type=int) pagination = collection.user_followers.paginate( page, per_page=current_app.config['OFFBRAND_RESULTS_PER_PAGE'], error_out=False ) follows = [{'user': item.c_follower, 'timestamp': item.timestamp} for item in pagination.items] return render_template('collection_followers.html', collection=collection, title='Followers', endpoint='.collection_followers', pagination=pagination, follows=follows) #...
Finally, we will need a way to access these pages, so we can update our user and collection pages to give a count as well as a link to see who is following or being followed by what. The collection page will be first (in app/templates/collection.html
)
#... </p> <div> <a href="{{ url_for('.collection_followers', id=collection.id) }}">Followers: <span class="badge">{{ collection.user_followers.count() - 1 }}</span></a> </div> {% if collection.author == current_user or current_user.is_administrator() %} #...
Now the same for the user page (in app/templates/user.html
)
#... {% endif %} <a href="{{ url_for('.followers', username=user.username) }}">Followers: <span class="badge">{{ user.followers.count() - 1 }}</span></a> <a href="{{ url_for('.followed_by', username=user.username) }}">Following: <span class="badge">{{ user.followed.count() - 1 }}</span></a> {% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %} #...
Act of Creation
Before we move on, create a new user and confirm them, and if you’re feeling particularly adventurous, create a new collection with some content for them. Remember before you do so to run flask deploy
to migrate and update the database, as well as to add the self follows to both your profile and your collection!
Injections
Before we can follow a user or a collection, we will have to make sure that the user has the follow level permissions. We will be “injecting” permission to our views to make them available in our templates (in app/main/init.py
)
#... from ..models import Permission @main.app_context_processor def inject_permissions(): return dict(Permission=Permission)
On The Button
We will be updating the user page to add a dynamic follow/unfollow button dependent on the current follow status (in app/templates/user.html
)
#... <p>Member since {{ moment(user.member_since).format('L') }}. Last seen {{ moment(user.last_seen).fromNow() }}.</p> <p> {% if current_user.can(Permission.FOLLOW) and user != current_user %} {% if not current_user.is_following(user) %} <a href="{{ url_for('.follow', username=user.username) }}" class="btn btn-primary">Follow</a> {% else %} <a href="{{ url_for('.unfollow', username=user.username) }}" class="btn btn-default">Unfollow</a> {% endif %} {% endif %} {% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %} | <span class="label label-default">Follows you</span> {% endif %} </p> #...
Next, we will do the same for our collection page (in app/templates/collections.html
)
#... <p id="collection-description">{{ collection.description }}</p> <p> {% if current_user.can(Permission.FOLLOW) and user != current_user %} {% if not current_user.is_following_collection(collection) %} <a href="{{ url_for('.follow_collection', id=collection.id) }}" class="btn btn-primary">Follow</a> {% else %} <a href="{{ url_for('.unfollow_collection', id=collection.id) }}" class="btn btn-default">Unfollow</a> {% endif %} {% endif %} </p> #...
Finally, a little bit of housekeeping. Since there can be unauthenticated users who will be accessing our site, we will want to create an empty Stream user token for anonymous users so that a non-authenticated user won't cause the site to crash ( in app/models.py
)
#... class AnonymousUser(AnonymousUserMixin): #... def stream_user_token(self): return None
Following
If you haven't created a new user and some collections/content, do so now. It will help provide some validation after we are finished that everything is working properly. If/once you have, navigate to that user’s page with your original account (using that spiffy new all users endpoint) and follow both the account and it’s collection. You should see all of the new features that we’ve built so far.
Index and Notifications
Aggregate and Notification feeds have some important distinctions between flat feeds in the way that data is retrieved. As such, we will have to make some small changes to the script we used in our user and collection pages to construct our infinite scroll feed. Starting with the homepage timeline (in app/templates/index.html
)
{% extends "base.html" %} {% block title %}Offbrand{% endblock %} {% block page_content %} <div class="page-header"> <h1>Welcome to Offbrand!</h1> </div> {% if current_user.can(Permission.FOLLOW) %} <div class="collection-content"> <div id="content-scroller"> {% include "_content.html" %} </div> </div> <div id="content-sentinel"></div> <script> document.addEventListener("DOMContentLoaded", function() { const client = stream.connect( '7yuqbuncncwa', '{{ token }}' ); const timeline = client.feed('Timeline', '{{ user.id }}'); let content_template = document.querySelector("#content-template"); let content_scroller = document.querySelector("#content-scroller"); let content_sentinel = document.querySelector("#content-sentinel"); let content_loading = false; let last_id = null; let last_page = false; function loadContent() { if (!content_loading) { content_loading = true; if (!last_id) { request = timeline.get({ limit:10 }) } else { request = timeline.get({ limit:10, id_lt: last_id }) } if (!last_page) { request.then((data) => { for (var i = 0; i < data.results.length; i++) { for (var j = 0; j < data.results[i].activities.length; j++) { let template_clone = content_template.content.cloneNode(true); <!-- Author Info --> template_clone.querySelector('#user-profile-img').src = data.results[i].activities[j]['actor']['data']['gravatar']; template_clone.querySelector('#user-profile-img').alt = 'None'; template_clone.querySelector('#user-profile-link').href = '/user/' + data.results[i].activities[j]['actor']['data']['username']; template_clone.querySelector('#user-profile-link').innerHTML = data.results[i].activities[j]['actor']['data']['username']; <!-- Content Info--> template_clone.querySelector('#content-title').innerHTML = data.results[i].activities[j]['post']; template_clone.querySelector('#content-title').href = '/content/' + data.results[i].activities[j]['object'].slice(8); template_clone.querySelector('#content-description').innerHTML = data.results[i].activities[j]['description']; template_clone.querySelector('#content-read-more').innerHTML = 'Read More'; template_clone.querySelector('#content-read-more').href = data.results[i].activities[j]['url']; <!-- Content Metadata --> template_clone.querySelector('#content-timestamp').innerHTML = moment(data.results[i].activities[j]['time'] + 'Z').fromNow(); template_clone.querySelector('#content-collection-link').innerHTML = data.results[i].activities[j]['collection']['name']; template_clone.querySelector('#content-collection-link').href = '/collection/' + data.results[i].activities[j]['collection']['id']; content_scroller.appendChild(template_clone); } } last_id = data.results[data.results.length - 1].activities[data.results[data.results.length - 1].activities.length - 1].id; if (data.next === "") { content_sentinel.innerHTML = `<p>That's All!<p>`; content_loading = false; last_page = true; return; } content_loading = false }) } } } var contentIntersectionObserver = new IntersectionObserver(entries => { if (entries[0].intersectionRatio <= 0) { return; } loadContent(); }); contentIntersectionObserver.observe(content_sentinel); }); </script> {% endif %} {% endblock %}
Next, we need to create our notification page (in app/templates/notifications.html
)
{% extends "base.html" %} {% block title %}Notifications{% endblock %} {% block page_content %} <div class="page-header"> <h1>Notifications</h1> </div> <div class="user-collections"> <div id="collection-scroller"> {% include "_collection.html" %} </div> </div> <div id="collection-sentinel"></div> <script> document.addEventListener("DOMContentLoaded", function() { const client = stream.connect( '7yuqbuncncwa', '{{ token }}' ); const notifications = client.feed('Notifications', '{{ user.id }}'); let collection_template = document.querySelector("#collection-template"); let collection_scroller = document.querySelector("#collection-scroller"); let collection_sentinel = document.querySelector("#collection-sentinel"); let collection_loading = false; let last_id = null; let last_page = false; function loadCollection() { if (!collection_loading) { collection_loading = true; if (!last_id) { request = notifications.get({limit: 10}) } else { request = notifications.get({limit: 10, id_lt: last_id}) } if (!last_page) { request.then((data) => { for (var i = 0; i < data.results.length; i++) { for (var j = 0; j < data.results[i].activities.length; j++) { let template_clone = collection_template.content.cloneNode(true); <!-- Author Info --> template_clone.querySelector('#user-profile-img').src = data.results[i].activities[j]['actor']['data']['gravatar']; template_clone.querySelector('#user-profile-img').alt = 'None'; template_clone.querySelector('#user-profile-link').href = '/user/' + data.results[i].activities[j]['actor']['data']['username']; template_clone.querySelector('#user-profile-link').innerHTML = data.results[i].activities[j]['actor']['data']['username']; <!-- Collection Info --> template_clone.querySelector('#collection-name').innerHTML = data.results[i].activities[j]['create']; template_clone.querySelector('#collection-name').href = '/collection/' + data.results[i].activities[j]['object'].slice(11); template_clone.querySelector('#collection-description').innerHTML = data.results[i].activities[j]['description']; <!-- Collection Metadata --> template_clone.querySelector('#collection-timestamp').innerHTML = moment(data.results[i].activities[j]['time'] + 'Z').fromNow(); collection_scroller.appendChild(template_clone) } } last_id = data.results[data.results.length - 1].id; console.log(last_id); if (data.next === "") { collection_sentinel.innerHTML = `<p>That's All!<p>`; collection_loading = false; last_page = true; return; } collection_loading = false }) } } } console.log(1); var collectionIntersectionObserver = new IntersectionObserver(entries => { if (entries[0].intersectionRatio <= 0) { return; } loadCollection(); }); collectionIntersectionObserver.observe(collection_sentinel); }); </script> {% endblock %}
While similar to flat feeds, there are some significant changes that I want to run through. First, the results now contain a nested list comprising “activities”. For the aggregated feed, it allows you to create custom logic to compress multiple similar activities (x added y new pieces of content to z collection). Notifications have their benefits, like seen and read receipts that can be updated in your JavaScript request. We will be taking advantage of this next week when we integrate a counter into the navbar to help users keep track of new activities.
After setting up the templates, we will update the view for the index as well as a notifications file (in app/main/views.py
).
#... # Index @main.route('/') def index(): token = current_user.stream_user_token() return render_template('index.html', token=token, user=current_user) # Notifications @main.route('/notifications') def notifications(): token = current_user.stream_user_token() return render_template('notifications.html', token=token, user=current_user) #...
Last but not least, we will include the notifications link within the user options tab in the navbar (in app/templates/base.html
)
#... <ul class="dropdown-menu"> <li><a href="{{ url_for('main.notifications') }}">Notifications</a></li> <li><a href="{{ url_for('auth.change_password') }}">Change Password</a></li> <li><a href="{{ url_for('auth.change_email_request') }}">Change Email</a></li> <li><a href="{{ url_for('auth.logout') }}">Log Out</a></li> </ul> #...
Sanity Check
Running flask deploy
and flask run
, we can see our brand new homepage. This will show all of the newest content added to any collections the user is following. In the top right corner under an account, there is a notifications tab that will show you all of the newest collections created by users that you follow!
Final Thoughts
It’s taken a little bit of thought and time to get here, but the core elements of a fully-featured social web app are taking shape. We have customized feeds, a navigation system, and (hopefully not too) complex follow relationships between users. Users can create, edit, and delete content on the site, as well as have a customizable profile page. The one drawback we see is that the page isn’t particularly aesthetically pleasing. In our next article, we will change all that, as we start implementing link previews using Open Graph, CSS Styling, as well as some UX tweaks like displaying notification counts.
As always, thanks for reading, and happy coding!
Note: The next post in this series can be found here