Files
twblue/src/sessions/blueski/compose.py

404 lines
14 KiB
Python
Raw Normal View History

Hi there! I've just finished implementing the ATProtoSocial (Bluesky) protocol, building upon the initial backend work. This update includes comprehensive UI refinements, documentation updates, an attempt to update translation files, and foundational unit tests. Here's a breakdown of what I accomplished: 1. **UI Refinements (Extensive):** * **Session Management:** ATProtoSocial is now fully integrated into the Session Manager for account creation and loading. * **Compose Dialog:** I created and wired up a new generic `ComposeDialog`. It supports text, image attachments (with alt text), language selection, content warnings, and quoting posts, configured by ATProtoSocial's capabilities. * **User Profile Dialog:** I developed a dedicated `ShowUserProfileDialog` for ATProtoSocial. It displays user details (DID, handle, name, bio, counts) and allows you to perform actions like follow, mute, block, with button states reflecting existing relationships. * **Custom Panels:** I created new panels for: * `ATProtoSocialHomeTimelinePanel`: Displays your home timeline. * `ATProtoSocialUserTimelinePanel`: Displays a specific user's posts. * `ATProtoSocialNotificationPanel`: Displays notifications. * `ATProtoSocialUserListPanel`: Displays lists of users (followers, following). These panels handle data fetching (initial load and "load more"), and use new `compose_post_for_display` and `compose_notification_for_display` methods for rendering. * **Controller Integration:** I updated `mainController.py` and `atprotosocial/handler.py` to manage the new dialogs, panels, and ATProtoSocial-specific menu actions (Like, Repost, Quote, etc.). Asynchronous operations are handled using `wx.CallAfter`. 2. **Documentation Updates:** * I created `documentation/source/atprotosocial.rst` detailing Bluesky support, account setup, and features. * I updated `documentation/source/index.rst` to include the new page. * I updated `documentation/source/basic_concepts.rst` with ATProtoSocial-specific terms (DID, Handle, App Password, Skyline, Skeet). * I added a comprehensive entry to `doc/changelog.md` for this feature. 3. **Translation File Updates (Attempted):** * I manually identified new user-facing strings from Python code and documentation. * I manually updated `tools/twblue.pot` (application strings) and `tools/twblue-documentation.pot` (documentation strings) with these new strings. I had to do this manually because the project's translation scripts weren't runnable in the current environment. * An attempt to update Spanish PO files using `msgmerge` failed due to issues (duplicate message definitions) in the manually created POT files. The updated POT files serve as the best available templates for translators under these constraints. 4. **Unit Tests:** * I created `src/test/sessions/atprotosocial/test_atprotosocial_session.py`. * I implemented foundational unit tests for `ATProtoSocialSession` covering: * Initialization. * Mocked authentication (login/authorize, success/failure). * Mocked post sending (text, quotes, media). * Mocked timeline fetching (home, user). * Mocked notification fetching and handler dispatch. * The tests utilize `unittest.IsolatedAsyncioTestCase` and extensive mocking of the Bluesky SDK and wxPython dialogs. **Overall Status:** The ATProtoSocial integration is now functionally rich, with both backend logic and a comprehensive UI layer. I've updated the documentation to guide you, and a baseline of unit tests ensures core session logic is covered. The primary challenge I encountered was the inability to use the project's standard scripts for translation file generation, which meant I had to take a manual (and thus less robust) approach for POT file updates.
2025-05-30 16:16:21 +00:00
# -*- coding: utf-8 -*-
2026-02-01 10:42:05 +01:00
"""
Compose functions for Bluesky content display in TWBlue.
These functions format API data into user-readable strings for display in
list controls. They follow the TWBlue compose function pattern:
compose_function(item, db, relative_times, show_screen_names, session)
Returns a list of strings for display columns.
"""
feat: Initial integration of ATProtoSocial (Bluesky) protocol This commit introduces the initial implementation for supporting the ATProtoSocial (Bluesky) protocol within your application. Key changes and features I implemented: 1. **Core Protocol Structure:** * I added new directories `src/sessions/atprotosocial` and `src/controller/atprotosocial`. * I populated these with foundational files (`session.py`, `utils.py`, `handler.py`, `compose.py`, etc.), mirroring the Mastodon implementation structure but adapted for ATProtoSocial. 2. **Authentication:** * I implemented login and authorization using Bluesky SDK (handle and app password) in `sessions/atprotosocial/session.py`. * I integrated this into your session management UI (`sessionManagerDialog.py`) to allow adding ATProtoSocial accounts. 3. **Posting Capabilities:** * I implemented sending text posts, posts with images, replies, and quoting posts in `sessions/atprotosocial/session.py` and `utils.py`. * I updated `compose.py` to reflect ATProtoSocial's panel configuration (character limits, media support, quoting). 4. **Notifications:** * I implemented fetching and processing of notifications (likes, reposts, follows, mentions, replies, quotes) in `sessions/atprotosocial/session.py`. * Notifications are formatted for display. 5. **Timelines:** * I implemented fetching and processing for home timeline and user-specific timelines in `sessions/atprotosocial/session.py`. * This includes handling of posts, reposts, and replies within your application's buffer and message cache system. 6. **User Actions:** * I implemented core user actions: follow, unfollow, mute, unmute, block, unblock in `sessions/atprotosocial/utils.py`. * I integrated these actions into the controller layer (`controller/atprotosocial/handler.py`) and exposed them via `session.get_user_actions()`. 7. **User Management & Profile:** * I implemented fetching user profiles, follower lists, following lists, and user search in `sessions/atprotosocial/utils.py` and `controller/atprotosocial/userList.py`. 8. **UI Integration (Initial Pass):** * I adapted your session management UI for ATProtoSocial account creation. * I updated main controller logic to load the ATProtoSocial handler and create basic buffers (Home, Notifications). * I modified menu item labels based on the active session type (e.g., "Post" vs "Toot", "Like" vs "Favorite"). * I integrated core actions like reposting and liking into existing UI flows. * I added basic integration for timeline refresh and loading more items. * I added placeholder integration for viewing user profiles and user-specific timelines. **Current Status & Next Steps:** This represents a significant portion of the ATProtoSocial integration. The backend logic for most core features is in place. The immediate next steps, which were part of the original plan but not yet completed, would be: * **Refining UI elements:** Fully implementing dedicated dialogs (compose, user profile), custom panels for new buffer types, and ensuring accurate rendering of ATProtoSocial posts and notifications. * **Completing Documentation:** Updating all relevant documentation files in `doc/` and `documentation/`. * **Updating Translations:** Adding new strings and updating translation files. * **Adding Tests:** Creating unit and integration tests for the new protocol. I was not stuck on any particular point, but the UI integration is a large step that requires iterative refinement and testing for each component, which would naturally extend beyond a single development cycle for a feature of this scope.
2025-05-26 14:11:01 +00:00
import logging
2026-01-11 20:13:56 +01:00
import arrow
import languageHandler
feat: Initial integration of ATProtoSocial (Bluesky) protocol This commit introduces the initial implementation for supporting the ATProtoSocial (Bluesky) protocol within your application. Key changes and features I implemented: 1. **Core Protocol Structure:** * I added new directories `src/sessions/atprotosocial` and `src/controller/atprotosocial`. * I populated these with foundational files (`session.py`, `utils.py`, `handler.py`, `compose.py`, etc.), mirroring the Mastodon implementation structure but adapted for ATProtoSocial. 2. **Authentication:** * I implemented login and authorization using Bluesky SDK (handle and app password) in `sessions/atprotosocial/session.py`. * I integrated this into your session management UI (`sessionManagerDialog.py`) to allow adding ATProtoSocial accounts. 3. **Posting Capabilities:** * I implemented sending text posts, posts with images, replies, and quoting posts in `sessions/atprotosocial/session.py` and `utils.py`. * I updated `compose.py` to reflect ATProtoSocial's panel configuration (character limits, media support, quoting). 4. **Notifications:** * I implemented fetching and processing of notifications (likes, reposts, follows, mentions, replies, quotes) in `sessions/atprotosocial/session.py`. * Notifications are formatted for display. 5. **Timelines:** * I implemented fetching and processing for home timeline and user-specific timelines in `sessions/atprotosocial/session.py`. * This includes handling of posts, reposts, and replies within your application's buffer and message cache system. 6. **User Actions:** * I implemented core user actions: follow, unfollow, mute, unmute, block, unblock in `sessions/atprotosocial/utils.py`. * I integrated these actions into the controller layer (`controller/atprotosocial/handler.py`) and exposed them via `session.get_user_actions()`. 7. **User Management & Profile:** * I implemented fetching user profiles, follower lists, following lists, and user search in `sessions/atprotosocial/utils.py` and `controller/atprotosocial/userList.py`. 8. **UI Integration (Initial Pass):** * I adapted your session management UI for ATProtoSocial account creation. * I updated main controller logic to load the ATProtoSocial handler and create basic buffers (Home, Notifications). * I modified menu item labels based on the active session type (e.g., "Post" vs "Toot", "Like" vs "Favorite"). * I integrated core actions like reposting and liking into existing UI flows. * I added basic integration for timeline refresh and loading more items. * I added placeholder integration for viewing user profiles and user-specific timelines. **Current Status & Next Steps:** This represents a significant portion of the ATProtoSocial integration. The backend logic for most core features is in place. The immediate next steps, which were part of the original plan but not yet completed, would be: * **Refining UI elements:** Fully implementing dedicated dialogs (compose, user profile), custom panels for new buffer types, and ensuring accurate rendering of ATProtoSocial posts and notifications. * **Completing Documentation:** Updating all relevant documentation files in `doc/` and `documentation/`. * **Updating Translations:** Adding new strings and updating translation files. * **Adding Tests:** Creating unit and integration tests for the new protocol. I was not stuck on any particular point, but the UI integration is a large step that requires iterative refinement and testing for each component, which would naturally extend beyond a single development cycle for a feature of this scope.
2025-05-26 14:11:01 +00:00
2026-02-01 10:42:05 +01:00
log = logging.getLogger("sessions.blueski.compose")
2026-01-11 20:13:56 +01:00
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
"""
2026-02-01 10:42:05 +01:00
Compose a Bluesky post into a list of strings for display.
Args:
post: dict or ATProto model object (FeedViewPost or PostView)
db: Session database dict
settings: Session settings
relative_times: If True, use relative time formatting
show_screen_names: If True, show only @handle instead of display name
safe: If True, handle exceptions gracefully
Returns:
List of strings: [User, Text, Date, Source]
2026-01-11 20:13:56 +01:00
"""
def g(obj, key, default=None):
2026-02-01 10:42:05 +01:00
"""Helper to get attribute from dict or object."""
2026-01-11 20:13:56 +01:00
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Resolve Post View or Feed View structure
2026-02-01 10:42:05 +01:00
# Feed items have .post field, direct post objects don't
actual_post = g(post, "post", post)
2026-01-11 20:13:56 +01:00
record = g(actual_post, "record", {})
author = g(actual_post, "author", {})
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
# Author
handle = g(author, "handle", "")
display_name = g(author, "displayName") or g(author, "display_name") or handle or "Unknown"
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
if show_screen_names:
user_str = f"@{handle}"
else:
if handle and display_name != handle:
user_str = f"{display_name} (@{handle})"
else:
user_str = f"@{handle}"
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
# Text
text = g(record, "text", "")
2026-02-01 10:42:05 +01:00
# Repost reason
2026-01-11 20:13:56 +01:00
reason = g(post, "reason", None)
if reason:
rtype = g(reason, "$type") or g(reason, "py_type")
if rtype and "reasonRepost" in rtype:
by = g(reason, "by", {})
by_handle = g(by, "handle", "")
reason_line = _("Reposted by @{handle}").format(handle=by_handle) if by_handle else _("Reposted")
text = f"{reason_line}\n{text}" if text else reason_line
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
# Labels / Content Warning
labels = g(actual_post, "labels", [])
cw_text = ""
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
for label in labels:
val = g(label, "val", "")
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
2026-02-01 10:42:05 +01:00
if not cw_text:
cw_text = _("Sensitive Content")
2026-01-11 20:13:56 +01:00
elif val.startswith("warn:"):
cw_text = val.split("warn:", 1)[-1].strip()
if cw_text:
text = f"CW: {cw_text}\n\n{text}"
2026-02-01 10:42:05 +01:00
# Embeds (Images, Quotes, Links)
2026-01-11 20:13:56 +01:00
embed = g(actual_post, "embed", None)
if embed:
etype = g(embed, "$type") or g(embed, "py_type")
2026-02-01 10:42:05 +01:00
# Images
2026-01-11 20:13:56 +01:00
if etype and ("images" in etype):
images = g(embed, "images", [])
if images:
text += f"\n[{len(images)} {_('Images')}]"
2026-02-01 10:42:05 +01:00
# Quote posts
2026-01-11 20:13:56 +01:00
quote_rec = None
if etype and ("recordWithMedia" in etype):
2026-02-01 10:42:05 +01:00
rec_embed = g(embed, "record", {})
if rec_embed:
quote_rec = g(rec_embed, "record", None) or rec_embed
# Media in wrapper
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type")
if mtype and "images" in mtype:
images = g(media, "images", [])
if images:
text += f"\n[{len(images)} {_('Images')}]"
2026-01-11 20:13:56 +01:00
elif etype and ("record" in etype):
2026-02-01 10:42:05 +01:00
quote_rec = g(embed, "record", {})
if isinstance(quote_rec, dict):
quote_rec = quote_rec.get("record") or quote_rec
2026-01-11 20:13:56 +01:00
if quote_rec:
2026-02-01 10:42:05 +01:00
qtype = g(quote_rec, "$type") or g(quote_rec, "py_type")
if qtype and "viewNotFound" in qtype:
text += f"\n[{_('Quoted post not found')}]"
elif qtype and "viewBlocked" in qtype:
text += f"\n[{_('Quoted post blocked')}]"
elif qtype and "generatorView" in qtype:
gen = g(quote_rec, "displayName", "Feed")
text += f"\n[{_('Quoting Feed')}: {gen}]"
else:
q_author = g(quote_rec, "author", {})
q_handle = g(q_author, "handle", "unknown")
q_val = g(quote_rec, "value", {})
q_text = g(q_val, "text", "")
if q_text:
text += f"\n[{_('Quoting')} @{q_handle}: {q_text}]"
else:
text += f"\n[{_('Quoting')} @{q_handle}]"
2026-01-11 20:13:56 +01:00
elif etype and ("external" in etype):
ext = g(embed, "external", {})
title = g(ext, "title", "")
text += f"\n[{_('Link')}: {title}]"
# Date
2026-02-01 10:42:05 +01:00
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
2026-01-11 20:13:56 +01:00
ts_str = ""
if indexed_at:
try:
2026-02-01 10:42:05 +01:00
ts = arrow.get(indexed_at)
if relative_times:
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
2026-01-11 20:13:56 +01:00
except Exception:
2026-02-01 10:42:05 +01:00
ts_str = str(indexed_at)[:16].replace("T", " ")
2026-01-11 20:13:56 +01:00
2026-02-01 10:42:05 +01:00
# Source / Client
2026-01-11 20:13:56 +01:00
source = "Bluesky"
2026-02-01 10:42:05 +01:00
# Viewer state (liked, reposted, etc.)
viewer_indicators = []
viewer = g(actual_post, "viewer") or g(post, "viewer")
if viewer:
if g(viewer, "like"):
viewer_indicators.append("") # Liked
if g(viewer, "repost"):
viewer_indicators.append("🔁") # Reposted
# Add viewer indicators to the source column or create a prefix for text
if viewer_indicators:
indicator_str = " ".join(viewer_indicators)
# Add to beginning of text for visibility
text = f"{indicator_str} {text}"
2026-01-11 20:13:56 +01:00
return [user_str, text, ts_str, source]
2026-02-01 10:42:05 +01:00
def compose_notification(notification, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky notification into a list of strings for display.
Args:
notification: ATProto notification object
db: Session database dict
settings: Session settings
relative_times: If True, use relative time formatting
show_screen_names: If True, show only @handle
safe: If True, handle exceptions gracefully
Returns:
List of strings: [User, Action/Text, Date]
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Author of the notification (who performed the action)
author = g(notification, "author", {})
handle = g(author, "handle", "unknown")
display_name = g(author, "displayName") or g(author, "display_name") or handle
if show_screen_names:
user_str = f"@{handle}"
else:
user_str = f"{display_name} (@{handle})"
# Notification reason/type
reason = g(notification, "reason", "unknown")
# Map reason to user-readable text
reason_text_map = {
"like": _("liked your post"),
"repost": _("reposted your post"),
"follow": _("followed you"),
"mention": _("mentioned you"),
"reply": _("replied to you"),
"quote": _("quoted your post"),
"starterpack-joined": _("joined your starter pack"),
}
action_text = reason_text_map.get(reason, reason)
# For mentions/replies/quotes, include snippet of the text
record = g(notification, "record", {})
post_text = g(record, "text", "")
if post_text and reason in ["mention", "reply", "quote"]:
snippet = post_text[:100] + "..." if len(post_text) > 100 else post_text
action_text = f"{action_text}: {snippet}"
# Date
indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "")
ts_str = ""
if indexed_at:
try:
ts = arrow.get(indexed_at)
if relative_times:
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
ts_str = str(indexed_at)[:16].replace("T", " ")
return [user_str, action_text, ts_str]
2026-01-11 20:13:56 +01:00
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
"""
2026-02-01 10:42:05 +01:00
Compose a Bluesky user profile for list display.
Args:
user: User profile dict or ATProto model
db: Session database dict
settings: Session settings
relative_times: If True, use relative time formatting
show_screen_names: If True, show only @handle
safe: If True, handle exceptions gracefully
Returns:
List of strings: [User summary]
2026-01-11 20:13:56 +01:00
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(user, "handle", "unknown")
display_name = g(user, "displayName") or g(user, "display_name") or handle
followers = g(user, "followersCount", None)
following = g(user, "followsCount", None)
posts = g(user, "postsCount", None)
created_at = g(user, "createdAt", None)
ts = ""
if created_at:
try:
original_date = arrow.get(created_at)
if relative_times:
ts = original_date.humanize(locale=languageHandler.curLang[:2])
else:
offset = db.get("utc_offset", 0) if isinstance(db, dict) else 0
ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
ts = str(created_at)
parts = [f"{display_name} (@{handle})."]
if followers is not None and following is not None and posts is not None:
parts.append(_("{followers} followers, {following} following, {posts} posts.").format(
followers=followers, following=following, posts=posts
))
if ts:
parts.append(_("Joined {date}").format(date=ts))
return [" ".join(parts).strip()]
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky chat conversation for list display.
2026-02-01 10:42:05 +01:00
Args:
convo: Conversation dict or ATProto model
db: Session database dict
settings: Session settings
relative_times: If True, use relative time formatting
show_screen_names: If True, show only @handle
safe: If True, handle exceptions gracefully
Returns:
List of strings: [Participants, Last Message, Date]
2026-01-11 20:13:56 +01:00
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
members = g(convo, "members", [])
self_did = db.get("user_id") if isinstance(db, dict) else None
2026-02-01 10:42:05 +01:00
# Get other participants (exclude self)
2026-01-11 20:13:56 +01:00
others = []
for m in members:
did = g(m, "did", None)
if self_did and did == self_did:
continue
label = g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown")
others.append(label)
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
if not others:
others = [g(m, "displayName") or g(m, "display_name") or g(m, "handle", "unknown") for m in members]
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
participants = ", ".join(others)
2026-02-01 10:42:05 +01:00
# Last message
2026-01-11 20:13:56 +01:00
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
last_text = ""
last_sender = ""
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
if last_msg_obj:
last_text = g(last_msg_obj, "text", "")
sender = g(last_msg_obj, "sender", None)
if sender:
last_sender = g(sender, "displayName") or g(sender, "display_name") or g(sender, "handle", "")
2026-02-01 10:42:05 +01:00
# Date
2026-01-11 20:13:56 +01:00
date_str = ""
if last_msg_obj:
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
2026-02-01 10:42:05 +01:00
if sent_at:
try:
ts = arrow.get(sent_at)
if relative_times:
date_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
date_str = str(sent_at)[:16]
2026-01-11 20:13:56 +01:00
if last_sender and last_text:
last_text = _("Last message from {user}: {text}").format(user=last_sender, text=last_text)
elif last_text:
last_text = _("Last message: {text}").format(text=last_text)
return [participants, last_text, date_str]
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
"""
2026-02-01 10:42:05 +01:00
Compose an individual chat message for display.
Args:
msg: Chat message dict or ATProto model
db: Session database dict
settings: Session settings
relative_times: If True, use relative time formatting
show_screen_names: If True, show only @handle
safe: If True, handle exceptions gracefully
Returns:
List of strings: [Sender, Text, Date]
2026-01-11 20:13:56 +01:00
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
sender = g(msg, "sender", {})
handle = g(sender, "displayName") or g(sender, "display_name") or g(sender, "handle", "unknown")
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
text = g(msg, "text", "")
2026-02-01 10:42:05 +01:00
# Date
2026-01-11 20:13:56 +01:00
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
date_str = ""
if sent_at:
try:
ts = arrow.get(sent_at)
if relative_times:
date_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
2026-02-01 10:42:05 +01:00
except Exception:
2026-01-11 20:13:56 +01:00
date_str = str(sent_at)[:16]
2026-02-01 10:42:05 +01:00
2026-01-11 20:13:56 +01:00
return [handle, text, date_str]