diff --git a/ttyverse.pl b/ttyverse.pl index 1cddb95..d11a9f2 100755 --- a/ttyverse.pl +++ b/ttyverse.pl @@ -40,12 +40,10 @@ BEGIN { $command_line = $0; $0 = "TTYverse"; $TTYverse_VERSION = "2025.07.25"; - $TTYverse_PATCH_VERSION = ""; - $TTYverse_RC_NUMBER = 0; # non-zero for release candidate # this is kludgy, yes. $LANG = $ENV{'LANG'} || $ENV{'GDM_LANG'} || $ENV{'LC_CTYPE'} || $ENV{'ALL'}; - $my_version_string = ($TTYverse_PATCH_VERSION) ? "${TTYverse_VERSION}.${TTYverse_PATCH_VERSION}" : "${TTYverse_VERSION}"; + $my_version_string = $TTYverse_VERSION; (warn ("$my_version_string\n"), exit) if ($version); # Set up XDG directories early for -create-rc and extensions @@ -224,7 +222,6 @@ EOF undef $master_store; undef %push_stack; - $padded_patch_version = substr($TTYverse_PATCH_VERSION . " ", 0, 2); %opts_boolean = map { $_ => 1 } qw( ansi noansi verbose superverbose ttytteristas noprompt @@ -263,7 +260,7 @@ EOF ); %opts_can_set = map { $_ => 1 } qw( - url pause dmurl dmpause superverbose ansi verbose + url pause dmurl dmpause dmmarkread superverbose ansi verbose update uurl rurl wurl avatar ttytteristas frurl track rlurl noprompt shoreblogurl newline wrap verify autosplit notimeline queryurl fediverseserver colourprompt colourme @@ -1093,9 +1090,10 @@ $slowpost ||= 0; $twarg ||= undef; $verbose ||= $superverbose; -$dmpause = 4 if (!defined $dmpause); # NOT ||= ... zero is a VALID value! +$dmpause = 4 if (!defined $dmpause); # NOT ||= ... zero is a VALID value! $dmpause = 0 if ($anonymous); $dmpause = 0 if ($pause eq '0'); +$dmmarkread = 1 if (!defined $dmmarkread); # Default to enabled $ansi = ($noansi) ? 0 : (($ansi || $ENV{'TERM'} eq 'ansi' || $ENV{'TERM'} eq 'xterm-color') ? 1 : 0); @@ -1748,6 +1746,7 @@ if ($daemon) { &update_effpause; &refresh(0); $dont_refresh_first_time = 0; + # Move DM refresh after timeline refresh so timeline updates show even if no unread DMs if ($dmpause) { if (!--$dmcount) { &dmrefresh(0); @@ -1833,7 +1832,7 @@ unless ($simplestart) { print <<"EOF"; ======================================== -TTYverse ${TTYverse_VERSION}.${padded_patch_version} (c)2025 Storm Dragon +TTYverse ${TTYverse_VERSION} (c)2025 Storm Dragon all rights reserved. https://git.stormux.org/storm/ttyverse @@ -4649,7 +4648,9 @@ $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 +# DM tracking now uses Mastodon's unread flag with local read tracking fallback +%dm_seen_status = (); # Hash to track seen conversation_id:last_status_id pairs +&load_dm_seen_status(); # Load persistent tracking data $stuck_stdin = 0; # tell the foreground we are ready @@ -5994,6 +5995,24 @@ sub dmrefresh { print $stdout "-- DEBUG: DM response: " . scalar(@{ $my_json_ref }) . " conversations, disp_max=$disp_max\n" if ($verbose); + # For background refresh, check if there are any unread DMs first + if (!$interactive && !$sent_dm && $disp_max) { + my $has_unread = 0; + for (my $check_i = 0; $check_i < $disp_max; $check_i++) { + my $check_j = $my_json_ref->[$check_i]; + next if (!$check_j->{'accounts'} || !@{$check_j->{'accounts'}} || !$check_j->{'last_status'}); + if ($check_j->{'unread'}) { + $has_unread = 1; + last; + } + } + if (!$has_unread) { + print $stdout "-- DEBUG: No unread DMs found in background refresh, returning early\n" if ($verbose); + return; + } + print $stdout "-- DEBUG: Found unread DMs in background refresh, proceeding\n" if ($verbose); + } + if ($disp_max) { # an empty list can be valid if ($dm_first_time) { sleep 5 while ($suspend_output > 0); @@ -6016,12 +6035,23 @@ sub dmrefresh { next; } - # For background refresh (interactive=0), use unread flag detection + # For background refresh (interactive=0), use unread flag detection with local tracking fallback if (!$interactive && !$sent_dm) { my $is_unread = $j->{'unread'} || 0; - print $stdout "-- DEBUG: DM #$i unread flag: " . ($is_unread ? "true" : "false") . "\n" if ($verbose); + my $conversation_id = $j->{'id'}; + my $last_status_id = $j->{'last_status'}->{'id'} || $j->{'last_status'}->{'id_str'}; + my $tracking_key = "${conversation_id}:${last_status_id}"; - if (!$is_unread) { + print $stdout "-- DEBUG: DM #$i unread flag: " . ($is_unread ? "true" : "false") . ", tracking_key: $tracking_key\n" if ($verbose); + + # Check if we've already seen this exact conversation+status combination + if ($dm_seen_status{$tracking_key}) { + print $stdout "-- DEBUG: Skipping DM #$i - already seen locally (key: $tracking_key)\n" if ($verbose); + next; + } + + # For servers that support unread flag, also check that + if (!$is_unread && !$dm_seen_status{$tracking_key}) { print $stdout "-- DEBUG: Skipping DM #$i - already read (unread=false)\n" if ($verbose); next; } @@ -7251,9 +7281,19 @@ sub defaultdmhandle { 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'}); + # Mark conversation as read if it was unread (can be disabled with dmmarkread=0) + if ($dm_ref->{'unread'} && (!defined($dmmarkread) || $dmmarkread)) { + my $mark_result = &mark_conversation_read($dm_ref->{'id'}); + + # If server-side mark-as-read failed, track locally to prevent re-showing + if (!$mark_result) { + my $conversation_id = $dm_ref->{'id'}; + my $last_status_id = $dm_ref->{'last_status'}->{'id'} || $dm_ref->{'last_status'}->{'id_str'}; + my $tracking_key = "${conversation_id}:${last_status_id}"; + $dm_seen_status{$tracking_key} = time(); # Store timestamp when seen + print $stdout "-- DEBUG: Marked DM as seen locally (key: $tracking_key)\n" if ($verbose); + &save_dm_seen_status(); # Persist the change + } } return 1; @@ -7277,15 +7317,46 @@ sub mark_conversation_read { 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"; + my $read_url = "${apibase}/conversations/$conversation_id/read"; + print $stdout "-- DEBUG: Mark-as-read URL: $read_url\n" if ($verbose); + + # Make POST request with shorter timeout for mark-as-read + my $old_timeout = $timeout; + my $old_exception = $exception; + $timeout = 5; # Short timeout for non-critical operation + + # Use a custom exception handler to capture detailed error info + $exception = sub { + my ($severity, $message) = @_; + print $stdout "-- DEBUG: Mark-as-read API call failed (severity=$severity): $message" if ($verbose); + # Don't show user warning for this - it's not critical, but log the error + }; - # Make POST request with empty data to mark as read my $result = &postjson($read_url, ""); + # Restore original handlers + $timeout = $old_timeout; + $exception = $old_exception; + if ($result) { print $stdout "-- DEBUG: Successfully marked conversation $conversation_id as read\n" if ($verbose); + return 1; } else { - print $stdout "-- DEBUG: Failed to mark conversation $conversation_id as read\n" if ($verbose); + print $stdout "-- DEBUG: Failed to mark conversation $conversation_id as read - trying alternative method\n" if ($verbose); + + # Fallback: Some servers might need different approach + # Try sending empty message to conversation (some implementations mark as read this way) + my $alt_url = "${apibase}/conversations/$conversation_id"; + print $stdout "-- DEBUG: Trying alternative approach: GET $alt_url\n" if ($verbose); + + my $alt_result = &grabjson($alt_url, 0, 0, 1, undef, 1); + if ($alt_result) { + print $stdout "-- DEBUG: Alternative method succeeded (conversation fetched)\n" if ($verbose); + return 1; + } else { + print $stdout "-- DEBUG: All methods failed - server may not support conversation read API\n" if ($verbose); + return 0; + } } } @@ -8227,8 +8298,14 @@ sub updatecheck { } elsif ($latest_version eq $current_version) { $vs .= "-- your version of TTYverse is up to date ($current_version)\n"; } else { - $vs .= "-- you appear to have a development version ($current_version)\n"; - $vs .= "-- latest stable release: $latest_version\n"; + # Local version is newer - check if we're in a git repo + if (-d '.git') { + $vs .= "-- your version ($current_version) is newer than latest remote ($latest_version)\n"; + $vs .= "-- you may have unpushed changes or unreleased version\n"; + } else { + $vs .= "-- you appear to have a development version ($current_version)\n"; + $vs .= "-- latest stable release: $latest_version\n"; + } } } else { $vs .= "-- warning: unable to determine latest version\n"; @@ -8736,10 +8813,14 @@ sub normalizejson { print $stdout "-- DEBUG: Found poll in main post\n" if ($verbose); } - # Store poll data for display - if ($poll_data) { + # Store poll data for display - but only if it's a valid poll with options + if ($poll_data && ref($poll_data) eq 'HASH' && exists($poll_data->{'options'}) && @{$poll_data->{'options'}}) { $i->{'poll'} = $poll_data; print $stdout "-- DEBUG: Poll data stored for display\n" if ($verbose); + } else { + # Clear any false poll data + delete $i->{'poll'} if exists($i->{'poll'}); + print $stdout "-- DEBUG: No valid poll data found, cleared poll flag\n" if ($verbose && $poll_data); } return $i; @@ -9818,4 +9899,59 @@ sub signrequest { return "-H \"Authorization: Bearer $tokenkey\""; } +# Load locally tracked DM seen status from persistent storage +sub load_dm_seen_status { + my $seen_file = "$config/dm_seen_status"; + return unless (-r $seen_file); + + if (open(SEEN, '<', $seen_file)) { + print $stdout "-- DEBUG: Loading DM seen status from $seen_file\n" if ($verbose); + my $count = 0; + while (my $line = ) { + chomp($line); + next unless ($line =~ /^(.+):(\d+)$/); + my ($tracking_key, $timestamp) = ($1, $2); + + # Skip entries older than 30 days to prevent infinite growth + if (time() - $timestamp < 30 * 24 * 3600) { + $dm_seen_status{$tracking_key} = $timestamp; + $count++; + } + } + close(SEEN); + print $stdout "-- DEBUG: Loaded $count DM seen entries\n" if ($verbose); + } +} + +# Save locally tracked DM seen status to persistent storage +sub save_dm_seen_status { + my $seen_file = "$config/dm_seen_status"; + + # Ensure config directory exists + unless (-d $config) { + mkdir($config, 0700) || do { + print $stdout "-- DEBUG: Could not create config directory $config: $!\n" if ($verbose); + return; + }; + } + + if (open(SEEN, '>', $seen_file)) { + print $stdout "-- DEBUG: Saving DM seen status to $seen_file\n" if ($superverbose); + my $count = 0; + for my $key (keys %dm_seen_status) { + my $timestamp = $dm_seen_status{$key}; + # Only save entries from last 30 days + if (time() - $timestamp < 30 * 24 * 3600) { + print SEEN "$key:$timestamp\n"; + $count++; + } + } + close(SEEN); + chmod(0600, $seen_file); # Keep private + print $stdout "-- DEBUG: Saved $count DM seen entries\n" if ($superverbose); + } else { + print $stdout "-- DEBUG: Could not save DM seen status to $seen_file: $!\n" if ($verbose); + } +} + }