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, " <" , " <" );
119+ findAndReplaceAll ( result, " >" , " >" );
120+ findAndReplaceAll ( result, " &" , " &" );
121+ findAndReplaceAll ( result, " "" , " \" " );
122+ findAndReplaceAll ( result, " '" , " '" );
123+ findAndReplaceAll ( result, " " , " " );
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+ }
0 commit comments