diff --git a/ttyverse.pl b/ttyverse.pl index eaa5ec0..3fe3f6b 100755 --- a/ttyverse.pl +++ b/ttyverse.pl @@ -169,7 +169,7 @@ BEGIN { # notrack=0 # Disable tracking # filterusers= # Filter posts from specific users # filterats= # Filter posts with specific @mentions -# filterrts= # Filter retweets/boosts +# filterrts= # Filter boosts # filteratonly= # Only show posts with @mentions # filterflags= # Filter flags # nofilter=0 # Disable all filtering @@ -193,7 +193,7 @@ BEGIN { # === ADVANCED/TECHNICAL === # runcommand= # Command to run on startup -# twarg= # Legacy Twitter argument (unused) +# twarg= # Legacy argument (unused) # user= # Username override # leader= # Command leader character @@ -2908,6 +2908,7 @@ EOF print $stdout "-- /refresh in streaming mode is pretty impatient\n" if ($dostream); &thump; + &dmthump_no_skip if ($dmpause); # Also refresh DMs but don't skip timeline return 0; } if (m#^/a(gain)?(\s+\+\d+)?$#) { # the asynchronous form @@ -3105,7 +3106,7 @@ EOF # grab all the IDs my $ids_ref = &grabjson( - "$mode?count=${countmaybe}&screen_name=${who}&stringify_ids=true", + "$mode?limit=${countmaybe}&screen_name=${who}", 0, 0, 0, undef, 1); return 0 if (!$ids_ref || ref($ids_ref) ne 'HASH' || !$ids_ref->{'ids'}); @@ -3173,7 +3174,7 @@ EOF if(!scalar(@usarray)) { last FABIO if ($nofetch); $json_ref = &grabjson( - "${mode}?count=${countper}&cursor=${cursor}${user}", + "${mode}?limit=${countper}&cursor=${cursor}${user}", 0, 0, 0, undef, 1); @usarray = @{ $json_ref->{'users'} }; last FABIO if (!scalar(@usarray)); @@ -3835,8 +3836,8 @@ EOF my $my_json_ref = &grabjson($timeline_url, 0, 0, $countmaybe || 20, undef, 1); if ($timeline_name eq 'notifications') { - # Notifications have a different structure, need special handling - &dt_tdisplay($my_json_ref, "notifications"); + # Notifications need individual type mapping for sound alerts + ¬ifications_tdisplay($my_json_ref); } else { &dt_tdisplay($my_json_ref, $timeline_name); } @@ -3886,7 +3887,7 @@ EOF $countmaybe = sprintf("%03i", $countmaybe); print $stdout "-- background request sent\n" unless ($synch); - $mode = ($mode =~ /^s/) ? 's' : 'd'; + $mode = ($mode eq 'sent') ? 's' : 'd'; print C "${mode}mreset${countmaybe}---------\n"; &sync_semaphore; return 0; @@ -4153,7 +4154,7 @@ EOF if(!scalar(@usarray)) { last LABIO if ($nofetch); $json_ref = &grabjson( - "${furl}&count=${countper}&cursor=${cursor}", 0, 0, 0, + "${furl}&limit=${countper}&cursor=${cursor}", 0, 0, 0, undef, 1); @usarray = @{ ((length($lname)) ? $json_ref->{'users'} : @@ -4585,6 +4586,9 @@ $interactive = $previous_last_id = $we_got_signal = 0; $suspend_output = -1; $stream_failure = 0; $dm_first_time = ($dmpause) ? 1 : 0; +$dm_display_only = 0; # Flag to suppress notifications during user-initiated /dms commands +$dm_notification_sent = 0; # Flag to prevent duplicate notifications per refresh cycle +# DM tracking now uses Mastodon's unread flag instead of content comparison $stuck_stdin = 0; # tell the foreground we are ready @@ -4605,10 +4609,14 @@ for(;;) { &dmrefresh(0); $dmcount = $dmpause; } elsif (!$interactive) { + print $stdout "-- DEBUG: DM countdown: $dmcount -> " . ($dmcount - 1) . "\n" if ($verbose); if (!--$dmcount) { + print $stdout "-- DEBUG: Triggering background DM refresh\n" if ($verbose); &dmrefresh($interactive); # using dm_first_time $dmcount = $dmpause; } + } else { + print $stdout "-- DEBUG: Skipping DM countdown (interactive=$interactive)\n" if ($verbose); } } DONT_REFRESH: @@ -4919,15 +4927,31 @@ EOF if ($rout =~ /^reset(\d+)/); ($dmfetchwanted = 0+$1, $last_dm = 0) if ($rout =~ /^dmreset(\d+)/); - if ($rout =~ /^smreset/) { # /dmsent + if ($rout =~ /^dmreset/) { # /dms (received) $dmfetchwanted = 0+$1 if ($rout =~ /(\d+)/); - &dmrefresh(1, 1); + $dm_display_only = 1; # Suppress notifications for user-initiated /dms + &dmrefresh(1, 0); # interactive=1, sent_dm=0 + $dm_display_only = 0; # Reset flag + &send_repaint if ($termrl); + # we do not want to force a refresh. + goto DONT_REFRESH; + } elsif ($rout =~ /^smreset/) { # /dmsent + $dmfetchwanted = 0+$1 + if ($rout =~ /(\d+)/); + $dm_display_only = 1; # Suppress notifications for user-initiated /dmsent + &dmrefresh(1, 1); # interactive=1, sent_dm=1 + $dm_display_only = 0; # Reset flag &send_repaint if ($termrl); # we do not want to force a refresh. goto DONT_REFRESH; } - if ($rout =~ /^dm/) { + if ($rout =~ /^dmthump_no_skip/) { + &dmrefresh(0); # Force background-style refresh to update last_dm + &send_repaint if ($termrl); + $dmcount = $dmpause; + # Don't skip - let timeline refresh continue + } elsif ($rout =~ /^dm/) { &dmrefresh($interactive); &send_repaint if ($termrl); $dmcount = $dmpause; @@ -5018,7 +5042,7 @@ sub update_effpause { if ($effpause > 120) { $effpause = 120; # Maximum 2 minutes for fediverse } - # this will usually be 75s (Twitter) or 120s (fediverse cap) + # this will usually be 120s (fediverse rate limit cap) # for lists, however, we have to drain the list bucket faster, so for every # list AFTER THE FIRST ONE we subscribe to, add rate_limit_rate to slow. # for search, it has 180 requests, so we don't care so much. if this @@ -5777,7 +5801,7 @@ sub tdisplay { # used by both synchronous /again and asynchronous refreshes $wrapseq++; $printed += scalar(&$handle($j, - ($class || (($id <= $relative_last_id) ? 'again' : + ($class || (($id le $relative_last_id) ? 'again' : undef)))); } } @@ -5823,12 +5847,46 @@ $my_json_ref->[(&min($print_max,scalar(@{ $my_json_ref }))-1)]->{'created_at'}); } } +sub notifications_tdisplay { + my $my_json_ref = shift; + if (defined($my_json_ref) + && ref($my_json_ref) eq 'ARRAY' + && scalar(@{ $my_json_ref })) { + + # Process each notification with proper type mapping + for my $notification (@{ $my_json_ref }) { + my $type = $notification->{'type'} || 'default'; + # Map Mastodon notification types to sound categories + my $sound_class = $type; + $sound_class = 'mention' if ($type eq 'mention'); + $sound_class = 'boost' if ($type eq 'reblog'); + $sound_class = 'favourite' if ($type eq 'favourite'); + $sound_class = 'follow' if ($type eq 'follow'); + + # Process as individual post with proper class + &$handle($notification, $sound_class); + } + + unless ($timestamp) { + my ($time, $ts1) = &$wraptime( +$my_json_ref->[(&min($print_max,scalar(@{ $my_json_ref }))-1)]->{'created_at'}); + my ($time, $ts2) = &$wraptime($my_json_ref->[0]->{'created_at'}); + print $stdout &wwrap( + "-- update covers $ts1 thru $ts2\n"); + } + &$conclude; + } +} + # thump for DMs sub dmrefresh { my $interactive = shift; my $sent_dm = shift; # for streaming API to inject DMs it receives my $my_json_ref = shift; + + # Reset notification flag for this refresh cycle + $dm_notification_sent = 0; if ($anonymous) { print $stdout @@ -5859,6 +5917,8 @@ sub dmrefresh { my $g; my $key; + print $stdout "-- DEBUG: DM response: " . scalar(@{ $my_json_ref }) . " conversations, disp_max=$disp_max\n" if ($verbose); + if ($disp_max) { # an empty list can be valid if ($dm_first_time) { sleep 5 while ($suspend_output > 0); @@ -5867,13 +5927,39 @@ sub dmrefresh { "-- checking for most recent direct messages:\n"; $disp_max = 2; $interactive = 1; + print $stdout "-- DEBUG: dm_first_time: disp_max reduced to $disp_max\n" if ($verbose); } + print $stdout "-- DEBUG: Starting DM display loop: $disp_max conversations\n" if ($verbose); for($i = $disp_max; $i > 0; $i--) { $g = ($i-1); my $j = $my_json_ref->[$g]; - next if (!$sent_dm && $j->{'id'} <= $last_dm); - next if (!$j->{'accounts'} || !@{$j->{'accounts'}} || - !$j->{'last_status'}); + print $stdout "-- DEBUG: Processing DM #$i (index $g)\n" if ($verbose); + + # Skip if missing data + if (!$j->{'accounts'} || !@{$j->{'accounts'}} || !$j->{'last_status'}) { + print $stdout "-- DEBUG: Skipping DM #$i - missing data (accounts=" . (defined($j->{'accounts'}) ? scalar(@{$j->{'accounts'}}) : 'undef') . ")\n" if ($verbose); + next; + } + + # For background refresh (interactive=0), use unread flag detection + if (!$interactive && !$sent_dm) { + my $is_unread = $j->{'unread'} || 0; + print $stdout "-- DEBUG: DM #$i unread flag: " . ($is_unread ? "true" : "false") . "\n" if ($verbose); + + if (!$is_unread) { + print $stdout "-- DEBUG: Skipping DM #$i - already read (unread=false)\n" if ($verbose); + next; + } + + # Only play sound for the first unread conversation to avoid spam + if ($dm_notification_sent) { + print $stdout "-- DEBUG: Suppressing notification for DM #$i - already notified this cycle\n" if ($verbose); + # Still display the DM but suppress notification + } else { + # Mark that we will send a notification for this unread DM + print $stdout "-- DEBUG: Will send notification for unread DM #$i\n" if ($verbose); + } + } $key = substr($alphabet, $dm_counter/10, 1) . $dm_counter % 10; @@ -5887,7 +5973,10 @@ sub dmrefresh { &send_removereadline if ($termrl); $wrapseq++; - $printed += scalar(&$dmhandle($j)); + print $stdout "-- DEBUG: Calling dmhandle for DM #$i\n" if ($verbose); + my $dm_result = scalar(&$dmhandle($j)); + print $stdout "-- DEBUG: dmhandle returned: $dm_result\n" if ($verbose); + $printed += $dm_result; } $max = $my_json_ref->[0]->{'id'}; } @@ -5899,8 +5988,26 @@ sub dmrefresh { : "-- sorry, no new direct messages.\n"); $wrapseq = 1; } - $last_dm = ($sent_dm) ? $orig_last_dm - : &max($last_dm, $max); + # Update last_dm for background refresh AND manual /dms + # Background calls: $interactive=0 + # Manual /dms: $interactive=1, $sent_dm=0 (receives messages, should update read marker) + # Manual /dmsent: $interactive=1, $sent_dm=1 (shows sent messages, don't update) + if (!$interactive || $dm_first_time || ($interactive && !$sent_dm)) { + $last_dm = ($sent_dm) ? $orig_last_dm + : &max($last_dm, $max); + + # Update content bookmark for the newest DM (index 0) + if (!$sent_dm && scalar(@{ $my_json_ref }) > 0) { + my $newest_dm = $my_json_ref->[0]; + if ($newest_dm->{'last_status'}) { + # Track newest DM for next comparison (unread flag handles change detection) + } + } + + print $stdout "-- DEBUG: Updated last_dm to $last_dm (interactive=$interactive, dm_first_time=$dm_first_time, sent_dm=$sent_dm)\n" if ($verbose); + } else { + print $stdout "-- DEBUG: NOT updating last_dm (interactive=$interactive, keeping last_dm=$last_dm)\n" if ($verbose); + } $dm_first_time = 0 if ($last_dm || !scalar(@{ $my_json_ref })); print $stdout "-- dm bookmark is $last_dm.\n" if ($verbose); &$dmconclude; @@ -6111,6 +6218,11 @@ EOF $lasttwit = $string; &$postpost($string); } + + # Send "me" notification for outgoing DMs + if ($user_name_dm) { + ¬ifytype_dispatch('me', $string, undef); + } return 0; } @@ -6328,6 +6440,16 @@ sub standardpost { } } + # Add poll indicator if this post has a poll + if (exists($ref->{'poll'}) && $ref->{'poll'}) { + my $poll_indicator = '[Poll]'; + if ($nocolour) { + $info_line .= " $poll_indicator"; + } else { + $info_line .= " ${GREEN}${poll_indicator}${OFF}"; + } + } + # Add content warning/title if present my $cw_text = ''; if (exists($ref->{'reblog'}) && $ref->{'reblog'} && exists($ref->{'reblog'}->{'spoiler_text'})) { @@ -7028,15 +7150,47 @@ sub defaultdmhandle { my $dm_ref = shift; my $sns = &descape($dm_ref->{'last_status'}->{'account'}->{'username'} || $dm_ref->{'last_status'}->{'account'}->{'acct'}); - print $streamout &standarddm($dm_ref); + my $dm_content = &standarddm($dm_ref); + print $streamout $dm_content; + print $stdout "-- DEBUG: DM displayed: " . substr($dm_content, 0, 50) . "...\n" if ($verbose); &senddmnotifies($dm_ref) if ($sns ne $whoami); + + # Mark conversation as read if it was unread + if ($dm_ref->{'unread'}) { + &mark_conversation_read($dm_ref->{'id'}); + } + return 1; } sub senddmnotifies { my $dm_ref = shift; - ¬ifytype_dispatch('dm', &standarddm($dm_ref, 1), $dm_ref) - if ($notify_list{'dm'} && $last_dm); + # Only send notification if we haven't already sent one this refresh cycle + # and we're not in initial load (either timeline or DM first time) + # and we're not in display-only mode (user-initiated /dms commands) + if ($notify_list{'dm'} && !$initial_load_in_progress && !$dm_first_time && !$dm_notification_sent && !$dm_display_only) { + ¬ifytype_dispatch('dm', &standarddm($dm_ref, 1), $dm_ref); + $dm_notification_sent = 1; + } +} + +sub mark_conversation_read { + my $conversation_id = shift; + return unless ($conversation_id); + + print $stdout "-- DEBUG: Marking conversation $conversation_id as read\n" if ($verbose); + + # Build the URL for marking conversation as read + my $read_url = "$baseurl/api/v1/conversations/$conversation_id/read"; + + # Make POST request with empty data to mark as read + my $result = &postjson($read_url, ""); + + if ($result) { + print $stdout "-- DEBUG: Successfully marked conversation $conversation_id as read\n" if ($verbose); + } else { + print $stdout "-- DEBUG: Failed to mark conversation $conversation_id as read\n" if ($verbose); + } } sub defaulteventhandle { @@ -7386,6 +7540,7 @@ sub thump { &sync_semaphore; } sub dmthump { print C "dmthump------------\n"; &sync_semaphore; } +sub dmthump_no_skip { print C "dmthump_no_skip----\n"; &sync_semaphore; } sub sync_n_quit { if ($child) { @@ -8192,14 +8347,14 @@ sub grabjson { $url = substr($url, 0, $i); } - # count needs to be removed for the default case due to show, etc. - push(@xargs, "count=$count") if ($count); + # limit parameter for Mastodon API (replaces Twitter's count parameter) + push(@xargs, "limit=$count") if ($count); # timeline control. this speeds up parsing since there's less data. # can't use skip_user: no SN push (@xargs, "since_id=${last_id}") if ($last_id); - # request entities, which should be supported everywhere now - push (@xargs, "include_entities=1") if ($do_entities); + # include_entities is Twitter-specific, not needed for Mastodon + # push (@xargs, "include_entities=1") if ($do_entities); my $resource = (scalar(@xargs)) ? [ $url, join('&', sort @xargs) ] : $url;