@m13v/s4l 1.6.197-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
"""Corner pop-up review cards for draft approval (AppKit / pyobjc).
|
|
2
|
+
|
|
3
|
+
`present_review(drafts, on_decision, on_complete)` shows one small floating panel
|
|
4
|
+
per draft in the top-right corner: thread context, an EDITABLE reply field, a
|
|
5
|
+
counter, and Reject / Approve. `on_decision` fires the INSTANT each card is
|
|
6
|
+
approved/rejected (so an approved draft can post right away), and `on_complete`
|
|
7
|
+
fires once the last card is decided or the window is closed. The whole AppKit
|
|
8
|
+
surface is isolated behind that one function so the menu bar wiring doesn't
|
|
9
|
+
depend on the windowing details.
|
|
10
|
+
|
|
11
|
+
Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
|
|
12
|
+
"edited": bool, "drop_link": bool, "reject_category": str|None,
|
|
13
|
+
"reject_note": str|None, "interactions": [{"type": str, "ts": str}],
|
|
14
|
+
"dwell_ms": int}
|
|
15
|
+
|
|
16
|
+
Approving comes in two strengths: the plain Approve button, and the 😄 button
|
|
17
|
+
right next to it, which approves AND stamps loved=True: the user's "this one
|
|
18
|
+
was a really good pick" signal, which the feedback digest treats as strong
|
|
19
|
+
positive evidence (a plain approve is merely counter-evidence against avoid
|
|
20
|
+
entries).
|
|
21
|
+
|
|
22
|
+
`present_feedback(on_submit)` is the OVERALL-feedback composer: a small
|
|
23
|
+
floating panel with one free-text field, reachable from the card's 💬 button
|
|
24
|
+
and from the menu bar's "Send feedback…" item. It carries guidance not tied to
|
|
25
|
+
any single thread; the menu bar registers a default submit handler via
|
|
26
|
+
`set_feedback_handler` (shipping decision='feedback' review events) so both
|
|
27
|
+
entry points behave identically.
|
|
28
|
+
|
|
29
|
+
Rejecting is a two-step flow: the Reject button swaps the card body for a
|
|
30
|
+
reason picker (three one-tap categories plus Other, with an optional free-text
|
|
31
|
+
note). The category feeds the feedback-digest loop that distills human
|
|
32
|
+
rejections into each project's learned_preferences config block; "Skip" keeps
|
|
33
|
+
the old zero-friction reject (no reason recorded). Link clicks (author profile,
|
|
34
|
+
thread ↗) and per-card dwell time ride along on the decision so the digest can
|
|
35
|
+
infer intent (e.g. profile-checked-then-rejected = author-quality signal) even
|
|
36
|
+
when the reason is skipped.
|
|
37
|
+
|
|
38
|
+
Must be driven on the main thread (the menu bar's rumps timer is on the main
|
|
39
|
+
run loop, so that holds).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import datetime
|
|
43
|
+
import json
|
|
44
|
+
import re
|
|
45
|
+
import time
|
|
46
|
+
|
|
47
|
+
import objc
|
|
48
|
+
from Foundation import (
|
|
49
|
+
NSObject,
|
|
50
|
+
NSMakeRect,
|
|
51
|
+
NSAttributedString,
|
|
52
|
+
NSMutableAttributedString,
|
|
53
|
+
NSURL,
|
|
54
|
+
)
|
|
55
|
+
from AppKit import (
|
|
56
|
+
NSApp,
|
|
57
|
+
NSPanel,
|
|
58
|
+
NSButton,
|
|
59
|
+
NSTextField,
|
|
60
|
+
NSTextView,
|
|
61
|
+
NSScrollView,
|
|
62
|
+
NSScreen,
|
|
63
|
+
NSEvent,
|
|
64
|
+
NSWindowOcclusionStateVisible,
|
|
65
|
+
NSColor,
|
|
66
|
+
NSFont,
|
|
67
|
+
NSView,
|
|
68
|
+
NSWorkspace,
|
|
69
|
+
NSBackingStoreBuffered,
|
|
70
|
+
NSFloatingWindowLevel,
|
|
71
|
+
NSApplicationActivationPolicyAccessory,
|
|
72
|
+
NSWindowStyleMaskTitled,
|
|
73
|
+
NSWindowStyleMaskClosable,
|
|
74
|
+
NSWindowStyleMaskUtilityWindow,
|
|
75
|
+
NSLineBreakByWordWrapping,
|
|
76
|
+
NSBezelStyleRounded,
|
|
77
|
+
NSFontAttributeName,
|
|
78
|
+
NSForegroundColorAttributeName,
|
|
79
|
+
NSUnderlineStyleAttributeName,
|
|
80
|
+
NSUnderlineStyleSingle,
|
|
81
|
+
NSLinkAttributeName,
|
|
82
|
+
NSTextAlignmentLeft,
|
|
83
|
+
NSTextAlignmentRight,
|
|
84
|
+
NSImage,
|
|
85
|
+
NSPopover,
|
|
86
|
+
NSPopoverBehaviorApplicationDefined,
|
|
87
|
+
NSViewController,
|
|
88
|
+
NSTrackingArea,
|
|
89
|
+
NSTrackingMouseEnteredAndExited,
|
|
90
|
+
NSTrackingActiveAlways,
|
|
91
|
+
NSEventModifierFlagCommand,
|
|
92
|
+
NSEventModifierFlagShift,
|
|
93
|
+
NSEventModifierFlagDeviceIndependentFlagsMask,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Strong reference to the live controller so pyobjc doesn't GC it mid-review
|
|
97
|
+
# (the classic "button click crashes" footgun).
|
|
98
|
+
_active = None
|
|
99
|
+
|
|
100
|
+
W = 380
|
|
101
|
+
H = 300
|
|
102
|
+
M = 16
|
|
103
|
+
NS_BEZEL_BORDER = 2 # NSBezelBorder
|
|
104
|
+
|
|
105
|
+
# Reject-reason categories, in display order. Tags are 1-based button tags on
|
|
106
|
+
# the reason picker; values must match the review_events.reject_category CHECK
|
|
107
|
+
# constraint server-side (wrong_author | off_topic | bad_draft | other).
|
|
108
|
+
REJECT_REASONS = (
|
|
109
|
+
("wrong_author", "Wrong author / audience"),
|
|
110
|
+
("off_topic", "Off-topic thread"),
|
|
111
|
+
("bad_draft", "Draft doesn't sound right"),
|
|
112
|
+
("other", "Other"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
116
|
+
MAX_INTERACTIONS = 50
|
|
117
|
+
|
|
118
|
+
# Review-surface state mirrored to the state dir for out-of-process observers
|
|
119
|
+
# (the menu bar watchdog, the dashboard, a debugging session). In the 2026-07-02
|
|
120
|
+
# incident a card sat unseen for 3 hours and NOTHING on disk could distinguish
|
|
121
|
+
# "cards shown and ignored" from "cards never shown"; this file is that record.
|
|
122
|
+
REVIEW_STATE_FILE = "review-state.json"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _write_review_state(controller=None, last_event=None):
|
|
126
|
+
"""Best-effort snapshot of the review surface to review-state.json. Passing
|
|
127
|
+
the controller explicitly matters during _build, when the module-level
|
|
128
|
+
_active has not been assigned yet."""
|
|
129
|
+
try:
|
|
130
|
+
from pathlib import Path
|
|
131
|
+
|
|
132
|
+
import s4l_state
|
|
133
|
+
|
|
134
|
+
c = controller if controller is not None else _active
|
|
135
|
+
state = {"open": False}
|
|
136
|
+
if c is not None and c._panel is not None:
|
|
137
|
+
state = c.status_dict()
|
|
138
|
+
if last_event:
|
|
139
|
+
state["last_event"] = last_event
|
|
140
|
+
state["updated_at"] = datetime.datetime.now(
|
|
141
|
+
datetime.timezone.utc
|
|
142
|
+
).isoformat()
|
|
143
|
+
p = Path(s4l_state.state_dir()) / REVIEW_STATE_FILE
|
|
144
|
+
p.write_text(json.dumps(state) + "\n")
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _mouse_screen():
|
|
150
|
+
"""The screen the pointer is on right now, i.e. where the user is actually
|
|
151
|
+
looking. Spawning on mainScreen() placed cards on whichever display last
|
|
152
|
+
held key focus, which on a multi-monitor Mac can be a corner the user never
|
|
153
|
+
checks. Falls back to mainScreen when the pointer is between screens."""
|
|
154
|
+
try:
|
|
155
|
+
loc = NSEvent.mouseLocation()
|
|
156
|
+
for s in NSScreen.screens():
|
|
157
|
+
f = s.frame()
|
|
158
|
+
if (
|
|
159
|
+
f.origin.x <= loc.x <= f.origin.x + f.size.width
|
|
160
|
+
and f.origin.y <= loc.y <= f.origin.y + f.size.height
|
|
161
|
+
):
|
|
162
|
+
return s
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
return NSScreen.mainScreen()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _corner_frame(screen):
|
|
169
|
+
"""Top-right corner frame for the card on the given screen."""
|
|
170
|
+
vf = (
|
|
171
|
+
screen.visibleFrame()
|
|
172
|
+
if screen is not None
|
|
173
|
+
else NSMakeRect(0, 0, 1440, 900)
|
|
174
|
+
)
|
|
175
|
+
x = vf.origin.x + vf.size.width - W - M
|
|
176
|
+
y = vf.origin.y + vf.size.height - H - M
|
|
177
|
+
return NSMakeRect(x, y, W, H)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _ReviewPanel(NSPanel):
|
|
181
|
+
"""A status-bar app panel that can actually own text input."""
|
|
182
|
+
|
|
183
|
+
def canBecomeKeyWindow(self):
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def canBecomeMainWindow(self):
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def performKeyEquivalent_(self, event):
|
|
190
|
+
# A rumps (status-bar) app has no Edit menu, so Cmd+V/C/X/A/Z have no
|
|
191
|
+
# menu item to dispatch to and AppKit silently drops them; every text
|
|
192
|
+
# field in this panel (reply editor, reject-reason note) was therefore
|
|
193
|
+
# un-pasteable. Route the standard editing actions down the responder
|
|
194
|
+
# chain ourselves.
|
|
195
|
+
flags = event.modifierFlags() & NSEventModifierFlagDeviceIndependentFlagsMask
|
|
196
|
+
if flags & NSEventModifierFlagCommand:
|
|
197
|
+
key = (event.charactersIgnoringModifiers() or "").lower()
|
|
198
|
+
shift = bool(flags & NSEventModifierFlagShift)
|
|
199
|
+
sel = {
|
|
200
|
+
"v": "paste:",
|
|
201
|
+
"c": "copy:",
|
|
202
|
+
"x": "cut:",
|
|
203
|
+
"a": "selectAll:",
|
|
204
|
+
"z": "redo:" if shift else "undo:",
|
|
205
|
+
}.get(key)
|
|
206
|
+
if sel is not None and NSApp.sendAction_to_from_(sel, None, self):
|
|
207
|
+
return True
|
|
208
|
+
return objc.super(_ReviewPanel, self).performKeyEquivalent_(event)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _log(msg):
|
|
212
|
+
"""Stderr breadcrumb in the menubar.err.log style; card interactions are
|
|
213
|
+
otherwise invisible when debugging a remote box."""
|
|
214
|
+
import sys
|
|
215
|
+
|
|
216
|
+
print(f"[s4l-card] {msg}", file=sys.stderr, flush=True)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _truncate(s, n=320):
|
|
220
|
+
s = (s or "").strip()
|
|
221
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _fmt_count(n):
|
|
225
|
+
"""1234 -> '1.2k', 3400000 -> '3.4M'; None/garbage -> None (omit the stat)."""
|
|
226
|
+
try:
|
|
227
|
+
n = int(n)
|
|
228
|
+
except (TypeError, ValueError):
|
|
229
|
+
return None
|
|
230
|
+
if n >= 1_000_000:
|
|
231
|
+
s = f"{n / 1_000_000:.1f}M"
|
|
232
|
+
return s.replace(".0M", "M")
|
|
233
|
+
if n >= 1_000:
|
|
234
|
+
s = f"{n / 1_000:.1f}k"
|
|
235
|
+
return s.replace(".0k", "k")
|
|
236
|
+
return str(n)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _followers_str(stats):
|
|
240
|
+
"""Author followers (profile stat), shown INLINE next to the handle in the
|
|
241
|
+
author row. None when the pipeline didn't capture it."""
|
|
242
|
+
followers = _fmt_count((stats or {}).get("author_followers"))
|
|
243
|
+
return None if followers is None else f"{followers} followers"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _engagement_line(stats):
|
|
247
|
+
"""The thread's engagement counts, shown ONLY in the eye icon's hover/click
|
|
248
|
+
popover, never inline on the card. Fields the pipeline didn't capture are
|
|
249
|
+
omitted; returns '' when nothing is known (the eye icon is skipped then)."""
|
|
250
|
+
stats = stats or {}
|
|
251
|
+
parts = []
|
|
252
|
+
for key, label in (
|
|
253
|
+
("likes", "likes"),
|
|
254
|
+
("retweets", "reposts"),
|
|
255
|
+
("replies", "replies"),
|
|
256
|
+
("views", "views"),
|
|
257
|
+
):
|
|
258
|
+
v = _fmt_count(stats.get(key))
|
|
259
|
+
if v is not None:
|
|
260
|
+
parts.append(f"{v} {label}")
|
|
261
|
+
return " · ".join(parts)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _age_str(iso):
|
|
265
|
+
"""Thread age since tweet_posted_at, minute-granular for fresh threads
|
|
266
|
+
('38m'); rolls to hours/days only when minutes would be absurd."""
|
|
267
|
+
if not iso:
|
|
268
|
+
return None
|
|
269
|
+
try:
|
|
270
|
+
t = datetime.datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
|
|
271
|
+
if t.tzinfo is None:
|
|
272
|
+
t = t.replace(tzinfo=datetime.timezone.utc)
|
|
273
|
+
mins = int(
|
|
274
|
+
(datetime.datetime.now(datetime.timezone.utc) - t).total_seconds() // 60
|
|
275
|
+
)
|
|
276
|
+
except Exception:
|
|
277
|
+
return None
|
|
278
|
+
mins = max(mins, 0)
|
|
279
|
+
if mins < 100:
|
|
280
|
+
return f"{mins}m"
|
|
281
|
+
hours = mins // 60
|
|
282
|
+
if hours < 48:
|
|
283
|
+
return f"{hours}h"
|
|
284
|
+
return f"{hours // 24}d"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _label(frame, text, *, size=12, bold=False, muted=False):
|
|
288
|
+
f = NSTextField.alloc().initWithFrame_(frame)
|
|
289
|
+
f.setStringValue_(text or "")
|
|
290
|
+
f.setBezeled_(False)
|
|
291
|
+
f.setDrawsBackground_(False)
|
|
292
|
+
f.setEditable_(False)
|
|
293
|
+
f.setSelectable_(False)
|
|
294
|
+
f.setFont_(
|
|
295
|
+
NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
|
|
296
|
+
)
|
|
297
|
+
if muted:
|
|
298
|
+
f.setTextColor_(NSColor.secondaryLabelColor())
|
|
299
|
+
f.setLineBreakMode_(NSLineBreakByWordWrapping)
|
|
300
|
+
try:
|
|
301
|
+
f.cell().setWraps_(True)
|
|
302
|
+
f.cell().setScrollable_(False)
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
return f
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _ReviewController(NSObject):
|
|
309
|
+
def initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
|
|
310
|
+
self = objc.super(_ReviewController, self).init()
|
|
311
|
+
if self is None:
|
|
312
|
+
return None
|
|
313
|
+
self._drafts = list(drafts)
|
|
314
|
+
self._on_decision = on_decision
|
|
315
|
+
self._on_complete = on_complete
|
|
316
|
+
self._idx = 0
|
|
317
|
+
self._decisions = []
|
|
318
|
+
self._panel = None
|
|
319
|
+
self._textview = None
|
|
320
|
+
self._link_targets = {}
|
|
321
|
+
self._eye_btn = None
|
|
322
|
+
self._stats_popover = None
|
|
323
|
+
# Per-card telemetry, reset when a NEW card renders (not on the
|
|
324
|
+
# card <-> reason-picker swap, which is the same card).
|
|
325
|
+
self._rendered_idx = -1
|
|
326
|
+
self._interactions = []
|
|
327
|
+
self._card_shown_at = None
|
|
328
|
+
self._reason_field = None
|
|
329
|
+
# Attention anchors for the unattended-review watchdog: the stack counts
|
|
330
|
+
# as "touched" on present, on any tracked interaction, and on any
|
|
331
|
+
# decision. No touch past the watchdog threshold = the user is not
|
|
332
|
+
# seeing this window, wherever AppKit thinks it is.
|
|
333
|
+
self._presented_at = time.time()
|
|
334
|
+
self._last_decision_at = None
|
|
335
|
+
self._last_interaction_at = None
|
|
336
|
+
self._last_move_log = 0.0
|
|
337
|
+
self._build()
|
|
338
|
+
return self
|
|
339
|
+
|
|
340
|
+
@objc.python_method
|
|
341
|
+
def _track(self, kind):
|
|
342
|
+
"""Append one interaction breadcrumb for the CURRENT card. Rides on the
|
|
343
|
+
decision dict so the feedback loop can correlate behavior (opened the
|
|
344
|
+
author profile, read the thread) with the eventual approve/reject."""
|
|
345
|
+
self._last_interaction_at = time.time()
|
|
346
|
+
if len(self._interactions) >= MAX_INTERACTIONS:
|
|
347
|
+
return
|
|
348
|
+
self._interactions.append(
|
|
349
|
+
{
|
|
350
|
+
"type": kind,
|
|
351
|
+
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
_log(f"interaction {kind} (card {self._idx + 1})")
|
|
355
|
+
|
|
356
|
+
@objc.python_method
|
|
357
|
+
def _dwell_ms(self):
|
|
358
|
+
if not self._card_shown_at:
|
|
359
|
+
return None
|
|
360
|
+
return int((time.time() - self._card_shown_at) * 1000)
|
|
361
|
+
|
|
362
|
+
@objc.python_method
|
|
363
|
+
def _occlusion_visible(self):
|
|
364
|
+
"""True when macOS is drawing at least one pixel of the card (not fully
|
|
365
|
+
covered, not on an inactive Space, not minimized). None if unknowable."""
|
|
366
|
+
try:
|
|
367
|
+
return bool(
|
|
368
|
+
self._panel.occlusionState() & NSWindowOcclusionStateVisible
|
|
369
|
+
)
|
|
370
|
+
except Exception:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
@objc.python_method
|
|
374
|
+
def status_dict(self):
|
|
375
|
+
"""Snapshot for the watchdog and review-state.json: is a card open, how
|
|
376
|
+
many drafts are undecided, when it was last touched, where it is, and
|
|
377
|
+
whether the user could physically see it."""
|
|
378
|
+
frame = None
|
|
379
|
+
try:
|
|
380
|
+
fr = self._panel.frame()
|
|
381
|
+
frame = [
|
|
382
|
+
int(fr.origin.x),
|
|
383
|
+
int(fr.origin.y),
|
|
384
|
+
int(fr.size.width),
|
|
385
|
+
int(fr.size.height),
|
|
386
|
+
]
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
screen_name = None
|
|
390
|
+
try:
|
|
391
|
+
scr = self._panel.screen()
|
|
392
|
+
if scr is not None:
|
|
393
|
+
screen_name = str(scr.localizedName())
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
return {
|
|
397
|
+
"open": self._panel is not None,
|
|
398
|
+
"total": len(self._drafts),
|
|
399
|
+
"pending": max(0, len(self._drafts) - self._idx),
|
|
400
|
+
"decided": len(self._decisions),
|
|
401
|
+
"presented_at": self._presented_at,
|
|
402
|
+
"last_decision_at": self._last_decision_at,
|
|
403
|
+
"last_interaction_at": self._last_interaction_at,
|
|
404
|
+
"occlusion_visible": self._occlusion_visible(),
|
|
405
|
+
"frame": frame,
|
|
406
|
+
"screen": screen_name,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
@objc.python_method
|
|
410
|
+
def _log_surface(self, event):
|
|
411
|
+
"""One stderr line + a review-state.json refresh per surface event
|
|
412
|
+
(presented / moved / occlusion_changed / extended / decision / healed).
|
|
413
|
+
This is the positive confirmation layer: silence in the log used to be
|
|
414
|
+
ambiguous between "being reviewed" and "invisible for hours"."""
|
|
415
|
+
s = self.status_dict()
|
|
416
|
+
_log(
|
|
417
|
+
f"{event}: {s['pending']} pending of {s['total']}, "
|
|
418
|
+
f"frame={s['frame']} screen={s['screen']} "
|
|
419
|
+
f"visible={s['occlusion_visible']}"
|
|
420
|
+
)
|
|
421
|
+
_write_review_state(controller=self, last_event=event)
|
|
422
|
+
|
|
423
|
+
def windowDidMove_(self, notification):
|
|
424
|
+
# Timestamped move history. The unanswerable question of the 2026-07-02
|
|
425
|
+
# incident (how did the card end up at the bottom edge of a side
|
|
426
|
+
# display) becomes greppable. Drags fire this repeatedly; throttle.
|
|
427
|
+
now = time.time()
|
|
428
|
+
if now - self._last_move_log < 1.0:
|
|
429
|
+
return
|
|
430
|
+
self._last_move_log = now
|
|
431
|
+
self._log_surface("moved")
|
|
432
|
+
|
|
433
|
+
def windowDidChangeOcclusionState_(self, notification):
|
|
434
|
+
self._log_surface("occlusion_changed")
|
|
435
|
+
|
|
436
|
+
@objc.python_method
|
|
437
|
+
def _build(self):
|
|
438
|
+
frame = _corner_frame(_mouse_screen())
|
|
439
|
+
style = (
|
|
440
|
+
NSWindowStyleMaskTitled
|
|
441
|
+
| NSWindowStyleMaskClosable
|
|
442
|
+
| NSWindowStyleMaskUtilityWindow
|
|
443
|
+
)
|
|
444
|
+
panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
445
|
+
frame, style, NSBackingStoreBuffered, False
|
|
446
|
+
)
|
|
447
|
+
panel.setLevel_(NSFloatingWindowLevel)
|
|
448
|
+
panel.setFloatingPanel_(True)
|
|
449
|
+
panel.setBecomesKeyOnlyIfNeeded_(False) # let the reply field be edited
|
|
450
|
+
panel.setHidesOnDeactivate_(False)
|
|
451
|
+
panel.setReleasedWhenClosed_(False)
|
|
452
|
+
panel.setDelegate_(self)
|
|
453
|
+
self._panel = panel
|
|
454
|
+
self._render()
|
|
455
|
+
panel.makeKeyAndOrderFront_(None)
|
|
456
|
+
panel.orderFrontRegardless()
|
|
457
|
+
self._log_surface("presented")
|
|
458
|
+
self.focusReply_(None)
|
|
459
|
+
# App activation/key-window promotion lands on the run loop; do one
|
|
460
|
+
# deferred pass so the first card is editable even when opened from the
|
|
461
|
+
# status-bar timer while another app is frontmost.
|
|
462
|
+
self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
|
|
463
|
+
|
|
464
|
+
def focusReply_(self, sender):
|
|
465
|
+
panel = self._panel
|
|
466
|
+
tv = self._textview
|
|
467
|
+
if panel is None or tv is None:
|
|
468
|
+
return
|
|
469
|
+
# A launchd/rumps status item can show a window while remaining a
|
|
470
|
+
# non-activating process. Accessory policy keeps it out of the Dock but
|
|
471
|
+
# lets the review panel become the key/main recipient for NSTextView input.
|
|
472
|
+
try:
|
|
473
|
+
NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
try:
|
|
477
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
try:
|
|
481
|
+
panel.makeKeyAndOrderFront_(None)
|
|
482
|
+
panel.orderFrontRegardless()
|
|
483
|
+
panel.makeMainWindow()
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
try:
|
|
487
|
+
tv.setEditable_(True)
|
|
488
|
+
tv.setSelectable_(True)
|
|
489
|
+
panel.makeFirstResponder_(tv)
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
@objc.python_method
|
|
494
|
+
def _render(self):
|
|
495
|
+
d = self._drafts[self._idx]
|
|
496
|
+
# Fresh card (not a card <-> reason-picker swap): reset telemetry.
|
|
497
|
+
if self._rendered_idx != self._idx:
|
|
498
|
+
self._rendered_idx = self._idx
|
|
499
|
+
self._interactions = []
|
|
500
|
+
self._card_shown_at = time.time()
|
|
501
|
+
self._reason_field = None
|
|
502
|
+
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
|
|
503
|
+
|
|
504
|
+
# Buttons at the TOP, one line: Approve, 😄 (approve + loved), then a
|
|
505
|
+
# small 💬 (overall feedback, decides nothing), Reject at the right.
|
|
506
|
+
approve = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 96, 30))
|
|
507
|
+
approve.setTitle_("Approve")
|
|
508
|
+
approve.setBezelStyle_(NSBezelStyleRounded)
|
|
509
|
+
approve.setTarget_(self)
|
|
510
|
+
approve.setAction_("approve:")
|
|
511
|
+
content.addSubview_(approve)
|
|
512
|
+
|
|
513
|
+
# 😄 = approve with the "really good one" signal (loved=True on the
|
|
514
|
+
# decision). Same posting path as Approve; only the feedback rail sees
|
|
515
|
+
# the difference.
|
|
516
|
+
smile = NSButton.alloc().initWithFrame_(NSMakeRect(M + 100, H - 42, 44, 30))
|
|
517
|
+
smile.setTitle_("😄")
|
|
518
|
+
smile.setBezelStyle_(NSBezelStyleRounded)
|
|
519
|
+
smile.setTarget_(self)
|
|
520
|
+
smile.setAction_("approveLoved:")
|
|
521
|
+
try:
|
|
522
|
+
smile.setToolTip_("Approve and mark as a really good pick")
|
|
523
|
+
except Exception:
|
|
524
|
+
pass
|
|
525
|
+
content.addSubview_(smile)
|
|
526
|
+
|
|
527
|
+
# 💬 = overall feedback (about the pipeline, not this draft). Opens the
|
|
528
|
+
# composer panel next to the card; the card itself stays undecided.
|
|
529
|
+
fb = NSButton.alloc().initWithFrame_(NSMakeRect(M + 184, H - 41, 30, 28))
|
|
530
|
+
fb.setTitle_("💬")
|
|
531
|
+
fb.setBordered_(False)
|
|
532
|
+
fb.setTarget_(self)
|
|
533
|
+
fb.setAction_("feedbackOpen:")
|
|
534
|
+
try:
|
|
535
|
+
fb.setToolTip_("Send overall feedback (not about this draft)")
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
content.addSubview_(fb)
|
|
539
|
+
|
|
540
|
+
reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 96, H - 42, 96, 30))
|
|
541
|
+
reject.setTitle_("Reject")
|
|
542
|
+
reject.setBezelStyle_(NSBezelStyleRounded)
|
|
543
|
+
reject.setTarget_(self)
|
|
544
|
+
reject.setAction_("reject:")
|
|
545
|
+
content.addSubview_(reject)
|
|
546
|
+
|
|
547
|
+
# "Replying to @author" row: the handle is a live link to the author's
|
|
548
|
+
# profile, with the follower count muted inline right after it. Right
|
|
549
|
+
# side: thread age (muted, minutes) and an eye icon whose hover/click
|
|
550
|
+
# popover carries the thread's engagement counts — those are never
|
|
551
|
+
# inline on the card. All from data the pipeline already carries; no
|
|
552
|
+
# scraping happens here.
|
|
553
|
+
self._link_targets = {}
|
|
554
|
+
handle = (d.get("thread_author") or "").lstrip("@").strip()
|
|
555
|
+
stats = d.get("stats") or {}
|
|
556
|
+
thread_url = d.get("thread_url")
|
|
557
|
+
content.addSubview_(
|
|
558
|
+
_label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True)
|
|
559
|
+
)
|
|
560
|
+
right_x = W - M
|
|
561
|
+
self._close_stats_popover()
|
|
562
|
+
self._eye_btn = None
|
|
563
|
+
if _engagement_line(stats):
|
|
564
|
+
eye = NSButton.alloc().initWithFrame_(NSMakeRect(right_x - 20, H - 70, 20, 18))
|
|
565
|
+
eye.setBordered_(False)
|
|
566
|
+
img = NSImage.imageWithSystemSymbolName_accessibilityDescription_(
|
|
567
|
+
"eye", "thread stats"
|
|
568
|
+
)
|
|
569
|
+
if img is not None:
|
|
570
|
+
eye.setImage_(img)
|
|
571
|
+
eye.setTitle_("")
|
|
572
|
+
else: # pre-Big Sur fallback: no SF Symbols
|
|
573
|
+
eye.setTitle_("👁")
|
|
574
|
+
# Stats surface in an NSPopover on hover OR click. A plain toolTip
|
|
575
|
+
# was tried first and never fired: this panel belongs to a
|
|
576
|
+
# non-activating accessory (status bar) app, where AppKit's tooltip
|
|
577
|
+
# machinery is unreliable. The tracking area drives hover; the
|
|
578
|
+
# button action covers click and any hover-tracking edge case.
|
|
579
|
+
eye.setTarget_(self)
|
|
580
|
+
eye.setAction_("statsToggle:")
|
|
581
|
+
eye.addTrackingArea_(
|
|
582
|
+
NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
|
|
583
|
+
eye.bounds(),
|
|
584
|
+
NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
|
|
585
|
+
self,
|
|
586
|
+
None,
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
content.addSubview_(eye)
|
|
590
|
+
self._eye_btn = eye
|
|
591
|
+
right_x -= 24
|
|
592
|
+
age = _age_str(stats.get("tweet_posted_at"))
|
|
593
|
+
if age:
|
|
594
|
+
age_w = int(
|
|
595
|
+
NSAttributedString.alloc().initWithString_attributes_(
|
|
596
|
+
age, {NSFontAttributeName: NSFont.systemFontOfSize_(11)}
|
|
597
|
+
).size().width
|
|
598
|
+
) + 8
|
|
599
|
+
age_label = _label(
|
|
600
|
+
NSMakeRect(right_x - age_w, H - 70, age_w, 18), age, size=11, muted=True
|
|
601
|
+
)
|
|
602
|
+
age_label.setAlignment_(NSTextAlignmentRight)
|
|
603
|
+
content.addSubview_(age_label)
|
|
604
|
+
right_x -= age_w + 4
|
|
605
|
+
handle_w = right_x - (M + 78) - 4
|
|
606
|
+
if handle:
|
|
607
|
+
# Size the link to its text so the follower count can sit right
|
|
608
|
+
# after the handle instead of at a fixed column.
|
|
609
|
+
text = f"@{handle}"
|
|
610
|
+
measured = NSAttributedString.alloc().initWithString_attributes_(
|
|
611
|
+
text, {NSFontAttributeName: NSFont.boldSystemFontOfSize_(12)}
|
|
612
|
+
).size().width
|
|
613
|
+
link_w = min(int(measured) + 8, handle_w)
|
|
614
|
+
self._add_link(
|
|
615
|
+
content,
|
|
616
|
+
NSMakeRect(M + 78, H - 70, link_w, 18),
|
|
617
|
+
text,
|
|
618
|
+
f"https://x.com/{handle}",
|
|
619
|
+
bold=True,
|
|
620
|
+
kind="profile_click",
|
|
621
|
+
)
|
|
622
|
+
followers = _followers_str(stats)
|
|
623
|
+
fol_w = handle_w - link_w
|
|
624
|
+
if followers and fol_w > 20:
|
|
625
|
+
content.addSubview_(
|
|
626
|
+
_label(
|
|
627
|
+
NSMakeRect(M + 78 + link_w, H - 70, fol_w, 18),
|
|
628
|
+
f"· {followers}",
|
|
629
|
+
size=11,
|
|
630
|
+
muted=True,
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
else:
|
|
634
|
+
content.addSubview_(
|
|
635
|
+
_label(NSMakeRect(M + 78, H - 70, handle_w, 18), "thread", size=12, bold=True)
|
|
636
|
+
)
|
|
637
|
+
# Thread text — black, with a small trailing ↗ link that opens the
|
|
638
|
+
# thread (an NSTextView because NSTextField can't do clickable links).
|
|
639
|
+
thread_tv = NSTextView.alloc().initWithFrame_(
|
|
640
|
+
NSMakeRect(M, H - 150, W - 2 * M, 74)
|
|
641
|
+
)
|
|
642
|
+
thread_tv.setEditable_(False)
|
|
643
|
+
thread_tv.setSelectable_(True) # links only respond when selectable
|
|
644
|
+
thread_tv.setDrawsBackground_(False)
|
|
645
|
+
# An NSTextView grows vertically by default; long threads inflated the
|
|
646
|
+
# frame over the author row above (non-flipped superview: growth goes
|
|
647
|
+
# UP) and pushed the trailing ↗ out of the box. Pin the frame and
|
|
648
|
+
# truncate to what 4 lines actually fit so the arrow stays visible.
|
|
649
|
+
thread_tv.setVerticallyResizable_(False)
|
|
650
|
+
thread_tv.setHorizontallyResizable_(False)
|
|
651
|
+
body = NSMutableAttributedString.alloc().initWithString_attributes_(
|
|
652
|
+
_truncate(d.get("thread_text"), 200),
|
|
653
|
+
{
|
|
654
|
+
NSFontAttributeName: NSFont.systemFontOfSize_(12),
|
|
655
|
+
NSForegroundColorAttributeName: NSColor.labelColor(),
|
|
656
|
+
},
|
|
657
|
+
)
|
|
658
|
+
if thread_url:
|
|
659
|
+
body.appendAttributedString_(
|
|
660
|
+
NSAttributedString.alloc().initWithString_attributes_(
|
|
661
|
+
" ↗",
|
|
662
|
+
{
|
|
663
|
+
NSFontAttributeName: NSFont.systemFontOfSize_(12),
|
|
664
|
+
# Delegate (textView:clickedOnLink:atIndex:) tracks the
|
|
665
|
+
# click as a thread_click interaction, then opens the
|
|
666
|
+
# URL itself via NSWorkspace.
|
|
667
|
+
NSLinkAttributeName: NSURL.URLWithString_(thread_url),
|
|
668
|
+
},
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
thread_tv.setDelegate_(self)
|
|
672
|
+
thread_tv.textStorage().setAttributedString_(body)
|
|
673
|
+
content.addSubview_(thread_tv)
|
|
674
|
+
# Reply heading — black.
|
|
675
|
+
content.addSubview_(
|
|
676
|
+
_label(
|
|
677
|
+
NSMakeRect(M, H - 172, W - 2 * M, 16),
|
|
678
|
+
"Reply (editable):",
|
|
679
|
+
size=12,
|
|
680
|
+
bold=True,
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
# Editable reply, with the attached link folded in as it'll post (no
|
|
685
|
+
# separate link field). The trailing link is stripped on send so the
|
|
686
|
+
# pipeline still mints the tracked /r/<code> short link (no double link).
|
|
687
|
+
reply = d.get("reply_text") or ""
|
|
688
|
+
link = d.get("link_url")
|
|
689
|
+
composed = f"{reply} {link}" if link else reply
|
|
690
|
+
scroll = NSScrollView.alloc().initWithFrame_(
|
|
691
|
+
NSMakeRect(M, M, W - 2 * M, H - 172 - M - 6)
|
|
692
|
+
)
|
|
693
|
+
scroll.setHasVerticalScroller_(True)
|
|
694
|
+
scroll.setBorderType_(NS_BEZEL_BORDER)
|
|
695
|
+
tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, W - 2 * M, 100))
|
|
696
|
+
tv.setFont_(NSFont.systemFontOfSize_(12))
|
|
697
|
+
tv.setRichText_(False)
|
|
698
|
+
tv.setEditable_(True)
|
|
699
|
+
tv.setSelectable_(True)
|
|
700
|
+
tv.setString_(composed)
|
|
701
|
+
scroll.setDocumentView_(tv)
|
|
702
|
+
content.addSubview_(scroll)
|
|
703
|
+
self._textview = tv
|
|
704
|
+
|
|
705
|
+
self._panel.setContentView_(content)
|
|
706
|
+
# Counter lives in the native title bar, not inside the content.
|
|
707
|
+
self._panel.setTitle_(f"Review draft {self._idx + 1} of {len(self._drafts)}")
|
|
708
|
+
# setContentView_ rebuilds the view tree, so the caret would otherwise
|
|
709
|
+
# default to the Approve button. Re-seat it in the reply field for every
|
|
710
|
+
# card (not just the first) so each one is immediately editable.
|
|
711
|
+
self._panel.makeFirstResponder_(tv)
|
|
712
|
+
self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
|
|
713
|
+
|
|
714
|
+
@objc.python_method
|
|
715
|
+
def _close_stats_popover(self):
|
|
716
|
+
try:
|
|
717
|
+
if self._stats_popover is not None and self._stats_popover.isShown():
|
|
718
|
+
self._stats_popover.close()
|
|
719
|
+
_log("stats popover closed")
|
|
720
|
+
except Exception:
|
|
721
|
+
pass
|
|
722
|
+
self._stats_popover = None
|
|
723
|
+
|
|
724
|
+
@objc.python_method
|
|
725
|
+
def _show_stats_popover(self):
|
|
726
|
+
if self._eye_btn is None:
|
|
727
|
+
return
|
|
728
|
+
if self._stats_popover is not None and self._stats_popover.isShown():
|
|
729
|
+
return
|
|
730
|
+
line = _engagement_line(self._drafts[self._idx].get("stats"))
|
|
731
|
+
if not line:
|
|
732
|
+
return
|
|
733
|
+
font = NSFont.systemFontOfSize_(12)
|
|
734
|
+
s = NSAttributedString.alloc().initWithString_attributes_(
|
|
735
|
+
line, {NSFontAttributeName: font}
|
|
736
|
+
)
|
|
737
|
+
# +34: 13px side insets plus NSTextField's own ~4px internal padding,
|
|
738
|
+
# which otherwise clips the last word.
|
|
739
|
+
pw, ph = int(s.size().width) + 34, 34
|
|
740
|
+
view = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, pw, ph))
|
|
741
|
+
view.addSubview_(_label(NSMakeRect(13, ph - 26, pw - 26, 18), line, size=12))
|
|
742
|
+
vc = NSViewController.alloc().init()
|
|
743
|
+
vc.setView_(view)
|
|
744
|
+
pop = NSPopover.alloc().init()
|
|
745
|
+
# ApplicationDefined, NOT Transient: a transient popover auto-closes
|
|
746
|
+
# whenever the owning app is inactive, and this accessory (status bar)
|
|
747
|
+
# app usually is — on the box the popover opened and dismissed within
|
|
748
|
+
# the same click. We own every close path instead (hover-out, click
|
|
749
|
+
# toggle, card advance, window close).
|
|
750
|
+
pop.setBehavior_(NSPopoverBehaviorApplicationDefined)
|
|
751
|
+
pop.setContentViewController_(vc)
|
|
752
|
+
pop.setContentSize_((pw, ph))
|
|
753
|
+
try:
|
|
754
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
755
|
+
except Exception:
|
|
756
|
+
pass
|
|
757
|
+
# Anchor to the eye's frame in the (non-flipped) content view, where
|
|
758
|
+
# NSRectEdge 1 = NSMinYEdge is unambiguously the BOTTOM edge, so the
|
|
759
|
+
# popover reliably opens below the icon. Anchoring to the button's own
|
|
760
|
+
# bounds flips the edge meaning (NSButton is a flipped view) and the
|
|
761
|
+
# popover appeared above the card's title bar.
|
|
762
|
+
pop.showRelativeToRect_ofView_preferredEdge_(
|
|
763
|
+
self._eye_btn.frame(), self._eye_btn.superview(), 1
|
|
764
|
+
)
|
|
765
|
+
self._stats_popover = pop
|
|
766
|
+
_log(f"stats popover shown ({pw}x{ph})")
|
|
767
|
+
|
|
768
|
+
# Click on the eye SHOWS the popover, never toggles it closed: a click is
|
|
769
|
+
# physically preceded by hover (mouseEntered already opened it), so a
|
|
770
|
+
# toggle would close what the hover just opened and the user sees nothing.
|
|
771
|
+
# Closing is owned by hover-out, card advance, and window close.
|
|
772
|
+
def statsToggle_(self, sender):
|
|
773
|
+
_log("eye clicked")
|
|
774
|
+
self._track("stats_open")
|
|
775
|
+
self._show_stats_popover()
|
|
776
|
+
|
|
777
|
+
# NSTrackingArea owner callbacks (hover over the eye icon).
|
|
778
|
+
def mouseEntered_(self, event):
|
|
779
|
+
_log("eye hover enter")
|
|
780
|
+
self._show_stats_popover()
|
|
781
|
+
|
|
782
|
+
def mouseExited_(self, event):
|
|
783
|
+
_log("eye hover exit")
|
|
784
|
+
self._close_stats_popover()
|
|
785
|
+
|
|
786
|
+
@objc.python_method
|
|
787
|
+
def _add_link(self, content, frame, text, url, *, size=12, bold=False, right=False, kind="link_click"):
|
|
788
|
+
"""Borderless button styled as a link (system link color, underlined).
|
|
789
|
+
The URL and interaction kind ride on the button's integer tag via
|
|
790
|
+
_link_targets so one openLink: selector serves every link on the card."""
|
|
791
|
+
btn = NSButton.alloc().initWithFrame_(frame)
|
|
792
|
+
btn.setBordered_(False)
|
|
793
|
+
font = NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
|
|
794
|
+
attrs = {
|
|
795
|
+
NSFontAttributeName: font,
|
|
796
|
+
NSForegroundColorAttributeName: NSColor.linkColor(),
|
|
797
|
+
NSUnderlineStyleAttributeName: NSUnderlineStyleSingle,
|
|
798
|
+
}
|
|
799
|
+
btn.setAttributedTitle_(
|
|
800
|
+
NSAttributedString.alloc().initWithString_attributes_(text, attrs)
|
|
801
|
+
)
|
|
802
|
+
btn.setAlignment_(NSTextAlignmentRight if right else NSTextAlignmentLeft)
|
|
803
|
+
tag = len(self._link_targets) + 1
|
|
804
|
+
btn.setTag_(tag)
|
|
805
|
+
self._link_targets[tag] = (str(url), kind)
|
|
806
|
+
btn.setTarget_(self)
|
|
807
|
+
btn.setAction_("openLink:")
|
|
808
|
+
content.addSubview_(btn)
|
|
809
|
+
return btn
|
|
810
|
+
|
|
811
|
+
def openLink_(self, sender):
|
|
812
|
+
try:
|
|
813
|
+
target = self._link_targets.get(sender.tag())
|
|
814
|
+
if target:
|
|
815
|
+
url, kind = target
|
|
816
|
+
self._track(kind)
|
|
817
|
+
NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
|
|
818
|
+
except Exception:
|
|
819
|
+
pass
|
|
820
|
+
|
|
821
|
+
# NSTextView delegate: the thread ↗ link. Track, then open ourselves
|
|
822
|
+
# (returning True suppresses the default handler).
|
|
823
|
+
def textView_clickedOnLink_atIndex_(self, textview, link, charIndex):
|
|
824
|
+
self._track("thread_click")
|
|
825
|
+
try:
|
|
826
|
+
url = link if hasattr(link, "absoluteString") else NSURL.URLWithString_(str(link))
|
|
827
|
+
NSWorkspace.sharedWorkspace().openURL_(url)
|
|
828
|
+
except Exception:
|
|
829
|
+
pass
|
|
830
|
+
return True
|
|
831
|
+
|
|
832
|
+
@objc.python_method
|
|
833
|
+
def _current_text(self):
|
|
834
|
+
try:
|
|
835
|
+
return str(self._textview.string())
|
|
836
|
+
except Exception:
|
|
837
|
+
return self._drafts[self._idx].get("reply_text") or ""
|
|
838
|
+
|
|
839
|
+
@objc.python_method
|
|
840
|
+
def _record(self, approved, reject_category=None, reject_note=None, loved=False):
|
|
841
|
+
d = self._drafts[self._idx]
|
|
842
|
+
orig = (d.get("reply_text") or "").strip()
|
|
843
|
+
link = d.get("link_url") or ""
|
|
844
|
+
drop_link = False
|
|
845
|
+
if approved:
|
|
846
|
+
text = self._current_text()
|
|
847
|
+
# The card folds link_url into the editable field (see _render). On
|
|
848
|
+
# send we must reconcile what the user did with that link:
|
|
849
|
+
# - link still present anywhere -> remove ALL occurrences so the
|
|
850
|
+
# poster mints the single tracked /r/<code> short link (no double
|
|
851
|
+
# link, no bare URL). Generalizes the old endswith() strip, which
|
|
852
|
+
# missed the link whenever the user typed anything after it.
|
|
853
|
+
# - link gone (user deleted it while editing) -> drop_link=True so
|
|
854
|
+
# the poster does NOT re-append it. Without this signal the post
|
|
855
|
+
# pipeline's forced TWITTER_TAIL_LINK_RATE=1.0 silently revived a
|
|
856
|
+
# link the user intentionally removed.
|
|
857
|
+
if link:
|
|
858
|
+
if link in text:
|
|
859
|
+
text = text.replace(link, "")
|
|
860
|
+
else:
|
|
861
|
+
drop_link = True
|
|
862
|
+
# Collapse only horizontal whitespace left by the removal; preserve
|
|
863
|
+
# any newlines the user intended.
|
|
864
|
+
body = re.sub(r"[ \t]{2,}", " ", text).strip()
|
|
865
|
+
else:
|
|
866
|
+
body = orig
|
|
867
|
+
self._decisions.append(
|
|
868
|
+
{
|
|
869
|
+
"n": d["n"],
|
|
870
|
+
"approved": bool(approved),
|
|
871
|
+
"loved": bool(approved and loved),
|
|
872
|
+
"text": body,
|
|
873
|
+
"edited": bool(approved and body != orig),
|
|
874
|
+
"drop_link": bool(approved and drop_link),
|
|
875
|
+
"reject_category": reject_category,
|
|
876
|
+
"reject_note": (reject_note or "").strip() or None,
|
|
877
|
+
"interactions": list(self._interactions),
|
|
878
|
+
"dwell_ms": self._dwell_ms(),
|
|
879
|
+
# Ride-along candidate context (from review_drafts) so the
|
|
880
|
+
# decision can be shipped to /api/v1/review-events without
|
|
881
|
+
# re-reading the plan.
|
|
882
|
+
"candidate_id": d.get("candidate_id"),
|
|
883
|
+
"project": d.get("project"),
|
|
884
|
+
"thread_url": d.get("thread_url"),
|
|
885
|
+
"thread_author": d.get("thread_author"),
|
|
886
|
+
}
|
|
887
|
+
)
|
|
888
|
+
self._last_decision_at = time.time()
|
|
889
|
+
self._log_surface("decision")
|
|
890
|
+
|
|
891
|
+
@objc.python_method
|
|
892
|
+
def _advance(self):
|
|
893
|
+
self._idx += 1
|
|
894
|
+
if self._idx >= len(self._drafts):
|
|
895
|
+
self._finish()
|
|
896
|
+
else:
|
|
897
|
+
self._render()
|
|
898
|
+
|
|
899
|
+
@objc.python_method
|
|
900
|
+
def extend_drafts(self, drafts):
|
|
901
|
+
"""Append newly-queued drafts to an OPEN card. Without this, a card built
|
|
902
|
+
when N drafts were pending froze at "of N": every draft that arrived after
|
|
903
|
+
the card opened was stranded behind it (the menu bar bailed while a panel
|
|
904
|
+
was up). Dedups by plan index `n`, never disturbs the card on screen, and
|
|
905
|
+
refreshes the title-bar counter live so the backlog is honest."""
|
|
906
|
+
if self._panel is None:
|
|
907
|
+
return 0
|
|
908
|
+
have = {d.get("n") for d in self._drafts}
|
|
909
|
+
added = [d for d in drafts if d.get("n") not in have]
|
|
910
|
+
if not added:
|
|
911
|
+
return 0
|
|
912
|
+
self._drafts.extend(added)
|
|
913
|
+
# Update only the "X of N" counter; do NOT re-render the body (that would
|
|
914
|
+
# reset the caret / clobber an in-progress edit on the current card).
|
|
915
|
+
try:
|
|
916
|
+
self._panel.setTitle_(
|
|
917
|
+
f"Review draft {self._idx + 1} of {len(self._drafts)}"
|
|
918
|
+
)
|
|
919
|
+
except Exception:
|
|
920
|
+
pass
|
|
921
|
+
self._log_surface(f"extended +{len(added)}")
|
|
922
|
+
return len(added)
|
|
923
|
+
|
|
924
|
+
@objc.python_method
|
|
925
|
+
def _fire_decision(self):
|
|
926
|
+
# Fire the per-card callback the instant a decision is made, so an
|
|
927
|
+
# approved draft starts posting immediately instead of waiting for the
|
|
928
|
+
# whole batch to be reviewed. A throwing callback must never break the
|
|
929
|
+
# card flow (or the panel would wedge on the current card).
|
|
930
|
+
cb = self._on_decision
|
|
931
|
+
if cb is None or not self._decisions:
|
|
932
|
+
return
|
|
933
|
+
try:
|
|
934
|
+
cb(dict(self._decisions[-1]))
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
# ObjC selectors (trailing underscore -> "approve:" etc.)
|
|
939
|
+
def approve_(self, sender):
|
|
940
|
+
self._record(True)
|
|
941
|
+
self._fire_decision()
|
|
942
|
+
self._advance()
|
|
943
|
+
|
|
944
|
+
def approveLoved_(self, sender):
|
|
945
|
+
_log("approved with love")
|
|
946
|
+
self._record(True, loved=True)
|
|
947
|
+
self._fire_decision()
|
|
948
|
+
self._advance()
|
|
949
|
+
|
|
950
|
+
def feedbackOpen_(self, sender):
|
|
951
|
+
# Overall feedback is orthogonal to the card decision: the composer
|
|
952
|
+
# opens alongside, the card stays as-is (including in-progress edits).
|
|
953
|
+
self._track("feedback_open")
|
|
954
|
+
present_feedback()
|
|
955
|
+
|
|
956
|
+
def reject_(self, sender):
|
|
957
|
+
# Two-step reject: swap the card body for the reason picker. The
|
|
958
|
+
# decision is recorded when a reason chip (or Skip) is clicked. Any
|
|
959
|
+
# in-progress edit of the reply is preserved for Back.
|
|
960
|
+
self._pending_reply_text = self._current_text()
|
|
961
|
+
self._render_reason()
|
|
962
|
+
|
|
963
|
+
@objc.python_method
|
|
964
|
+
def _render_reason(self):
|
|
965
|
+
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
|
|
966
|
+
content.addSubview_(
|
|
967
|
+
_label(NSMakeRect(M, H - 46, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
|
|
968
|
+
)
|
|
969
|
+
y = H - 82
|
|
970
|
+
for i, (_, title) in enumerate(REJECT_REASONS):
|
|
971
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(M, y, W - 2 * M, 28))
|
|
972
|
+
btn.setTitle_(title)
|
|
973
|
+
btn.setBezelStyle_(NSBezelStyleRounded)
|
|
974
|
+
btn.setTag_(i + 1)
|
|
975
|
+
btn.setTarget_(self)
|
|
976
|
+
btn.setAction_("rejectReason:")
|
|
977
|
+
content.addSubview_(btn)
|
|
978
|
+
y -= 32
|
|
979
|
+
note = NSTextField.alloc().initWithFrame_(NSMakeRect(M, 56, W - 2 * M, 48))
|
|
980
|
+
note.setEditable_(True)
|
|
981
|
+
note.setBezeled_(True)
|
|
982
|
+
note.setFont_(NSFont.systemFontOfSize_(12))
|
|
983
|
+
try:
|
|
984
|
+
note.setPlaceholderString_("Optional note (sent with whichever reason you pick)")
|
|
985
|
+
note.cell().setWraps_(True)
|
|
986
|
+
except Exception:
|
|
987
|
+
pass
|
|
988
|
+
content.addSubview_(note)
|
|
989
|
+
self._reason_field = note
|
|
990
|
+
|
|
991
|
+
back = NSButton.alloc().initWithFrame_(NSMakeRect(M, 14, 90, 30))
|
|
992
|
+
back.setTitle_("Back")
|
|
993
|
+
back.setBezelStyle_(NSBezelStyleRounded)
|
|
994
|
+
back.setTarget_(self)
|
|
995
|
+
back.setAction_("rejectBack:")
|
|
996
|
+
content.addSubview_(back)
|
|
997
|
+
|
|
998
|
+
skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 150, 14, 150, 30))
|
|
999
|
+
skip.setTitle_("Reject (no reason)")
|
|
1000
|
+
skip.setBezelStyle_(NSBezelStyleRounded)
|
|
1001
|
+
skip.setTarget_(self)
|
|
1002
|
+
skip.setAction_("rejectSkip:")
|
|
1003
|
+
content.addSubview_(skip)
|
|
1004
|
+
|
|
1005
|
+
self._textview = None
|
|
1006
|
+
self._panel.setContentView_(content)
|
|
1007
|
+
self._panel.makeFirstResponder_(note)
|
|
1008
|
+
|
|
1009
|
+
@objc.python_method
|
|
1010
|
+
def _reason_note(self):
|
|
1011
|
+
try:
|
|
1012
|
+
return str(self._reason_field.stringValue()) if self._reason_field is not None else ""
|
|
1013
|
+
except Exception:
|
|
1014
|
+
return ""
|
|
1015
|
+
|
|
1016
|
+
def rejectReason_(self, sender):
|
|
1017
|
+
try:
|
|
1018
|
+
category = REJECT_REASONS[int(sender.tag()) - 1][0]
|
|
1019
|
+
except Exception:
|
|
1020
|
+
category = "other"
|
|
1021
|
+
_log(f"reject reason: {category}")
|
|
1022
|
+
self._record(False, reject_category=category, reject_note=self._reason_note())
|
|
1023
|
+
self._fire_decision()
|
|
1024
|
+
self._advance()
|
|
1025
|
+
|
|
1026
|
+
def rejectSkip_(self, sender):
|
|
1027
|
+
_log("reject reason: skipped")
|
|
1028
|
+
self._record(False, reject_note=self._reason_note())
|
|
1029
|
+
self._fire_decision()
|
|
1030
|
+
self._advance()
|
|
1031
|
+
|
|
1032
|
+
def rejectBack_(self, sender):
|
|
1033
|
+
# Re-render the card and restore any reply edit made before Reject.
|
|
1034
|
+
pending = getattr(self, "_pending_reply_text", None)
|
|
1035
|
+
self._render()
|
|
1036
|
+
if pending is not None and self._textview is not None:
|
|
1037
|
+
try:
|
|
1038
|
+
self._textview.setString_(pending)
|
|
1039
|
+
except Exception:
|
|
1040
|
+
pass
|
|
1041
|
+
|
|
1042
|
+
def windowShouldClose_(self, sender):
|
|
1043
|
+
# Closing the window stops review; remaining cards are left undecided
|
|
1044
|
+
# (not posted). Finish with whatever was decided so far.
|
|
1045
|
+
self._finish()
|
|
1046
|
+
return True
|
|
1047
|
+
|
|
1048
|
+
@objc.python_method
|
|
1049
|
+
def _finish(self):
|
|
1050
|
+
global _active
|
|
1051
|
+
self._close_stats_popover()
|
|
1052
|
+
try:
|
|
1053
|
+
if self._panel is not None:
|
|
1054
|
+
self._panel.setDelegate_(None)
|
|
1055
|
+
self._panel.close()
|
|
1056
|
+
except Exception:
|
|
1057
|
+
pass
|
|
1058
|
+
self._panel = None
|
|
1059
|
+
cb, self._on_complete = self._on_complete, None
|
|
1060
|
+
if cb is not None:
|
|
1061
|
+
try:
|
|
1062
|
+
cb(list(self._decisions))
|
|
1063
|
+
except Exception:
|
|
1064
|
+
pass
|
|
1065
|
+
_active = None
|
|
1066
|
+
_log(f"closed: {len(self._decisions)} decided of {len(self._drafts)}")
|
|
1067
|
+
_write_review_state(last_event="closed")
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def present_review(drafts, on_decision=None, on_complete=None):
|
|
1071
|
+
"""Show the review cards (main thread only). drafts: list of
|
|
1072
|
+
{n, thread_author, thread_text, reply_text, link_url, thread_url?, stats?}
|
|
1073
|
+
where stats is the discovery-time candidate snapshot
|
|
1074
|
+
{author_followers, likes, retweets, replies, views, tweet_posted_at, ...}
|
|
1075
|
+
(optional). thread_url renders as a trailing ↗ link on the thread text;
|
|
1076
|
+
followers show inline next to the handle, age muted at the right, and the
|
|
1077
|
+
thread engagement counts live only in the eye icon's hover/click popover.
|
|
1078
|
+
on_decision(decision) fires the instant each card is approved/rejected (so an
|
|
1079
|
+
approved draft posts right away); on_complete(decisions) fires when the user
|
|
1080
|
+
finishes the last card or closes the window. Both run on the main thread."""
|
|
1081
|
+
global _active
|
|
1082
|
+
if not drafts:
|
|
1083
|
+
if on_complete is not None:
|
|
1084
|
+
on_complete([])
|
|
1085
|
+
return
|
|
1086
|
+
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
|
|
1087
|
+
drafts, on_decision, on_complete
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def extend_active(drafts):
|
|
1092
|
+
"""Push newly-queued drafts into the open review card, if one is up. Returns
|
|
1093
|
+
the count actually added (0 if no card is open or nothing is new). Main thread
|
|
1094
|
+
only (called from the menu bar's rumps timer)."""
|
|
1095
|
+
if _active is None:
|
|
1096
|
+
return 0
|
|
1097
|
+
try:
|
|
1098
|
+
return _active.extend_drafts(drafts)
|
|
1099
|
+
except Exception:
|
|
1100
|
+
return 0
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def active_status():
|
|
1104
|
+
"""Live review-surface snapshot for the menu bar's unattended-review
|
|
1105
|
+
watchdog, or None when no card is open. Main thread only."""
|
|
1106
|
+
c = _active
|
|
1107
|
+
if c is None or c._panel is None:
|
|
1108
|
+
return None
|
|
1109
|
+
try:
|
|
1110
|
+
return c.status_dict()
|
|
1111
|
+
except Exception:
|
|
1112
|
+
return None
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def heal_active():
|
|
1116
|
+
"""Self-heal an unattended card: move it to the top-right of the screen the
|
|
1117
|
+
pointer is on and raise it, WITHOUT stealing keyboard focus (the user is
|
|
1118
|
+
mid-something elsewhere by definition). Main thread only. Returns True if a
|
|
1119
|
+
card was moved."""
|
|
1120
|
+
c = _active
|
|
1121
|
+
if c is None or c._panel is None:
|
|
1122
|
+
return False
|
|
1123
|
+
try:
|
|
1124
|
+
c._panel.setFrame_display_(_corner_frame(_mouse_screen()), True)
|
|
1125
|
+
c._panel.orderFrontRegardless()
|
|
1126
|
+
c._log_surface("healed")
|
|
1127
|
+
return True
|
|
1128
|
+
except Exception:
|
|
1129
|
+
return False
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
# ---- overall-feedback composer ----------------------------------------------
|
|
1133
|
+
# One small floating panel with a free-text field, for guidance that is about
|
|
1134
|
+
# the PIPELINE rather than any single draft ("less shilling", "more dev
|
|
1135
|
+
# threads", ...). Reachable from the card's 💬 button and the menu bar's
|
|
1136
|
+
# "Send feedback…" item; both call present_feedback(), which falls back to the
|
|
1137
|
+
# handler the menu bar registered at boot via set_feedback_handler() (that
|
|
1138
|
+
# handler ships a decision='feedback' review event down the same outbox rail
|
|
1139
|
+
# as card decisions, so the digest processes it the same way).
|
|
1140
|
+
|
|
1141
|
+
FB_W = 380
|
|
1142
|
+
FB_H = 200
|
|
1143
|
+
|
|
1144
|
+
# Default submit handler (menu bar's shipper). Module-level so the card's 💬
|
|
1145
|
+
# button can open the composer without threading a callback through
|
|
1146
|
+
# present_review's signature.
|
|
1147
|
+
_feedback_handler = None
|
|
1148
|
+
# Strong ref to the live composer, same pyobjc-GC footgun as _active.
|
|
1149
|
+
_feedback_active = None
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def set_feedback_handler(cb):
|
|
1153
|
+
"""Register the default on_submit for present_feedback(). The menu bar
|
|
1154
|
+
calls this once at boot with its review-event shipper."""
|
|
1155
|
+
global _feedback_handler
|
|
1156
|
+
_feedback_handler = cb
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _feedback_frame():
|
|
1160
|
+
"""Just below the open review card when there is one (so it never covers
|
|
1161
|
+
the card being reviewed), else the top-right corner of the pointer's
|
|
1162
|
+
screen."""
|
|
1163
|
+
scr = _mouse_screen()
|
|
1164
|
+
vf = (
|
|
1165
|
+
scr.visibleFrame()
|
|
1166
|
+
if scr is not None
|
|
1167
|
+
else NSMakeRect(0, 0, 1440, 900)
|
|
1168
|
+
)
|
|
1169
|
+
x = vf.origin.x + vf.size.width - FB_W - M
|
|
1170
|
+
y = vf.origin.y + vf.size.height - FB_H - M
|
|
1171
|
+
if _active is not None and _active._panel is not None:
|
|
1172
|
+
try:
|
|
1173
|
+
fr = _active._panel.frame()
|
|
1174
|
+
x = fr.origin.x
|
|
1175
|
+
y = max(fr.origin.y - FB_H - 10, vf.origin.y + M)
|
|
1176
|
+
except Exception:
|
|
1177
|
+
pass
|
|
1178
|
+
return NSMakeRect(x, y, FB_W, FB_H)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
class _FeedbackController(NSObject):
|
|
1182
|
+
def initWithOnSubmit_(self, on_submit):
|
|
1183
|
+
self = objc.super(_FeedbackController, self).init()
|
|
1184
|
+
if self is None:
|
|
1185
|
+
return None
|
|
1186
|
+
self._on_submit = on_submit
|
|
1187
|
+
self._panel = None
|
|
1188
|
+
self._tv = None
|
|
1189
|
+
self._build()
|
|
1190
|
+
return self
|
|
1191
|
+
|
|
1192
|
+
@objc.python_method
|
|
1193
|
+
def _build(self):
|
|
1194
|
+
style = (
|
|
1195
|
+
NSWindowStyleMaskTitled
|
|
1196
|
+
| NSWindowStyleMaskClosable
|
|
1197
|
+
| NSWindowStyleMaskUtilityWindow
|
|
1198
|
+
)
|
|
1199
|
+
panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
1200
|
+
_feedback_frame(), style, NSBackingStoreBuffered, False
|
|
1201
|
+
)
|
|
1202
|
+
panel.setLevel_(NSFloatingWindowLevel)
|
|
1203
|
+
panel.setFloatingPanel_(True)
|
|
1204
|
+
panel.setBecomesKeyOnlyIfNeeded_(False)
|
|
1205
|
+
panel.setHidesOnDeactivate_(False)
|
|
1206
|
+
panel.setReleasedWhenClosed_(False)
|
|
1207
|
+
panel.setDelegate_(self)
|
|
1208
|
+
panel.setTitle_("Overall feedback")
|
|
1209
|
+
|
|
1210
|
+
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, FB_W, FB_H))
|
|
1211
|
+
content.addSubview_(
|
|
1212
|
+
_label(
|
|
1213
|
+
NSMakeRect(M, FB_H - 48, FB_W - 2 * M, 32),
|
|
1214
|
+
"Standing guidance for the drafting loop (thread choice, tone, "
|
|
1215
|
+
"audience), not about any single draft.",
|
|
1216
|
+
size=11,
|
|
1217
|
+
muted=True,
|
|
1218
|
+
)
|
|
1219
|
+
)
|
|
1220
|
+
scroll = NSScrollView.alloc().initWithFrame_(
|
|
1221
|
+
NSMakeRect(M, 54, FB_W - 2 * M, FB_H - 48 - 8 - 54)
|
|
1222
|
+
)
|
|
1223
|
+
scroll.setHasVerticalScroller_(True)
|
|
1224
|
+
scroll.setBorderType_(NS_BEZEL_BORDER)
|
|
1225
|
+
tv = NSTextView.alloc().initWithFrame_(
|
|
1226
|
+
NSMakeRect(0, 0, FB_W - 2 * M, 80)
|
|
1227
|
+
)
|
|
1228
|
+
tv.setFont_(NSFont.systemFontOfSize_(12))
|
|
1229
|
+
tv.setRichText_(False)
|
|
1230
|
+
tv.setEditable_(True)
|
|
1231
|
+
tv.setSelectable_(True)
|
|
1232
|
+
scroll.setDocumentView_(tv)
|
|
1233
|
+
content.addSubview_(scroll)
|
|
1234
|
+
self._tv = tv
|
|
1235
|
+
|
|
1236
|
+
cancel = NSButton.alloc().initWithFrame_(NSMakeRect(M, 14, 90, 30))
|
|
1237
|
+
cancel.setTitle_("Cancel")
|
|
1238
|
+
cancel.setBezelStyle_(NSBezelStyleRounded)
|
|
1239
|
+
cancel.setTarget_(self)
|
|
1240
|
+
cancel.setAction_("feedbackCancel:")
|
|
1241
|
+
content.addSubview_(cancel)
|
|
1242
|
+
|
|
1243
|
+
send = NSButton.alloc().initWithFrame_(NSMakeRect(FB_W - M - 90, 14, 90, 30))
|
|
1244
|
+
send.setTitle_("Send")
|
|
1245
|
+
send.setBezelStyle_(NSBezelStyleRounded)
|
|
1246
|
+
send.setTarget_(self)
|
|
1247
|
+
send.setAction_("feedbackSend:")
|
|
1248
|
+
content.addSubview_(send)
|
|
1249
|
+
|
|
1250
|
+
panel.setContentView_(content)
|
|
1251
|
+
self._panel = panel
|
|
1252
|
+
panel.makeKeyAndOrderFront_(None)
|
|
1253
|
+
panel.orderFrontRegardless()
|
|
1254
|
+
try:
|
|
1255
|
+
NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
1256
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
1257
|
+
except Exception:
|
|
1258
|
+
pass
|
|
1259
|
+
panel.makeFirstResponder_(tv)
|
|
1260
|
+
_log("feedback composer opened")
|
|
1261
|
+
|
|
1262
|
+
@objc.python_method
|
|
1263
|
+
def _close(self):
|
|
1264
|
+
global _feedback_active
|
|
1265
|
+
try:
|
|
1266
|
+
if self._panel is not None:
|
|
1267
|
+
self._panel.setDelegate_(None)
|
|
1268
|
+
self._panel.close()
|
|
1269
|
+
except Exception:
|
|
1270
|
+
pass
|
|
1271
|
+
self._panel = None
|
|
1272
|
+
_feedback_active = None
|
|
1273
|
+
|
|
1274
|
+
def feedbackSend_(self, sender):
|
|
1275
|
+
text = ""
|
|
1276
|
+
try:
|
|
1277
|
+
text = str(self._tv.string()).strip()
|
|
1278
|
+
except Exception:
|
|
1279
|
+
pass
|
|
1280
|
+
cb = self._on_submit
|
|
1281
|
+
self._close()
|
|
1282
|
+
if not text:
|
|
1283
|
+
_log("feedback composer sent empty; dropped")
|
|
1284
|
+
return
|
|
1285
|
+
_log(f"feedback submitted ({len(text)} chars)")
|
|
1286
|
+
if cb is not None:
|
|
1287
|
+
try:
|
|
1288
|
+
cb(text)
|
|
1289
|
+
except Exception:
|
|
1290
|
+
pass
|
|
1291
|
+
|
|
1292
|
+
def feedbackCancel_(self, sender):
|
|
1293
|
+
_log("feedback composer cancelled")
|
|
1294
|
+
self._close()
|
|
1295
|
+
|
|
1296
|
+
def windowShouldClose_(self, sender):
|
|
1297
|
+
self._close()
|
|
1298
|
+
return True
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def present_feedback(on_submit=None):
|
|
1302
|
+
"""Open (or raise) the overall-feedback composer. Main thread only.
|
|
1303
|
+
on_submit(text) fires only when the user sends non-empty text; defaults to
|
|
1304
|
+
the handler registered via set_feedback_handler()."""
|
|
1305
|
+
global _feedback_active
|
|
1306
|
+
cb = on_submit if on_submit is not None else _feedback_handler
|
|
1307
|
+
if _feedback_active is not None and _feedback_active._panel is not None:
|
|
1308
|
+
try:
|
|
1309
|
+
_feedback_active._panel.makeKeyAndOrderFront_(None)
|
|
1310
|
+
_feedback_active._panel.orderFrontRegardless()
|
|
1311
|
+
return
|
|
1312
|
+
except Exception:
|
|
1313
|
+
pass
|
|
1314
|
+
_feedback_active = _FeedbackController.alloc().initWithOnSubmit_(cb)
|