浏览代码

release v0.2.2

Harlan Iverson 1 年之前
父节点
当前提交
0d2d11ccba

+ 11 - 23
hogumathi_app.py

@@ -9,9 +9,11 @@ from flask_cors import CORS
 sys.path.append('.data/extensions')
 
 if find_spec('twitter_v2_facade'):
+    # FIXME get_brand needs a refactor
     from twitter_v2_facade import twitter_app as twitter_v2
     import oauth2_login
     twitter_enabled = True
+    
 else:
     print('twitter module not found.')
     twitter_enabled = False
@@ -19,6 +21,10 @@ else:
 if find_spec('twitter_v2_live_facade'):
     from twitter_v2_live_facade import twitter_app as twitter_v2_live
     import oauth2_login
+    
+    from item_collections import item_collections_bp
+    from brands import brands_bp, get_brand
+    
     twitter_live_enabled = True
 else:
     print('twitter live module not found.')
@@ -77,7 +83,7 @@ else:
 
 
 #messages_enabled = False
-#twitter_live_enabled = False
+#twitter_live_enabled = True
 
 add_account_enabled = True
 
@@ -251,8 +257,10 @@ if __name__ == '__main__':
         
     if twitter_live_enabled:
         api.register_blueprint(twitter_v2_live, url_prefix='/twitter-live')
-    
-    CORS(api)
+        api.register_blueprint(item_collections_bp, url_prefix='/collections')
+        api.register_blueprint(brands_bp, url_prefix='/')
+        
+    #CORS(api)
     
     @api.get('/login.html')
     def get_login_html ():
@@ -269,25 +277,5 @@ if __name__ == '__main__':
     def index ():
         return redirect(url_for('.get_login_html'))
     
-    @api.get('/brand/<brand_id>.html')
-    def get_brand_html (brand_id):
-        brands = {
-            'ispoogedaily': {
-                'id': 'ispoogedaily',
-                'display_name': 'iSpooge Daily',
-                'accounts': [
-                    'twitter:14520320',
-                    'mastodon:mastodon.cloud:109271381872332822',
-                    'youtube:UCUMn9G0yzhQWXiRTOmPLXOg'
-                ]
-            }
-        }
-        
-        brand = brands.get(brand_id.lower())
-        
-        if not brand:
-            return '{"error": "no such brand."}', 404
-        
-        return jsonify(brand)
         
     api.run(port=PORT, host=HOST)

+ 22 - 18
mastodon_facade.py

@@ -57,6 +57,8 @@ def mastodon_model_dc_vm (post_data: Status) -> FeedItem:
     This is the method we should use. The others should be refactored out.
     """
     
+    print(post_data)
+    
     user = post_data.account
     
     source_url = post_data.url
@@ -309,16 +311,24 @@ def get_tweet_html (tweet_id = None):
 @twitter_app.get('/profile/<user_id>.html')
 def get_profile_html (user_id):
     me = request.args.get('me')
-    mastodon_user = session[me]
-    
-    instance = mastodon_user['instance']
-    access_token = mastodon_user["access_token"]
+    mastodon_user = session.get(me)
     
     max_id = request.args.get('max_id')
-
-    headers = {
-       'Authorization': f'Bearer {access_token}'
-    }
+    
+    instance = request.args.get('instance') or mastodon_user['instance']
+    access_token = mastodon_user and mastodon_user.get('access_token')
+    
+    print(instance)
+    
+    headers = {}
+    
+    if access_token:
+        headers.update({
+           'Authorization': f'Bearer {access_token}'
+        })
+    
+    mastodon_source = MastodonAPSource(f'https://{instance}', access_token)
+    
     params = {}
     
     try:
@@ -336,20 +346,14 @@ def get_profile_html (user_id):
     
     
     user_id = account_info["id"]
+
     
-    url = f'https://{instance}/api/v1/accounts/{user_id}/statuses'
-    
-    params = {}
-    
-    if max_id:
-        params['max_id'] = max_id
-    
-    resp = requests.get(url, params=params, headers=headers)
+    tweets = mastodon_source.get_user_statuses(user_id, max_id=max_id, return_dataclasses=True)
+
 
-    tweets = json.loads(resp.text)
     #tweets = tweet_source.get_timeline("public", timeline_params)
     
-    max_id = tweets[-1]["id"] if len(tweets) else ''
+    max_id = tweets[-1].id if len(tweets) else ''
     tweets = list(map(mastodon_model_dc_vm, tweets))
     
     print("max_id = " + max_id)

+ 10 - 6
mastodon_source.py

@@ -23,11 +23,17 @@ class MastodonAPSource:
             **params
         }
         
-        headers = {"Authorization": "Bearer {}".format(self.token)}
+        headers = {}
+        if self.token:
+            headers.update({
+               'Authorization': f'Bearer {self.token}'
+            })
         
         response = requests.get(url, params=params, headers=headers)
         
-        #print(response)
+        print(url)
+        #print(response.status_code)
+        #print(response.text)
         
         response_json = json.loads(response.text)
         
@@ -127,10 +133,8 @@ class MastodonAPSource:
         # /api/v2/search?type=statuses&account_id=
         # /api/v1/accounts/:id/statuses
         
-        url = f'{self.endpoint}/api/v1/accounts/{account_id}/statuses'
-        headers = {
-           'Authorization': f'Bearer {self.token}'
-        }
+        collection_path = f'/api/v1/accounts/{account_id}/statuses'
+        
         params = {}
         
         if max_id:

+ 1 - 1
oauth2_login.py

@@ -40,7 +40,7 @@ oauth2_login = Blueprint('oauth2_login', 'oauth2_login',
 
 def url_for_with_me (route, *args, **kwargs):
     #print('url_for_with_me')
-    if route.endswith('.static'):
+    if route.endswith('.static') or not 'me' in g:
         return og_url_for(route, *args, **kwargs)
     
     return og_url_for(route, *args, **{'me': g.me, **kwargs})

+ 3 - 1
templates/base.html

@@ -57,8 +57,10 @@ Flexbox makes logical sense but we'll go with a table-based display
 		{% endfor %}
 	{% endif %}
 	
+	<!--
 	<li><a href="javascript:document.body.classList.toggle('theme-dark')">Toggle Dark Mode</a></li>
 	<li><a href="javascript:document.location.reload()">Refresh</a></li>
+	-->
 </ul>
 
 {% if twitter_user or mastodon_user %}
@@ -89,7 +91,7 @@ Flexbox makes logical sense but we'll go with a table-based display
 {% block content %}{% endblock %}
 
 </div>
-<footer style="position: absolute; bottom: -300px; right: 0; width: 100px;">
+<footer style="position: absolute; bottom: -800px; right: 0; width: 100px;">
 <p>
 Powered by <a href="https://glitch.com/~hogumathi">Hogumathi</a>. Give a gift to the author with <a href="https://venmo.com/harlanji">Venmo</a> or become a <a href="https://patreon.com/harlanji">patron</a> for gifts
 like early access and coupon codes for software on <a href="https://harlanji.gumroad.com">Gumroad</a>.

+ 17 - 0
templates/brand-page.html

@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block head %}
+	<title>{{ brand.display_name }} on Hogumathi</title>
+
+{% endblock %}
+
+{% block content %}
+	<div class="w-100" style="height: 80px;">
+		{% include "partial/page-nav.html" %}
+	</div>
+	
+	{% if brand %}
+	{% include "partial/brand-info.html" %}
+	{% endif %}
+	
+{% endblock %}

+ 24 - 0
templates/dual-collection.html

@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+
+{% block head %}
+	<title>Tweet Library: {{ user.id }}</title>
+{% endblock %}
+
+
+{% block content %}
+
+
+	{% include "partial/page-nav.html" %}
+	
+	<div class="w-100">
+		<div class="w-50" style="float: left">
+			{% include "partial/tweets-timeline.html" %}
+		</div>
+		<div class="w-50" style="float: left">
+			{% with tweets=tweets2, skip_plot=True %}
+			{% include "partial/tweets-timeline.html" %}
+			{% endwith %}
+		</div>
+	</div>
+
+{% endblock %}

+ 24 - 0
templates/followers.html

@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{% if twitter_live_enabled %}
+<div class="w-100">
+<h2>Follower activity</h2>
+
+<ul>
+
+<li>Posting Schedule (4 seconds * number of followers)
+
+<li>Like Schedule (13 seconds * number of followers)
+
+<li>Unfollows
+
+</ul>
+
+</div>
+{% endif %}
+
+{% include "partial/users-list.html" %}
+
+{% endblock %}

+ 93 - 0
templates/partial/brand-info.html

@@ -0,0 +1,93 @@
+<div class="w-100" style="background-color: white; border: 1px solid black;">
+
+<h1>{{ brand.display_name }}</h1>
+
+<h2>Tips</h2>
+
+<ul>
+
+{% if venmo %}
+<li><a href="{{ venmo.href }}">Venmo</a> (@{{ venmo.id }})</li>
+{% endif %}
+
+</ul>
+
+
+
+
+{% if patreon or gumroad %}
+	<h2>Subscriptions</h2>
+
+	<ul>
+
+	{% if patreon %}
+	<li><a href="{{ patreon.href }}">Patreon</a> (@{{ patreon.id }})</li>
+	{% endif %}
+
+	{% if gumroad %}
+	<li><a href="{{ gumroad.href }}">Gumroad</a></li>
+	{% endif %}
+
+	</ul>
+{% endif %}
+
+
+{% if twitter or youtube or mastodon or receipt_tracker %}
+	<h2>On Hogumathi</h2>
+
+	<ul>
+
+	{% if twitter %}
+	<li><a href="{{ twitter.hogu_href }}">Twitter</a> (@{{ twitter.username }}) [<a href="{{ twitter.href }}">source</a>]</li>
+	{% endif %}
+
+	{% if youtube %}
+	<li><a href="{{ youtube.hogu_href }}">YouTube</a> (@{{ youtube.username }}) [<a href="{{ youtube.href }}">source</a>]</li>
+	{% endif %}
+
+	{% if mastodon %}
+	<li><a href="{{ mastodon.hogu_href }}">Mastodon</a> (@{{ mastodon.username }}@{{ mastodon.instance }}) [<a href="{{ mastodon.href }}">source</a>]</li>
+	{% endif %}
+
+	{% if receipt_tracker %}
+	<li><a href="{{ receipt_tracker.hogu_href }}">Receipts</a> [<a href="{{ receipt_tracker.href }}">source</a>]</li>
+	{% endif %}
+
+	</ul>
+{% endif %}
+
+{% if mailchimp or discord %}
+	<h2>Community</h2>
+
+	<ul>
+
+	{% if mailchimp %}
+	<li><a href="{{ mailchimp.href }}">Mailchimp</a></li>
+	{% endif %}
+
+	{% if discord %}
+	<li><a href="{{ discord.href }}">Discord</a></li>
+	{% endif %}
+
+	</ul>
+{% endif %}
+
+{% if twitch or instagram %}
+	<h2>Other Socials</h2>
+
+	<ul>
+
+	{% if twitch %}
+	<li><a href="{{ twitch.href }}">Twitch</a> (@{{ twitch.id }})</a></li>
+	{% endif %}
+
+
+	{% if instagram %}
+	<li><a href="{{ instagram.href }}">Instagram</a> (@{{ instagram.id }})</a></li>
+	{% endif %}
+
+{% endif %}
+
+</ul>
+
+</div>

+ 13 - 0
templates/partial/page-nav.html

@@ -0,0 +1,13 @@
+<div class="w-100" style="height: 40px">
+	
+	<p class="w-100" style="text-align: right;">
+		
+		{% if page_nav %}
+		{% for nav_item in page_nav|sort(attribute='order') %}
+		| <a href="{{ nav_item.href }}">{{ nav_item.label }}</a>
+		{% endfor %}
+		{% endif %}
+		
+	</p>
+	
+</div>

+ 13 - 0
templates/partial/tweet-thread-children.html

@@ -0,0 +1,13 @@
+{% if children %}
+<div>
+	<ul class="tweets">
+		{% for thread_item in children %}
+		<li style="border: 1px solid black">
+		
+		{% include "partial/tweet-thread-item.html" %}
+		
+		</li>
+		{% endfor %}
+	</ul>
+</div>
+{% endif %}

+ 22 - 0
templates/partial/tweet-thread-item.html

@@ -0,0 +1,22 @@
+{% with tweet = thread_item.feed_item %}
+{% include "partial/timeline-tweet.html" %}
+{% if tweet.unreplied %}
+<div class="w-100" style="background-color: skyblue; border: 1px solid red;">
+	<h3>Unreplied sections</h3>
+	<ul>
+		{% for unreplied in tweet.unreplied %}
+		<li>
+			<p>{{ unreplied.description }}</p>
+			<p>Text: {{ unreplied.text_from(tweet) }}</p>
+		</li>
+		{% endfor %}
+	</ul>
+</div>
+{% endif %}
+{% endwith %}
+
+{% with children = thread_item.children %}
+
+{% include "partial/tweet-thread-children.html" %}
+
+{% endwith %}

+ 20 - 16
templates/partial/tweets-timeline.html

@@ -26,22 +26,8 @@ var notesAppUrl = {{ notes_app_url | tojson }}
 	}
 </script>
 
-{% if twitter_live_enabled and visjs_enabled %}
-
-<div class="w-100" style="position: sticky; top: 20px; background-color: silver; padding: 20px 0; margin: 10px 0;">
-		<div id="visualization"></div>
-	
-</div>
-
-{% endif %}
-
-<ul id="tweets" class="tweets w-75 center z-0">
-
-{% for tweet in tweets %}
-
-<li class="tweet w-100 dt {% if tweet.is_marked %}marked{% endif %}">
 <script>
-	function feed_item_to_activity (fi) {
+function feed_item_to_activity (fi) {
 		
 		var group = 'tweet';
 		var y = 1;
@@ -99,6 +85,24 @@ var notesAppUrl = {{ notes_app_url | tojson }}
 			'feed_item': fi
 		}
 	}
+</script>
+
+{% if twitter_live_enabled and visjs_enabled and not skip_plot %}
+
+<div class="w-100" style="position: sticky; top: 20px; background-color: silver; padding: 20px 0; margin: 10px 0;">
+		<div id="visualization"></div>
+	
+</div>
+
+{% endif %}
+
+<ul id="tweets" class="tweets w-75 center z-0">
+
+{% for tweet in tweets %}
+
+<li class="tweet w-100 dt {% if tweet.is_marked %}marked{% endif %}">
+<script>
+	
 	
 	var feedItem = {{ tweet | tojson }};
 	var plotItems = [];
@@ -249,7 +253,7 @@ var notesAppUrl = {{ notes_app_url | tojson }}
 	</li>
 </ul>
 
-			{% if twitter_live_enabled and visjs_enabled %}
+			{% if twitter_live_enabled and visjs_enabled and not skip_plot %}
 			
 	<script>
 

+ 1 - 12
templates/tweet-collection.html

@@ -8,19 +8,8 @@
 {% block content %}
 
 
-<div class="w-100" style="height: 40px">
+	{% include "partial/page-nav.html" %}
 	
-	<p class="w-100" style="text-align: right">
-		
-		{% if page_nav %}
-		{% for nav_item in page_nav|sort(attribute='order') %}
-		| <a href="{{ nav_item.href }}">{{ nav_item.label }}</a>
-		{% endfor %}
-		{% endif %}
-		
-	</p>
-	
-</div>
 	
 	{% include "partial/tweets-timeline.html" %}
 	

+ 21 - 0
templates/tweet-thread.html

@@ -0,0 +1,21 @@
+{% extends "base.html" %}
+
+{% block head %}
+	<title>Tweet Library: {{ user.id }}</title>
+{% endblock %}
+
+
+{% block content %}
+
+
+	{% include "partial/page-nav.html" %}
+	
+	<div class="w-100" style="border: 1px solid silver; background-color: white">
+	
+	{% with thread_item=root, skip_embed_replies=True %}
+	{% include "partial/tweet-thread-item.html" %}
+	{% endwith %}
+	
+	</div>
+	
+{% endblock %}

+ 15 - 30
templates/user-profile.html

@@ -12,23 +12,20 @@
 
 
 	<div class="w-100">
-		{% if twitter_user %}
 		
 		{% include "partial/user-card.html" %}
 		
+		{% if brand %}
+		{% include "partial/brand-info.html" %}
+		{% endif %}
+		
 		<div class="w-100" style="height: 80px;">
-			<p class="w-100" style="text-align: right">
-			
-			{% if page_nav %}
-			{% for nav_item in page_nav|sort(attribute='order') %}
-			| <a href="{{ nav_item.href }}">{{ nav_item.label }}</a>
-			{% endfor %}
-			{% endif %}
-			
-			</p>
+
+			{% include "partial/page-nav.html" %}
+
 			
 			<div class="w-100">
-			<form action="{{ url_for('.get_profile_html', user_id=user.id) }}" method="GET">
+			<form action="{{ url_for('twitter_v2_facade.get_profile_html', user_id=user.id) }}" method="GET">
 			
 			<input type="hidden" name="me" value="{{ me }}">
 			<input type="hidden" name="user_id" value="{{ user.id }}">
@@ -43,28 +40,16 @@
 		</div>
 		
 		
-		
-		{% endif %}
-		
-		{% block tab_content %}
-		
-		{% if tab == "media" %}
-			media
-		
-		{% else %}
 
 
-		
-			{% with show_thread_controls=True %}
-			
-			{% include "partial/tweets-timeline.html" %}
-			
-			{% endwith %}
-			
 
-			
-		{% endif %}
+	
+		{% with show_thread_controls=True %}
+		
+		{% include "partial/tweets-timeline.html" %}
 		
-		{% endblock %}
+		{% endwith %}
+		
+
 	</div>
 {% endblock %}

+ 5 - 13
templates/youtube-channel.html

@@ -9,21 +9,13 @@
 
 
 
-<div class="w-100" style="height: 40px">
-	
-	<p class="w-100" style="text-align: right">
-		
-		{% if page_nav %}
-		{% for nav_item in page_nav|sort(attribute='order') %}
-		| <a href="{{ nav_item.href }}">{{ nav_item.label }}</a>
-		{% endfor %}
-		{% endif %}
-		
-	</p>
-	
-</div>
+	{% include "partial/page-nav.html" %}
 	
 	{% include "partial/users-list.html" %}
 
+	{% if brand %}
+	{% include "partial/brand-info.html" %}
+	{% endif %}
+
 	
 {% endblock %}

+ 7 - 5
tweet_source.py

@@ -664,7 +664,9 @@ class ApiV2TweetSource:
                        since_id = None,
                        granularity = 'hour'
                        ):
-        
+        """
+        App rate limit (Application-only): 300 requests per 15-minute window shared among all users of your app = once per 3 seconds.
+        """
         token = self.token
         
         url = "https://api.twitter.com/2/tweets/counts/recent"
@@ -681,8 +683,8 @@ class ApiV2TweetSource:
         
         response = requests.get(url, params=params, headers=headers)
         
-        print(response.status_code)
-        print(response.text)
+        #print(response.status_code)
+        #print(response.text)
         
         response_json = json.loads(response.text)
         
@@ -808,13 +810,13 @@ class ApiV2TweetSource:
         
         user_fields = ["id", "created_at", "name", "username", "location", "profile_image_url", "verified", "description", "public_metrics", "protected", "pinned_tweet_id", "url"]
         
-        expansions = None
+        expansions = ["author_id"]
         
         params = cleandict({
             "user.fields": ','.join(user_fields),
             "max_results": max_results,
             "pagination_token": pagination_token,
-            "expansions": expansions,
+            "expansions": ','.join(expansions),
         })
         
         headers = {

+ 102 - 195
twitter_v2_facade.py

@@ -2,6 +2,8 @@ from typing import List
 from dataclasses import asdict, replace
 from dacite import from_dict
 
+from importlib.util import find_spec
+
 from configparser import ConfigParser
 import base64
 import sqlite3
@@ -32,10 +34,13 @@ from flask_cors import CORS
 from tweet_source import ApiV2TweetSource, TwitterApiV2SocialGraph, ApiV2ConversationSource
 from twitter_v2_types import Tweet, TweetExpansions
 
-from view_model import FeedItem, FeedServiceUser, FeedItemAction, MediaItem, Card, PublicMetrics, NonPublicMetrics, cleandict
+from view_model import FeedItem, FeedServiceUser, ThreadItem, FeedItemAction, MediaItem, Card, PublicMetrics, NonPublicMetrics, UnrepliedSection, cleandict
 
 import oauth2_login
 
+if find_spec('brands'):
+    from brands import find_brand_by_account, fetch_brand_info
+
 DATA_DIR='.data'
 
 twitter_app = Blueprint('twitter_v2_facade', 'twitter_v2_facade',
@@ -167,196 +172,6 @@ class TwitterMetadata:
         return tweet
 
 
-def collection_from_card_source (url):
-    """
-    temp1 = await fetch('http://localhost:5000/notes/cards/search?q=twitter.com/&limit=10').then(r => r.json())
-
-    re = /(http[^\s]+twitter\.com\/[^\/]+\/status\/[\d]+)/ig
-    tweetLinks = temp1.cards.map(c => c.card.content).map(c => c.match(re))
-    tweetLinks2 = tweetLinks.flat().filter(l => l)
-    tweetLinksS = Array.from(new Set(tweetLinks2))
-
-    statusUrls = tweetLinksS.map(s => new URL(s))
-    //users = Array.from(new Set(statusUrls.map(s => s.pathname.split('/')[1])))
-    ids = Array.from(new Set(statusUrls.map(s => parseInt(s.pathname.split('/')[3]))))
-    """
-    
-    """
-    temp1 = JSON.parse(document.body.innerText)
-    // get swipe note + created_at + tweet user + tweet ID
-    tweetCards = temp1.cards.map(c => c.card).filter(c => c.content.match(re))
-    tweets = tweetCards.map(c => ({created_at: c.created_at, content: c.content, tweets: c.content.match(re).map(m => new URL(m))}))
-    
-    tweets.filter(t => t.tweets.filter(t2 => t2.user.toLowerCase() == 'stephenmpinto').length)
-    
-    
-    // HN
-    re = /(http[^\s]+news.ycombinator\.com\/[^\s]+\=[\d]+)/ig
-    linkCards = temp1.cards.map(c => c.card).filter(c => c.content.match(re))
-    links = linkCards.map(c => ({created_at: c.created_at, content: c.content, links: c.content.match(re).map(m => new URL(m))}))
-    
-    // YT (I thnk I've already done this one)
-    
-    """
-    
-    # more in 2022 twitter report
-
-    return None
-
-def get_tweet_collection (collection_id):
-    with open(f'{DATA_DIR}/collection/{collection_id}.json', 'rt', encoding='utf-8') as f:
-        collection = json.loads(f.read())
-        
-    return collection
-
-# pagination token is the next tweet_ID
-@twitter_app.get('/collection/<collection_id>.html')
-def get_collection_html (collection_id):
-    
-    max_results = int(request.args.get('max_results', 10))
-    
-    pagination_token = request.args.get('pagination_token')
-    
-    collection = get_tweet_collection(collection_id)
-    
-    if 'authorized_users' in collection and g.twitter_user['id'] not in collection['authorized_users']:
-        return 'access denied.', 403
-    
-    items = []
-    for item in collection['items']:
-        tweet_id = item['id']
-        if pagination_token and tweet_id != pagination_token:
-            continue
-        elif tweet_id == pagination_token:
-            pagination_token = None
-        elif len(items) == max_results:
-            pagination_token = tweet_id
-            break
-        
-        items.append(item)
-        
-    
-    if not len(items):
-        return 'no tweets', 404
-    
-    token = g.twitter_user['access_token']
-    
-    
-    tweet_source = ApiV2TweetSource(token)
-    
-    tweet_ids = list(map(lambda item: item['id'], items))
-    response_json = tweet_source.get_tweets( tweet_ids, return_dataclass=True )
-    
-    #print(response_json)
-    if response_json.errors:
-        # types:
-        # https://api.twitter.com/2/problems/not-authorized-for-resource (blocked or suspended)
-        # https://api.twitter.com/2/problems/resource-not-found (deleted)
-        #print(response_json.get('errors'))
-        for err in response_json.errors:
-            if not 'type' in err:
-                print('unknown error type: ' + str(err))
-            elif err['type'] == 'https://api.twitter.com/2/problems/not-authorized-for-resource':
-                print('blocked or suspended tweet: ' + err['value'])
-            elif err['type'] == 'https://api.twitter.com/2/problems/resource-not-found':
-                print('deleted tweet: ' + err['value'])
-            else:
-                print('unknown error')
-            
-            print(json.dumps(err, indent=2))
-            
-    
-    includes = response_json.includes
-    tweets = list(map(lambda t: tweet_model_dc_vm(includes, t, g.me), response_json.data))
-    
-    for item in items:
-        t = list(filter(lambda t: item['id'] == t.id, tweets))
-        
-        if not len(t):
-            print("no tweet for item: " + item['id'])
-            t = FeedItem(
-                id = item['id'],
-                text = "(Deleted, suspended or blocked)",
-                created_at = "",
-                handle = "error",
-                display_name = "Error"
-            )
-            # FIXME 1) put this in relative order to the collection
-            # FIXME 2) we can use the tweet link to get the user ID...
-            
-        else:
-            t = t[0]
-        
-        t = replace(t, note = item['note'])
-        
-        tweets.append(t)
-    
-    if request.args.get('format') == 'json':
-        return jsonify({'ids': tweet_ids,
-                       'data': cleandict(asdict(response_json)),
-                       'tweets': tweets,
-                       'items': items,
-                       'pagination_token': pagination_token})
-    else:
-        query = {}
-        
-        if pagination_token:
-            query['next_data_url'] = url_for('twitter_v2_facade.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
-        
-        if 'HX-Request' in request.headers:
-            return render_template('partial/tweets-timeline.html', tweets = tweets, user = {}, query = query)
-        else:
-            if pagination_token:
-                query['next_page_url'] = url_for('twitter_v2_facade.get_collection_html', collection_id=collection_id, pagination_token=pagination_token)
-            return render_template('user-profile.html', tweets = tweets, user = {}, query = query)
-
-
-
-@twitter_app.post('/data/collection/create/from-cards')
-def post_data_collection_create_from_cards ():
-    """
-    // create collection from search, supporting multiple Tweets per card and Tweets in multiple Cards.
-    
-    
-    re = /(https?[a-z0-9\.\/\:]+twitter\.com\/[0-9a-z\_]+\/status\/[\d]+)/ig
-    
-    temp1 = await fetch('http://localhost:5000/notes/cards/search?q=twitter.com/').then(r => r.json())
-
-    cardMatches = temp1.cards
-    .map(cm => Object.assign({}, cm, {tweetLinks: Array.from(new Set(cm.card.content.match(re)))}))
-    .filter(cm => cm.tweetLinks && cm.tweetLinks.length)
-    .map(cm => Object.assign({}, cm, {tweetUrls: cm.tweetLinks.map(l => new URL(l))}))
-    .map(cm => Object.assign({}, cm, {tweetInfos: cm.tweetUrls.map(u => ({user: u.pathname.split('/')[1], tweetId: u.pathname.split('/')[3]}))}));
-    
-    collectionCards = {}
-
-    cardMatches.forEach(function (cm) {
-        if (!cm.tweetLinks.length) { return; }
-        cm.tweetInfos.forEach(function (ti) {
-            if (!collectionCards[ti.tweetId]) {
-                collectionCards[ti.tweetId] = [];
-            }
-            collectionCards[ti.tweetId].push(cm.card);
-        })
-    })
-
-    var collectionItems = [];
-    Object.entries(collectionCards).forEach(function (e) {
-        var tweetId = e[0], cards = e[1];
-        var note = cards.map(function (card) {
-            return card.created_at + "\n\n" + card.content;
-        }).join("\n\n-\n\n");
-
-        collectionItems.push({id: tweetId, note: note, tweet_infos: cm.tweetInfos, card_infos: cards.map(c => 'card#' + c.id)});
-    })
-    """
-    
-    collection = {
-        'items': [], # described in JS function above
-        'authorized_users': [g.twitter_user['id']]
-    }
-    
-    return jsonify(collection)
 
 #twitter_meta = TwitterMetadata('./data/meta')
 
@@ -567,7 +382,68 @@ def get_tweet_html (tweet_id):
             image = user.profile_image_url
         )
         
-        return render_template('tweet-collection.html', user = user, tweets = tweets, query = query, page_nav=page_nav, skip_embed_replies=skip_embed_replies, opengraph_info=opengraph_info)
+        
+        if view == 'replies':
+            tweet = tweets[0]
+            
+            if tweet.id == '1608510741941989378':
+                unreplied = [
+                    UnrepliedSection(
+                        description = "Not clear what GS is still.",
+                        span = (40, 80)
+                    )
+                ]
+                tweet = replace(tweet,
+                    unreplied = unreplied
+                    )
+            
+            expand_parts = request.args.get('expand')
+            if expand_parts:
+                expand_parts = expand_parts.split(',')
+            
+            def reply_to_thread_item (fi):
+                nonlocal expand_parts
+                
+                if fi.id == '1609714342211244038':
+                    print(f'reply_to_thread_item id={fi.id}')
+                    unreplied = [
+                        UnrepliedSection(
+                            description = "Is there proof of this claim?",
+                            span = (40, 80)
+                        )
+                    ]
+                    fi = replace(fi,
+                        unreplied = unreplied
+                        )
+                
+                children = None
+                
+                if expand_parts and len(expand_parts) and fi.id == expand_parts[0]:
+                    expand_parts = expand_parts[1:]
+                    
+                    print(f'getting expanded replied for tweet={fi.id}')
+                    
+                    expanded_replies_response = tweet_source.get_thread(fi.id,
+                                                only_replies=True,
+                                                return_dataclass=True)
+                    if expanded_replies_response.data:
+                        print('we got expanded responses data')
+                        
+                        children =  list(map(lambda t: tweet_model_dc_vm(expanded_replies_response.includes, t, g.me), expanded_replies_response.data))
+                        children = list(map(reply_to_thread_item, children))
+                
+                
+                return ThreadItem(feed_item=fi, children=children)
+                
+            children = list(map(reply_to_thread_item, tweets[1:]))
+            
+            root = ThreadItem(
+                feed_item = tweet,
+                children = children
+            )
+            return render_template('tweet-thread.html', user = user, root = root, query = query, page_nav=page_nav, skip_embed_replies=skip_embed_replies, opengraph_info=opengraph_info)
+        else:
+            return render_template('tweet-collection.html', user = user, tweets = tweets, query = query, page_nav=page_nav, skip_embed_replies=skip_embed_replies, opengraph_info=opengraph_info)
 
 
 
@@ -705,7 +581,7 @@ def tweet_model_dc_vm (includes: TweetExpansions, tweet: Tweet, me, my_url_for=u
         'view_conversation': FeedItemAction('twitter_v2_facade.get_tweet_html', {'tweet_id': tweet.conversation_id, 'view': 'conversation'}),
     }
     
-    if g.twitter_user:
+    if g.get('twitter_user'):
         actions.update(
             bookmark = FeedItemAction('twitter_v2_facade.post_tweet_bookmark', {'tweet_id': tweet.id}),
             delete_bookmark = FeedItemAction('twitter_v2_facade.delete_tweet_bookmark', {'tweet_id': tweet.id}),
@@ -1105,6 +981,8 @@ def get_profile_html (user_id):
     exclude_replies = request.args.get('exclude_replies', '0')
     exclude_retweets = request.args.get('exclude_retweets', '0')
     
+    
+    
     tweet_source = ApiV2TweetSource(token)
     response_json = tweet_source.get_user_timeline(user_id,
                                                     exclude_replies = exclude_replies == '1',
@@ -1206,6 +1084,17 @@ def get_profile_html (user_id):
                 order = 50,
             )
         ]
+        
+        if not g.twitter_user:
+            for uid, acct in session.items():
+                if uid.startswith('twitter:'):
+                    page_nav += [
+                        dict(
+                            href = url_for('twitter_v2_facade.get_profile_html', user_id=user_id, me=uid),
+                            label = f'View as {acct["id"]}',
+                            order = 1000,
+                        )
+                    ]
 
         if g.twitter_live_enabled:
             page_nav += [
@@ -1227,8 +1116,25 @@ def get_profile_html (user_id):
             ]
             
         top8 = get_top8(user_id)
+        
+        brand_info = {}
+        if g.twitter_live_enabled:
+            brand = find_brand_by_account(f'twitter:{user_id}')
             
-        return render_template('user-profile.html', user = user, tweets = tweets, query = query, opengraph_info=opengraph_info, page_nav = page_nav)
+            if brand:
+                page_nav += [
+                    dict(
+                        href = url_for('brands.get_brand_html', brand_id=brand['id']),
+                        label = 'Brand Page',
+                        order = 5000,
+                    )
+                ]
+               
+                brand_info = fetch_brand_info(brand)
+                brand_info.update({'brand': brand, 'twitter': None})
+        
+        return render_template('user-profile.html', user = user, tweets = tweets, query = query, opengraph_info=opengraph_info, page_nav = page_nav, top8=top8, **brand_info)
+
 
 
 
@@ -1406,4 +1312,5 @@ def get_top8 (user_id):
         dict(
             id='14520320'
         ),
-    ]
+    ]
+    

+ 33 - 1
view_model.py

@@ -1,5 +1,5 @@
 from dataclasses import dataclass, asdict, replace
-from typing import List, Dict, Optional
+from typing import List, Dict, Optional, Tuple
 
 @dataclass
 class PublicMetrics:
@@ -66,6 +66,21 @@ class FeedItemAttachment:
     
     size: Optional[int] = None
 
+@dataclass
+class UnrepliedSection:
+    feed_item_id: Optional[str] = None
+    description: Optional[str] = None
+    text: Optional[str] = None
+    span: Optional[Tuple[int, int]] = None
+    
+    def text_from (self, feed_item: 'FeedItem') -> str:
+        if not self.span or not feed_item.text:
+            return
+        
+        return feed_item.text[self.span[0]:self.span[1]]
+
+ReplyingToSection = UnrepliedSection
+
 @dataclass
 class FeedItem:
     id: str
@@ -121,10 +136,27 @@ class FeedItem:
     
     # At some point we may move to feed_item_actions when the set is known
     actions: Optional[Dict[str, FeedItemAction]] = None
+    
+    # This is a TBD concept to highlight unreplied parts of a message.
+    unreplied: Optional[List[UnrepliedSection]] = None
+    
+    # This is a TBD concept to highlight parts of a message that are a reply.
+    replying_to: Optional[List[ReplyingToSection]] = None
 
 # tm = FeedItem(id="1", text="aa", created_at="fs", display_name="fda", handle="fdsafas")
 
 
+@dataclass
+class ThreadItem:
+    feed_item: FeedItem
+    children: Optional[List['ThreadItem']] = None
+    parent: Optional['ThreadItem'] = None
+    parents: Optional[List['ThreadItem']] = None
+    
+    actions: Optional[Dict[str, FeedItemAction]] = None
+    
+
+
 @dataclass
 class FeedServiceUser:
     id: str