This is the eighth installment of a tutorial series focused on how to create a full-stack application using Flask and Stream. In this article, we will be walking through how to retrieve information from the Stream API client-side using Javascript to make an infinite scroll feature for our collections and content. Be sure to check out the Github repo to follow along!
Getting Started
At this point in our development, all of the requests have been done on the backend, through the server. One of the key advantages of integrating Stream is the ability to make client-side requests, using Javascript in the browser; this allows us to avoid proxying through our servers to retrieve information. In making these requests, we have to ensure that we retain control over our authentication credentials to avoid unauthorized access.
Stream provides a useful tool to generate user tokens to authenticate and limit their ability to modify data on the service. We will first have to generate these tokens on our backend and pass them to the user through a template. Much like our previous interactions with Stream, we will create user tokens as a method on the User
class (in app/models.py
) to be returned in a request.
#... class User(db.Model, UserMixin): #... def stream_user_token(self): client = stream.connect(current_app.config['STREAM_API_KEY'], current_app.config['STREAM_SECRET']) return client.create_user_token(str(self.id)) #...
Token Issues
Now that we have a way to generate tokens for the user
, we will return them in the views that require them. In the last couple of articles, we made ways for a user to create, update, and delete collections and content, but no way for them to be able to view it. As collections are the highest level component created by a user, we will create a feed on the user
page to view all of the collections that they’ve created. After that, the collection
page will have a feed of all the content that has been added to it. Let’s update app/main/views.py
to generate the Stream tokens for both and return them in their respective views:
#... @main.route('/user/<username>') def user(username): user = User.query.filter_by(username=username).first_or_404() token = user.stream_user_token() return render_template('user.html', user=user, token=token) #...
Next, we’ll make the same adjustment for the collection
view:
#... @main.route('/collection/<int:id>', methods=['GET']) @login_required def get_collection(id): collection = Collection.query.get_or_404(id) token = collection.author.stream_user_token() return render_template('collection.html', collection=collection, token=token) #...
Client-Based Fun
Stream provides a helpful JavaScript library to access its resources instead of building your own from scratch. In order to use it, we will have to import the package in our base.html
file. While we are there, we can also update our navbar to add routes for adding collections and content:
#... {% block head %} {{ super() }} <link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon"> <link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon"> <script src="https://cdn.jsdelivr.net/npm/getstream/dist/js_min/getstream.js"></script> {% endblock %} #... <div class="navbar-collapse collapse"> <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.new_collection') }}">Add Collection</a></li> <li><a href="{{ url_for('main.new_content') }}">Add Content</a></li> {% endif %} </ul> #...
Templates in Templates
Users expect a visual consistency in accessing elements across a site, and you want to try and modularize components as much as possible to avoid repeating yourself (DRY!). To employ this philosophy for our app, we will create a template for rendering collections and content and simply import them to the pages where we will be using them. That way, if we make a change to the layout of a component, it will be replicated across the entire site, rather than having to recode every single page. collections
and content
share a number of attributes, so the layout will be rather similar between them:
app/templates/_collection.html
:
https://gist.github.com/Porter97/6ebfab932bf81d6c2b190e06ca9204f7
As compared to
app/templates/_content.html
:
https://gist.github.com/Porter97/0604fdcb507c922fac26434ac97664d7
Collecting Collections
Rather than paginating users’ collections with an ugly set of buttons at the bottom, we want to use an “infinite scroll” feature. Infinite scroll has become a rather ubiquitous feature in modern social media networks, and I find it makes the user experience a lot more fluid than continuously clicking to navigate through pages. Infinite scroll is actually a surprisingly simple feature to implement, when you know how to do it; with that said, there are a few “best practices” that can often slip through the development cracks. For the HTML portion, it's pretty simple. There’s the template that we just defined, a scroller to which newly formed elements are attached, and a sentinel element, which monitors for when a user (app/templates/user.html
) has scrolled to a specific threshold of the screen to request and render new elements to the DOM:
<div> <div class="page-header"> <img class="img-rounded profile-thumbnail" src="{{ user.gravatar(size=256) }}"> <div class="profile-header"> <h1>{{ user.username }}</h1> {% if user.name %} <p> {{ user.name }}<br> </p> {% endif %} {% if current_user.is_administrator() %} <p><a href="mailto:{{ user.email }}">{{ user.email }}</a></p> {% endif %} {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %} <p>Member since {{ moment(user.member_since).format('L') }}. Last seen {{ moment(user.last_seen).fromNow() }}.</p> <p> {% if user == current_user %} <a class="btn btn-default" href="{{ url_for('.edit_profile') }}">Edit Profile</a> {% endif %} {% if current_user.is_administrator() %} <a class="btn btn-danger" href="{{ url_for('.edit_profile_admin', id=user.id) }}">Edit Profile [Admin]</a> {% endif %} </p> </div> </div> <div class="user-collections"> <div id="collection-scroller"> {% include "_collection.html" %} </div> <div id="collection-sentinel"></div> </div> </div>
Following the Script
Next, we will set up a script to request and render the information on the page in our app/templates/user.html
file. As this is our first time really diving into JavaScript, below, we’ll take a little time to walk through what is happening.
<script> document.addEventListener("DOMContentLoaded", function() { const client = stream.connect( 'Your Public Key Here', '{{ token }}' ); const user = client.feed('User', '{{ 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; function loadCollection() { if (!collection_loading) { collection_loading = true; if (!last_id) { request = user.get({ limit:10 }) } else { request = user.get({ limit:10, id_lt: last_id }) } request.then((data) => { if (data.results.length < 1) { collection_sentinel.innerHTML = `<p>That's All!<p>`; collection_loading = false; return; } for (var i = 0; i < data.results.length; i++) { let template_clone = collection_template.content.cloneNode(true); <!-- Author Info --> template_clone.querySelector('#user-profile-img').src = data.results[i]['actor']['data']['gravatar']; template_clone.querySelector('#user-profile-img').alt = 'None'; template_clone.querySelector('#user-profile-link').href = '/user/' + data.results[i]['actor']['data']['username']; template_clone.querySelector('#user-profile-link').innerHTML = data.results[i]['actor']['data']['username']; <!-- Collection Info --> template_clone.querySelector('#collection-name').innerHTML = data.results[i]['create']; template_clone.querySelector('#collection-name').href = '/collection/' + data.results[i]['object'].slice(11); template_clone.querySelector('#collection-description').innerHTML = data.results[i]['description']; <!-- Collection Metadata --> template_clone.querySelector('#collection-timestamp').innerHTML = moment(data.results[i]['time'] + 'Z').fromNow(); collection_scroller.appendChild(template_clone) } last_id = data.results[data.results.length - 1].id; collection_loading = false }) } } var collectionIntersectionObserver = new IntersectionObserver(entries => { if (entries[0].intersectionRatio <= 0) { return; } loadCollection(); }); collectionIntersectionObserver.observe(collection_sentinel) }); </script>
First, when the DOM content is loaded, we create a Stream client using a public key and the token you created and passed through the view. After that, we create a variable to hold the feed of the page that we are currently viewing.
Next, we create variables for the template, scroller, and sentinel, as well as one for whether or not a collection is currently loading as a boolean value and the last ID of the most recent collection that was retrieved. As you can see at the beginning of the loadCollection()
function, it first checks to make sure that the script isn’t already running to make sure we don’t render duplicates. The next step is checking to see if there is a last_id
value, and if there is, including it with the user.get()
function. Stream recommends you use last_id
as the preferred method of pagination, as it drastically reduces the latency of the requests. Once the data is requested, it is checked to ensure there are values present in the results and, if not, the app returns a message to indicate the end to the user.
Once a response is returned with results, the script loops through it and, for each of the results, populates the template fields, appending the completed template to the scroller. Finally, it creates a new intersection observer on the sentinel component for the next time a user scrolls to that location. For the timestamp, we add a “Z” to the end of the string, to make sure that it is parsed properly for UTC with moment.js
.
Once again, we are going to replicate the same steps for the collections page in rendering content. First, we insert the template, scroller, and sentinel in the collection.html
page:
{% extends "base.html" %} {% block title %}Offbrand - {{ collection.name }}{% endblock %} {% block page_content %} <div> <div class="page-header"> <h2 id="collection-name">{{ collection.name }}</h2> <p id="collection-description">{{ collection.description }}</p> {% if collection.author == current_user or current_user.is_administrator() %} <a id="edit-collection" class="btn btn-warning" href="{{ url_for('.edit_collection', id=collection.id) }}">Edit Collection</a> {% endif %} <p id="collection-info"><img class="img-rounded profile-thumbnail" src="{{ collection.author.gravatar(size=35) }}"><a id="collection-author" href="{{ url_for('main.user', username=collection.author.username) }}">{{ collection.author.username }}</a> • {{ moment(collection.timestamp).fromNow() }}</p> </div> <div class="collection-content"> <div id="content-scroller"> {% include "_content.html" %} </div> <div id="content-sentinel"></div> </div> </div>
Next, we add in the JavaScript at the bottom in <script>
tags:
<script> document.addEventListener("DOMContentLoaded", function() { const client = stream.connect( 'Your Public Key Here', '{{ token }}' ); const collection = client.feed('Collections', '{{ collection.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; function loadContent() { if (!content_loading) { content_loading = true; if (!last_id) { request = collection.get({ limit:10 }) } else { request = collection.get({ limit:10, id_lt: last_id }) } request.then((data) => { if (data.results.length < 1 ) { content_sentinel.innerHTML = `<p>That's All!<p>`; content_loading = false; return; } for (var i = 0; i < data.results.length; i++) { let template_clone = content_template.content.cloneNode(true); <!-- Author Info --> template_clone.querySelector('#user-profile-img').src = data.results[i]['actor']['data']['gravatar']; template_clone.querySelector('#user-profile-img').alt = 'None'; template_clone.querySelector('#user-profile-link').href = '/user/' + data.results[i]['actor']['data']['username']; template_clone.querySelector('#user-profile-link').innerHTML = data.results[i]['actor']['data']['username']; <!-- Content Info--> template_clone.querySelector('#content-title').innerHTML = data.results[i]['post']; template_clone.querySelector('#content-title').href = '/content/' + data.results[i]['object'].slice(8); template_clone.querySelector('#content-description').innerHTML = data.results[i]['description']; template_clone.querySelector('#content-read-more').innerHTML = 'Read More'; template_clone.querySelector('#content-read-more').href = data.results[i]['url']; <!-- Content Metadata --> template_clone.querySelector('#content-timestamp').innerHTML = moment(data.results[i]['time'] + 'Z').fromNow(); template_clone.querySelector('#content-collection-link').innerHTML = data.results[i]['collection']['name']; template_clone.querySelector('#content-collection-link').href = '/collection/' + data.results[i]['collection']['id']; content_scroller.appendChild(template_clone); } last_id = data.results[data.results.length - 1].id; content_loading = false }) } } var contentIntersectionObserver = new IntersectionObserver(entries => { if (entries[0].intersectionRatio <= 0) { return; } loadContent(); }); contentIntersectionObserver.observe(content_sentinel); }); </script> {% endblock %}
Sanity Check
Our user page should now render any collections a user has at the bottom of the page in an infinite scroll feed:
Once you’ve created your first collection, you can then click through the title to get to the collection page and see any content rendered at the bottom, in the same way you can see a user’s collections at the bottom of their page:
Finishing Up
Congratulations! We have now created an infinite scroll element on both user
and collection
pages: a way for registered users to create collections and content, as well as easily navigate the site and all of its elements. In our next article, we are going to get into allowing users to follow each other and specific collections, before creating a homepage timeline that aggregates all of the activity from each user’s follows into a customized feed!
As always, thanks for reading, and happy coding!
Note: The next post in this series can be found here.