Skip to content

Commit 725c6aa

Browse files
committed
Replace Twitter API with Mastodon integration in GoodNightMorning sample
1 parent e79c8fb commit 725c6aa

File tree

9 files changed

+254
-189
lines changed

9 files changed

+254
-189
lines changed

samples/_svg/GoodNightMorning/proj/cmake/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ if( CMAKE_SYSTEM_NAME STREQUAL "Linux" )
2727
)
2828

2929
ci_make_app(
30-
SOURCES ${APP_PATH}/src/GoodNightMorningApp.cpp ${APP_PATH}/src/TweetStream.cpp ${CAIRO_SOURCES}
30+
SOURCES ${APP_PATH}/src/GoodNightMorningApp.cpp ${APP_PATH}/src/MastadonHashtagStream.cpp ${CAIRO_SOURCES}
3131
INCLUDES ${CAIRO_INCLUDES}
3232
LIBRARIES ${CAIRO_LIBRARIES}
3333
CINDER_PATH ${CINDER_PATH}
@@ -40,7 +40,7 @@ if( CMAKE_SYSTEM_NAME STREQUAL "Linux" )
4040
else()
4141
# Use standard CinderBlock for other platforms
4242
ci_make_app(
43-
SOURCES ${APP_PATH}/src/GoodNightMorningApp.cpp ${APP_PATH}/src/TweetStream.cpp
43+
SOURCES ${APP_PATH}/src/GoodNightMorningApp.cpp ${APP_PATH}/src/MastadonHashtagStream.cpp
4444
BLOCKS Cairo
4545
CINDER_PATH ${CINDER_PATH}
4646
)

samples/_svg/GoodNightMorning/src/GoodNightMorningApp.cpp

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
#include "cinder/Timeline.h"
88
#include "cinder/ip/Fill.h"
99
#include "cinder/ip/Blend.h"
10+
#include "cinder/ip/Resize.h"
1011
#include "cinder/gl/Texture.h"
1112
#include "cinder/gl/gl.h"
1213

13-
#include "TweetStream.h"
14+
#include "MastadonHashtagStream.h"
1415

1516
using namespace ci;
1617
using namespace ci::app;
@@ -39,24 +40,21 @@ class GoodNightMorningApp : public App {
3940
Anim<float> mMorningTweetAlpha, mNightTweetAlpha;
4041
gl::TextureRef mMorningTweetTex, mNightTweetTex;
4142
TweenRef<float> mMorningTween, mNightTween;
42-
43-
shared_ptr<TweetStream> mMorningTweets, mNightTweets;
44-
};
4543

46-
void GoodNightMorningApp::prepareSettings( Settings *settings )
47-
{
48-
settings->setWindowSize( 890, 500 );
49-
}
44+
shared_ptr<MastadonHashtagStream> mMorningTweets, mNightTweets;
45+
};
5046

5147
void GoodNightMorningApp::setup()
5248
{
53-
mCityscapeSvg = std::shared_ptr<svg::Doc>( new svg::Doc( loadAsset( "cityscape.svg" ) ) );
49+
setWindowSize( 1280, 720 );
50+
51+
mCityscapeSvg = std::shared_ptr<svg::Doc>( new svg::Doc( loadAsset( "cityscape.svg" ) ) );
5452
mFont = Font( loadAsset( "Lato-Bold.ttf" ), 14 );
5553
mHeaderFont = Font( loadAsset( "Lato-Light.ttf" ), 14 );
56-
57-
mMorningTweets = shared_ptr<TweetStream>( new TweetStream( "\"Good morning\"" ) );
58-
mNightTweets = shared_ptr<TweetStream>( new TweetStream( "\"Good night\"" ) );
59-
54+
55+
mMorningTweets = shared_ptr<MastadonHashtagStream>( new MastadonHashtagStream( "goodmorning" ) );
56+
mNightTweets = shared_ptr<MastadonHashtagStream>( new MastadonHashtagStream( "goodnight" ) );
57+
6058
newMorningTweet();
6159
newNightTweet();
6260

@@ -66,16 +64,35 @@ void GoodNightMorningApp::setup()
6664
// Renders the tweet into a gl::Texture, using TextBox for type rendering
6765
gl::TextureRef GoodNightMorningApp::renderTweet( const Tweet &tweet, float width, const Color &textColor, float backgroundAlpha )
6866
{
67+
const int avatarSize = 32;
68+
const int avatarPadding = 6;
69+
const int textOffset = avatarSize + avatarPadding;
70+
71+
// Truncate long messages with ellipsis
72+
string message = tweet.getPhrase();
73+
const int maxMessageLength = 140; // Limit like old Twitter
74+
if( message.length() > maxMessageLength ) {
75+
message = message.substr( 0, maxMessageLength - 3 ) + "...";
76+
}
77+
6978
TextBox header = TextBox().font( mHeaderFont ).color( textColor ).text( "@" + tweet.getUser() );
7079
Surface headerSurface = header.render();
71-
TextBox phrase = TextBox().size( vec2( width - 48 - 4, TextBox::GROW ) ).font( mFont ).color( textColor ).text( tweet.getPhrase() );
80+
TextBox phrase = TextBox().size( vec2( width - textOffset - 4, TextBox::GROW ) ).font( mFont ).color( textColor ).text( message );
7281
Surface textSurface = phrase.render();
73-
Surface result( textSurface.getWidth() + 56, std::max( headerSurface.getHeight() + textSurface.getHeight() + 10, 56 ), true );
82+
83+
int resultHeight = std::max( headerSurface.getHeight() + textSurface.getHeight() + 10, avatarSize + 8 );
84+
Surface result( textSurface.getWidth() + textOffset + 8, resultHeight, true );
7485
ip::fill( &result, ColorA( 1, 1, 1, backgroundAlpha ) );
75-
if( tweet.getIcon() )
76-
result.copyFrom( *tweet.getIcon(), tweet.getIcon()->getBounds(), ivec2( 4, 4 ) );
86+
87+
// Draw avatar as a small square on the left
88+
if( tweet.getIcon() ) {
89+
Surface resizedAvatar = ip::resizeCopy( *tweet.getIcon(), tweet.getIcon()->getBounds(), ivec2( avatarSize, avatarSize ) );
90+
result.copyFrom( resizedAvatar, resizedAvatar.getBounds(), ivec2( 4, 4 ) );
91+
}
92+
93+
// Position text to the right of the avatar
7794
ip::blend( &result, headerSurface, headerSurface.getBounds(), ivec2( result.getWidth() - 4 - headerSurface.getWidth(), textSurface.getHeight() + 6 ) );
78-
ip::blend( &result, textSurface, textSurface.getBounds(), ivec2( 56, 4 ) );
95+
ip::blend( &result, textSurface, textSurface.getBounds(), ivec2( textOffset, 4 ) );
7996
return gl::Texture::create( result );
8097
}
8198

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#include "MastadonHashtagStream.h"
2+
#include "cinder/Rand.h"
3+
#include "cinder/Thread.h"
4+
#include "cinder/Function.h"
5+
#include "cinder/Json.h"
6+
#include "cinder/ImageIo.h"
7+
#include "cinder/Surface.h"
8+
#include "cinder/Log.h"
9+
#include "cinder/Url.h"
10+
#include "cinder/Utilities.h"
11+
12+
using namespace std;
13+
using namespace ci;
14+
15+
string stripHtmlTags( const string& htmlText );
16+
Json queryMastodon( const std::string& hashtag, const std::string& sinceId = "" );
17+
18+
MastadonHashtagStream::MastadonHashtagStream( const std::string& hashtag )
19+
: mBuffer( 10 )
20+
, // our buffer of tweets can hold up to 10
21+
mCanceled( false )
22+
, mHashtag( hashtag )
23+
{
24+
mThread = thread( bind( &MastadonHashtagStream::serviceTweets, this ) );
25+
}
26+
27+
MastadonHashtagStream::~MastadonHashtagStream()
28+
{
29+
mCanceled = true;
30+
mBuffer.cancel();
31+
mThread.join();
32+
}
33+
34+
void MastadonHashtagStream::serviceTweets()
35+
{
36+
ThreadSetup threadSetup;
37+
38+
// Loop until the app quits, fetching new posts from Mastodon
39+
while( ! mCanceled ) {
40+
try {
41+
Json posts = queryMastodon( mHashtag, mLastPostId );
42+
43+
// Process posts in reverse order to maintain chronological order in buffer
44+
for( auto it = posts.rbegin(); it != posts.rend(); ++it ) {
45+
if( mCanceled )
46+
break;
47+
48+
try {
49+
const Json& post = *it;
50+
51+
// Extract the data we need
52+
string content = stripHtmlTags( post["content"].get<string>() );
53+
string username = post["account"]["username"].get<string>();
54+
string avatarUrl = post["account"]["avatar"].get<string>();
55+
56+
// Load the avatar image
57+
SurfaceRef userIcon;
58+
try {
59+
userIcon = Surface::create( loadImage( loadUrl( Url( avatarUrl ) ) ) );
60+
}
61+
catch( const Exception& exc ) {
62+
CI_LOG_W( "Failed to load avatar image: " << exc.what() );
63+
userIcon = SurfaceRef(); // null icon
64+
}
65+
66+
// Create and add the tweet
67+
mBuffer.pushFront( Tweet( content, username, userIcon ) );
68+
69+
// Update last post ID for pagination
70+
if( posts.size() > 0 && it == posts.rbegin() ) {
71+
mLastPostId = post["id"].get<string>();
72+
}
73+
}
74+
catch( const Exception& exc ) {
75+
CI_LOG_W( "Exception parsing individual post: " << exc.what() );
76+
}
77+
}
78+
}
79+
catch( const Exception& exc ) {
80+
CI_LOG_W( "Exception fetching Mastodon posts: " << exc.what() );
81+
// Put an error message in the stream
82+
mBuffer.pushFront( Tweet( "Mastodon API query failed", "error", SurfaceRef() ) );
83+
}
84+
85+
// Wait before next query (Mastodon rate limits are more generous than Twitter's old API)
86+
if( ! mCanceled ) {
87+
ci::sleep( 5000 ); // wait 5 seconds between requests
88+
}
89+
}
90+
}
91+
92+
Json queryMastodon( const std::string& hashtag, const std::string& sinceId )
93+
{
94+
string urlStr = "https://mastodon.social/api/v1/timelines/tag/" + Url::encode( hashtag ) + "?limit=20";
95+
if( ! sinceId.empty() ) {
96+
urlStr += "&since_id=" + Url::encode( sinceId );
97+
}
98+
99+
Url url( urlStr );
100+
string jsonString = loadString( loadUrl( url ) );
101+
return Json::parse( jsonString );
102+
}
103+
104+
void findAndReplaceAll( std::string& data, const std::string& toSearch, const std::string& replaceStr )
105+
{
106+
size_t pos = data.find( toSearch );
107+
while( pos != std::string::npos ) {
108+
data.replace( pos, toSearch.size(), replaceStr );
109+
pos = data.find( toSearch, pos + replaceStr.size() );
110+
}
111+
}
112+
113+
string stripHtmlTags( const string& htmlText )
114+
{
115+
string result = htmlText;
116+
117+
// Replace common HTML entities
118+
findAndReplaceAll( result, "&lt;", "<" );
119+
findAndReplaceAll( result, "&gt;", ">" );
120+
findAndReplaceAll( result, "&amp;", "&" );
121+
findAndReplaceAll( result, "&quot;", "\"" );
122+
findAndReplaceAll( result, "&#39;", "'" );
123+
findAndReplaceAll( result, "&nbsp;", " " );
124+
125+
// Remove HTML tags - simple approach for this demo
126+
size_t startPos = 0;
127+
while( ( startPos = result.find( "<", startPos ) ) != string::npos ) {
128+
size_t endPos = result.find( ">", startPos );
129+
if( endPos != string::npos ) {
130+
result.erase( startPos, endPos - startPos + 1 );
131+
}
132+
else {
133+
break;
134+
}
135+
}
136+
137+
// Clean up extra whitespace
138+
findAndReplaceAll( result, "\n", " " );
139+
findAndReplaceAll( result, "\r", " " );
140+
while( result.find( " " ) != string::npos ) {
141+
findAndReplaceAll( result, " ", " " );
142+
}
143+
144+
return result;
145+
}
146+
147+
bool MastadonHashtagStream::hasTweetAvailable()
148+
{
149+
return mBuffer.isNotEmpty();
150+
}
151+
152+
Tweet MastadonHashtagStream::getNextTweet()
153+
{
154+
Tweet result;
155+
mBuffer.popBack( &result );
156+
return result;
157+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#pragma once
2+
3+
#include "cinder/Surface.h"
4+
#include "cinder/ConcurrentCircularBuffer.h"
5+
#include "cinder/Thread.h"
6+
7+
#include <string>
8+
9+
class Tweet {
10+
public:
11+
Tweet() {}
12+
Tweet( const std::string& phrase, const std::string& user, const ci::SurfaceRef& icon )
13+
: mPhrase( phrase )
14+
, mUser( user )
15+
, mIcon( icon )
16+
{
17+
}
18+
19+
const std::string& getPhrase() const { return mPhrase; }
20+
const std::string& getUser() const { return mUser; }
21+
const ci::SurfaceRef& getIcon() const { return mIcon; }
22+
23+
private:
24+
std::string mPhrase, mUser;
25+
ci::SurfaceRef mIcon;
26+
};
27+
28+
class MastadonHashtagStream {
29+
public:
30+
MastadonHashtagStream( const std::string& hashtag );
31+
~MastadonHashtagStream();
32+
33+
bool hasTweetAvailable();
34+
Tweet getNextTweet();
35+
36+
protected:
37+
void serviceTweets();
38+
39+
std::string mHashtag;
40+
std::thread mThread;
41+
ci::ConcurrentCircularBuffer<Tweet> mBuffer;
42+
bool mCanceled;
43+
std::string mLastPostId;
44+
};

0 commit comments

Comments
 (0)