diff --git a/ttyverse.pl b/ttyverse.pl index 00e820b..63b1960 100755 --- a/ttyverse.pl +++ b/ttyverse.pl @@ -146,6 +146,9 @@ BEGIN { # === DIRECT MESSAGES === # dmpause=0 # DM refresh rate (0=use main pause) +# === NOTIFICATIONS === +# notificationpause=0 # Notification refresh rate (0=use main pause) + # === INTERACTION === # mentions=0 # Show mentions in timeline # synch=0 # Synchronous mode (blocks on requests) @@ -237,7 +240,7 @@ EOF signals_use_posix dostream nostreamreplies streamallreplies nofilter ); %opts_sync = map { $_ => 1 } qw( - ansi pause dmpause ttytteristas verbose superverbose + ansi pause dmpause notificationpause ttytteristas verbose superverbose url rlurl dmurl newline wrap notimeline lists dmidurl queryurl track colourprompt colourme notrack colourdm colourreply colourwarn coloursearch colourlist idurl @@ -247,6 +250,7 @@ EOF ); %opts_urls = map {$_ => 1} qw( url dmurl uurl rurl wurl frurl rlurl update shoreblogurl apibase fediverseserver queryurl idurl delurl dmdelurl favsurl + notificationurl markersurl favurl favdelurl followurl leaveurl dmupdate credurl blockurl blockdelurl friendsurl modifyliurl adduliurl delliurl getliurl getlisurl getfliurl @@ -265,7 +269,7 @@ EOF ); %opts_can_set = map { $_ => 1 } qw( - url pause dmurl dmpause dmmarkread superverbose ansi verbose + url pause dmurl dmpause notificationpause dmmarkread superverbose ansi verbose update uurl rurl wurl avatar ttytteristas frurl track rlurl noprompt shoreblogurl newline wrap verify autosplit notimeline queryurl fediverseserver colourprompt colourme @@ -672,15 +676,16 @@ if ($termrl && $termrl->ReadLine eq 'Term::ReadLine::Gnu') { /help /? /quit /q /bye /end /e /exit /refresh /r /thump /again /a /dm /dmr /dmrefresh /dms /dmsent /dmagain + /notifications /notificationrefresh /nr /replies /re /reply /timeline /timelines - /media /visibility /search /se + /media /poll /mpoll /visibility /search /se /history /h /print /p /verbose /ve /ruler /ru /cls /clear /url /open /short /sh /rate /ratelimit /track /tron /troff /trends /woeids /notrack /set /unset /add /del /push /pop /list /lists /listfollowers - /listfriends /dump /du /eval /ev + /listfriends /followers /following /dump /du /eval /ev /version /update /versioncheck /updatecheck /thread /th /entities /ent /delete /deletelast /rtsof /vote /whois /w /me @@ -1344,6 +1349,9 @@ $dmupdate ||= "${apibase}/statuses"; # DMs are private statuses $dmdelurl ||= "${apibase}/statuses/%I"; $dmidurl ||= "${apibase}/statuses/%I"; +$notificationurl ||= "${apibase}/notifications"; +$markersurl ||= "${apibase}/markers"; + $favsurl ||= "${apibase}/favourites"; $favurl ||= "${apibase}/statuses/%I/favourite"; $favdelurl ||= "${apibase}/statuses/%I/unfavourite"; @@ -1396,6 +1404,9 @@ $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 +$notificationpause = 6 if (!defined $notificationpause); # NOT ||= ... zero is a VALID value! +$notificationpause = 0 if ($anonymous); +$notificationpause = 0 if ($pause eq '0'); $ansi = ($noansi) ? 0 : (($ansi || $ENV{'TERM'} eq 'ansi' || $ENV{'TERM'} eq 'xterm-color') ? 1 : 0); @@ -1404,9 +1415,11 @@ $ansi = ($noansi) ? 0 : if ($synch) { $pause = 0; $dmpause = ($dmpause) ? 1 : 0; + $notificationpause = ($notificationpause) ? 1 : 0; } $dmcount = $dmpause; +$notificationcount = $notificationpause; $lastshort = undef; # ANSI sequences @@ -2008,7 +2021,7 @@ exit(0) if (length($status)); if (length($credentials)) { print "-- processing credentials: "; $my_json_ref = &map_mastodon_fields(&parsejson($credentials)); - $whoami = lc($my_json_ref->{'username'} || $my_json_ref->{'acct'}); + $whoami = lc($my_json_ref->{'acct'} || $my_json_ref->{'username'}); if (!length($whoami)) { print "FAILED!\nis your account suspended, or wrong token?\n"; exit; @@ -2060,6 +2073,7 @@ if ($daemon) { } $parent = 0; $dmcount = 1 if ($dmpause); # force fetch + $notificationcount = 1 if ($notificationpause); # force fetch $is_background = 1; DAEMONLOOP: for(;;) { my $snooze; @@ -2070,6 +2084,13 @@ if ($daemon) { &update_effpause; &refresh(0); $dont_refresh_first_time = 0; + # Check notifications before DMs since DM refresh can return early + if ($notificationpause) { + if (!--$notificationcount) { + ¬ificationrefresh(0); + $notificationcount = $notificationpause; + } + } # Move DM refresh after timeline refresh so timeline updates show even if no unread DMs if ($dmpause) { if (!--$dmcount) { @@ -3124,6 +3145,12 @@ Just type to talk! /media /path/to/file Upload media (images, video, audio) with accessibility features +/poll Your question here + Create a single choice poll + +/mpoll Your question here + Create a multiple choice poll (users can select multiple options) + /quit Resumes your boring life. @@ -3144,6 +3171,8 @@ USER COMMANDS: /wagain username - combines them all /follow username - follow a username /leave username - stop following a username + /followers [username] - show who follows you (or username) + /following [username] - show who you follow (or username follows) /dm username message - send a username a DM POST AND DM SELECTION: @@ -3239,6 +3268,7 @@ EOF if ($dostream); &thump; &dmthump_no_skip if ($dmpause); # Also refresh DMs but don't skip timeline + ¬ificationthump if ($notificationpause); # Also refresh notifications return 0; } @@ -3247,6 +3277,24 @@ EOF my $file_path = $1; return &handle_media_upload($file_path); } + + # Poll creation commands + if (m#^/poll\s+(.+)$#) { + my $poll_text = $1; + return &handle_poll_creation($poll_text, 0); # Single choice + } + if (m#^/poll\s*$#) { + print $stdout "-- ERROR: Poll requires question text. Usage: /poll Your question here\n"; + return 0; + } + if (m#^/mpoll\s+(.+)$#) { + my $poll_text = $1; + return &handle_poll_creation($poll_text, 1); # Multiple choice + } + if (m#^/mpoll\s*$#) { + print $stdout "-- ERROR: Multiple choice poll requires question text. Usage: /mpoll Your question here\n"; + return 0; + } if (m#^/a(gain)?(\s+\+\d+)?$#) { # the asynchronous form my $countmaybe = $2; $countmaybe =~ s/[^\d]//g if (length($countmaybe)); @@ -3406,7 +3454,7 @@ EOF } # this is dual-headed and supports both lists and regular followers. - if(s#^/(frs|friends|fos|followers)(\s+\+\d+)?\s*##) { + if(s#^/(frs|friends|following|fos|followers)(\s+\+\d+)?\s*##) { my $countmaybe = $2; my $mode = $1; my $arg = lc($_); @@ -3423,9 +3471,9 @@ EOF } $who ||= $whoami; if (!length($lname)) { - $what = ($mode eq 'frs' || $mode eq 'friends') - ? "friends" : "followers"; - $mode = ($mode eq 'frs' || $mode eq 'friends') + $what = ($mode eq 'frs' || $mode eq 'friends' || $mode eq 'following') + ? "following" : "followers"; + $mode = ($mode eq 'frs' || $mode eq 'friends' || $mode eq 'following') ? $friendsurl : $followersurl; } else { # List members/followers - fediverse only supports list members, not subscribers @@ -3480,59 +3528,31 @@ EOF return 0; } - # Use proper Mastodon API endpoint with account ID + # Use proper Mastodon API endpoint with account ID - much simpler than Twitter! my $followers_url = $mode; $followers_url =~ s/%I/$account_id/g; - my $accounts_ref = &grabjson("$followers_url?limit=${countmaybe}", 0, 0, 0, undef, 1); + + # Fediverse API directly returns account objects, no need for separate lookup + my $limit = &min($countmaybe, 80); # Mastodon default max is 80 + my $accounts_ref = &grabjson("$followers_url?limit=$limit", 0, 0, 0, undef, 1); + + print $stdout "-- DEBUG: API response type: " . (ref($accounts_ref) || 'not a reference') . "\n" if ($verbose); + if (ref($accounts_ref) eq 'ARRAY') { + print $stdout "-- DEBUG: Number of accounts in response: " . scalar(@{ $accounts_ref }) . "\n" if ($verbose); + } + return 0 if (!$accounts_ref || ref($accounts_ref) ne 'ARRAY'); - # Fediverse (Mastodon/Pleroma/etc) returns array of account objects, extract IDs - my @ids = map { $_->{'id'} } @{ $accounts_ref }; - @ids = sort { 0+$a <=> 0+$b } @ids; - # make it somewhat deterministic - - my $dount = &min($countmaybe, scalar(@ids)); - my $swallow = &min(100, $dount); - my @usarray = undef; shift(@usarray); # force underflow - my $l_ref = undef; - - # for each block of $countper, emit + print $stdout "-- $what for $who:\n"; my $printed = 0; - - FFABIO: while ($dount--) { - if (!scalar(@usarray)) { - my @next_ids; - - last FFABIO if (!scalar(@ids)); - - # if we asked for less than 100, get - # that. otherwise, - # get the top 100 off that list (or - # the list itself, if 100 or less) - if (scalar(@ids) <= $swallow) { - @next_ids = @ids; - @ids = (); - } else { - @next_ids = - @ids[0..($swallow-1)]; - @ids = @ids[$swallow..$#ids]; - } - - # turn it into a list to pass to - # lookupidurl and get the list - $l_ref = &postjson($lookupidurl, - "user_id=".&url_oauth_sub(join(',', @next_ids))); - last FFABIO if(ref($l_ref) ne 'ARRAY'); - @usarray = sort - { 0+($a->{'id'}) <=> 0+($b->{'id'}) } - @{ $l_ref }; - last if (!scalar(@usarray)); - } - &$userhandle(shift(@usarray)); + for my $account (@{ $accounts_ref }) { + # Fediverse returns complete account objects - just display them + print $stdout "-- DEBUG: Processing account: " . ($account->{'username'} || 'no-username') . " / " . ($account->{'acct'} || 'no-acct') . "\n" if ($verbose); + &$userhandle($account); $printed++; } - print $stdout "-- sorry, no $what found for $who.\n" - if (!$printed); + + print $stdout "-- sorry, no $what found for $who.\n" if (!$printed); return 0; } @@ -4065,14 +4085,33 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) { } # Use Mastodon's acct field (includes @domain for remote users) or username for local users my $target; - my $acct = &descape($post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - $target = $acct; + my $acct = &descape($post->{'account'}->{'acct'} || $post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - # If acct doesn't include @domain and this is a remote post, construct it - if ($acct !~ /\@/ && $post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { - my $domain = $1; - if ($domain ne $fediverseserver) { + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $target = $acct; + } else { + # No domain - extract from post URL, account URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($post->{'account'}->{'url'} && $post->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'account'}->{'uri'} && $post->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'uri'} && $post->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { $target = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for user $acct, using local server\n" if ($verbose); + $target = "$acct\@$fediverseserver"; } } print $stdout "-- DEBUG: Reply target acct='$post->{'user'}->{'acct'}', username='$post->{'user'}->{'username'}', url='$post->{'url'}', using='$target'\n" if ($verbose); @@ -4105,7 +4144,36 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) { return 0; } # in the future, add DM in_reply_to here - my $target = &descape($dm->{'last_status'}->{'account'}->{'acct'} || $dm->{'last_status'}->{'account'}->{'username'}); + my $acct = &descape($dm->{'last_status'}->{'account'}->{'acct'} || $dm->{'last_status'}->{'account'}->{'username'}); + my $target; + + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $target = $acct; + } else { + # No domain - extract from DM account URL, URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($dm->{'last_status'}->{'account'}->{'url'} && $dm->{'last_status'}->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($dm->{'last_status'}->{'url'} && $dm->{'last_status'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($dm->{'last_status'}->{'account'}->{'uri'} && $dm->{'last_status'}->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($dm->{'last_status'}->{'uri'} && $dm->{'last_status'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { + $target = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for DM user $acct, using local server\n" if ($verbose); + $target = "$acct\@$fediverseserver"; + } + } if ($termrl) { $readline_completion{'@'.lc($target)}++; &save_completion_cache; @@ -4128,14 +4196,33 @@ m#^/(un)?f(boost|a|av|ave|avorite|avourite)? ([zZ]?[a-zA-Z]?[0-9]+)$#) { } # Use Mastodon's acct field (includes @domain for remote users) or username for local users my $target; - my $acct = &descape($post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - $target = $acct; + my $acct = &descape($post->{'account'}->{'acct'} || $post->{'user'}->{'acct'} || $post->{'user'}->{'username'}); - # If acct doesn't include @domain and this is a remote post, construct it - if ($acct !~ /\@/ && $post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { - my $domain = $1; - if ($domain ne $fediverseserver) { + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $target = $acct; + } else { + # No domain - extract from post URL, account URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($post->{'account'}->{'url'} && $post->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'url'} && $post->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'account'}->{'uri'} && $post->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post->{'uri'} && $post->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { $target = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for user $acct, using local server\n" if ($verbose); + $target = "$acct\@$fediverseserver"; } } my $text = $_; @@ -4284,6 +4371,12 @@ EOF &dmthump; return 0; } + + # Notifications + if ($_ eq '/notifications' || $_ eq '/notificationrefresh' || $_ eq '/nr') { + ¬ificationthump; + return 0; + } # /dmsent, /dmagain if (m#^/dm(s|sent|a|again)(\s+\+\d+)?$#) { my $mode = $1; @@ -4838,7 +4931,36 @@ sub reply_to_all { # Get the post content and author my $post_content = $post_ref->{'text'} || ''; - my $post_author = $post_ref->{'user'}->{'acct'} || $post_ref->{'user'}->{'username'} || ''; + my $acct = $post_ref->{'account'}->{'acct'} || $post_ref->{'user'}->{'acct'} || $post_ref->{'user'}->{'username'} || ''; + my $post_author; + + # Always ensure we have the full @domain format for federation + if ($acct =~ /\@/) { + # Already has domain, use as-is + $post_author = $acct; + } else { + # No domain - extract from post URL, account URI, or other sources + my $domain; + + # Try multiple sources for domain information + if ($post_ref->{'account'}->{'url'} && $post_ref->{'account'}->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post_ref->{'url'} && $post_ref->{'url'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post_ref->{'account'}->{'uri'} && $post_ref->{'account'}->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } elsif ($post_ref->{'uri'} && $post_ref->{'uri'} =~ m{^https?://([^/]+)/}) { + $domain = $1; + } + + if ($domain) { + $post_author = "$acct\@$domain"; + } else { + # Last resort fallback - but this should not happen for federation + print $stdout "-- WARNING: Could not determine domain for user $acct in reply-to-all, using local server\n" if ($verbose); + $post_author = "$acct\@$fediverseserver"; + } + } # Set up reply-to ID my $in_reply_to = $post_ref->{'id_str'} || $post_ref->{'id'}; @@ -4953,6 +5075,7 @@ sub sub_helper { sub sync_console { &thump; &dmthump unless (!$dmpause); + ¬ificationthump unless (!$notificationpause); } sub sync_semaphore { if ($synch) { @@ -5044,6 +5167,12 @@ $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 +$notification_first_time = ($notificationpause) ? 1 : 0; +$notification_display_only = 0; # Flag to suppress notifications during user-initiated notification commands +$notification_notification_sent = 0; # Flag to prevent duplicate notifications per refresh cycle +$last_notification_marker = ''; # Track last read notification ID via markers API +# Notification tracking uses markers API for server-side read status +%notification_seen = (); # Hash to track seen notification IDs locally # 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 @@ -5062,6 +5191,21 @@ for(;;) { (!$effpause && !$interactive); $dont_refresh_first_time = 0; $previous_last_id = $last_id; + if ($notificationpause && ($effpause || $synch)) { + if ($notification_first_time) { + ¬ificationrefresh(0); + $notificationcount = $notificationpause; + } elsif (!$interactive) { + print $stdout "-- DEBUG: Notification countdown: $notificationcount -> " . ($notificationcount - 1) . "\n" if ($verbose); + if (!--$notificationcount) { + print $stdout "-- DEBUG: Triggering background notification refresh\n" if ($verbose); + ¬ificationrefresh($interactive); + $notificationcount = $notificationpause; + } + } else { + print $stdout "-- DEBUG: Skipping notification countdown (interactive=$interactive)\n" if ($verbose); + } + } if ($dmpause && ($effpause || $synch)) { if ($dm_first_time) { &dmrefresh(0); @@ -5259,7 +5403,7 @@ EOF $key->{'tag'}->{'type'}. " ". # NO SPACES! unpack("${pack_magic}H*", $key->{'tag'}->{'payload'}). " ". ($key->{'reblogs_count'} || "0") . " " . - ($key->{'user'}->{'username'} || $key->{'user'}->{'acct'})." $ds $src|". + ($key->{'user'}->{'username'} || $key->{'user'}->{'acct'})." ".($key->{'user'}->{'acct'} || $key->{'user'}->{'username'})." $ds $src|". unpack("${pack_magic}H*", $key->{'text'}). $space_pad), 0, 1024); print P $key; @@ -5414,6 +5558,11 @@ EOF &send_repaint if ($termrl); $dmcount = $dmpause; goto DONT_REFRESH; + } elsif ($rout =~ /^notificationthump/) { + ¬ificationrefresh($interactive); + &send_repaint if ($termrl); + $notificationcount = $notificationpause; + goto DONT_REFRESH; } } } else { @@ -6661,7 +6810,23 @@ sub updatest { } else { if (length($user_name_dm)) { # For DMs: include mention in status and set visibility - my $dm_status = "\@${user_name_dm} ${string}"; + # Ensure we have the full @domain format for federation + my $dm_target = $user_name_dm; + if ($dm_target !~ /\@/) { + # No domain - try to find full format in completion cache + my $found_full = undef; + foreach my $cached_user (keys %readline_completion) { + if ($cached_user =~ /^\@(.+)\@(.+)$/) { + my ($cached_username) = ($1); + if (lc($cached_username) eq lc($dm_target)) { + $found_full = substr($cached_user, 1); # Remove @ prefix + last; + } + } + } + $dm_target = $found_full || $dm_target; + } + my $dm_status = "\@${dm_target} ${string}"; my $dm_urle = ''; foreach my $char (unpack("${pack_magic}C*", $dm_status)) { my $k = chr($char); @@ -6746,6 +6911,171 @@ EOF return 0; } +# refresh for notifications +sub notificationrefresh { + my $interactive = shift; + + # Reset notification flag for this refresh cycle + $notification_notification_sent = 0; + + if ($anonymous) { + print $stdout + "-- sorry, you can't read notifications if you're anonymous.\n" + if ($interactive); + return; + } + + # no point in doing this if we can't even get to our own timeline + # (unless user specifically requested it, or our timeline is off) + return if (!$interactive && !$last_id && !$notimeline); + + # Get current markers to see what's been read + my $markers_ref = &grabjson($markersurl . "?timeline[]=notifications", + 0, 0, 0, undef, 1); + + my $since_id = 0; + if (defined($markers_ref) && ref($markers_ref) eq 'HASH' && + $markers_ref->{'notifications'} && + $markers_ref->{'notifications'}->{'last_read_id'}) { + $since_id = $markers_ref->{'notifications'}->{'last_read_id'}; + $last_notification_marker = $since_id; + print $stdout "-- DEBUG: Got notification marker: $since_id\n" if ($verbose); + } + + # Fetch notifications since the marker + my $my_json_ref = &grabjson($notificationurl, $since_id, 0, + ($interactive ? 20 : 50), undef, 1); + + return if (!defined($my_json_ref) || ref($my_json_ref) ne 'ARRAY'); + + my $printed = 0; + my $max_id = 0; + my $disp_max = &min($print_max, scalar(@{ $my_json_ref })); + + print $stdout "-- DEBUG: Notification response: " . scalar(@{ $my_json_ref }) . " notifications, disp_max=$disp_max\n" if ($verbose); + + # For background refresh, check if there are any new notifications first + if (!$interactive && $disp_max) { + my $has_new = 0; + for (my $check_i = 0; $check_i < $disp_max; $check_i++) { + my $check_notif = $my_json_ref->[$check_i]; + next if (!$check_notif->{'id'}); + my $notif_id = $check_notif->{'id'}; + + # Skip if we've already seen this notification locally + if ($notification_seen{$notif_id}) { + print $stdout "-- DEBUG: Skipping notification $notif_id - already seen locally\n" if ($verbose); + next; + } + + $has_new = 1; + last; + } + if (!$has_new) { + print $stdout "-- DEBUG: No new notifications found in background refresh, returning early\n" if ($verbose); + return; + } + print $stdout "-- DEBUG: Found new notifications in background refresh, proceeding\n" if ($verbose); + } + + if ($disp_max) { + if ($notification_first_time) { + sleep 5 while ($suspend_output > 0); + &send_removereadline if ($termrl); + print $stdout + "-- checking for most recent notifications:\n"; + $disp_max = 3; + $interactive = 1; + $notification_display_only = 1; # Suppress sounds during first-time load + print $stdout "-- DEBUG: notification_first_time: disp_max reduced to $disp_max, sounds suppressed\n" if ($verbose); + } + + print $stdout "-- DEBUG: Starting notification display loop: $disp_max notifications\n" if ($verbose); + for(my $i = $disp_max; $i > 0; $i--) { + my $g = ($i-1); + my $notif = $my_json_ref->[$g]; + print $stdout "-- DEBUG: Processing notification #$i (index $g)\n" if ($verbose); + + # Skip if missing data + if (!$notif->{'id'} || !$notif->{'type'}) { + print $stdout "-- DEBUG: Skipping notification #$i - missing id or type\n" if ($verbose); + next; + } + + my $notif_id = $notif->{'id'}; + + # For background refresh, skip notifications we've already seen locally + if (!$interactive) { + if ($notification_seen{$notif_id}) { + print $stdout "-- DEBUG: Skipping notification #$i - already seen locally (id: $notif_id)\n" if ($verbose); + next; + } + + # Mark as seen locally for this session + $notification_seen{$notif_id} = 1; + print $stdout "-- DEBUG: Marked notification $notif_id as seen locally\n" if ($verbose); + } + + # Track highest ID for marker update + my $current_id = 0+$notif_id; + $max_id = $current_id if ($current_id > $max_id); + + # Process notification with proper type mapping for sounds + my $type = $notif->{'type'} || 'default'; + 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'); + + print $stdout "-- DEBUG: Processing notification type '$type' as sound class '$sound_class'\n" if ($verbose); + + # Use existing notification display handler + if ($notification_display_only) { + # During first-time load, display without sounds + print $stdout "-- DEBUG: Displaying notification without sound (display_only mode)\n" if ($verbose); + &$handle($notif, ''); # Empty class = no sound + } else { + ¬ifications_tdisplay([ $notif ]); + } + $printed++; + } + } + + sleep 5 while ($suspend_output > 0); + if (($interactive || $verbose) && !$printed && !$notification_first_time) { + &send_removereadline if ($termrl); + print $stdout "-- no new notifications.\n"; + $wrapseq = 1; + } + + # Update marker to highest ID we processed (for background refresh or manual check) + if (!$interactive || $notification_first_time || ($interactive && $max_id > 0)) { + if ($max_id > 0) { + my $marker_data = "notifications%5Blast_read_id%5D=$max_id"; + my $marker_result = &backticks($baseagent, '/dev/null', undef, + $markersurl, $marker_data, 0, @wend); + + if ($? == 0) { + $last_notification_marker = $max_id; + print $stdout "-- DEBUG: Updated notification marker to $max_id\n" if ($verbose); + } else { + print $stdout "-- DEBUG: Failed to update notification marker (exit code: $?)\n" if ($verbose); + } + } + + print $stdout "-- DEBUG: Updated last_notification_marker to $last_notification_marker (interactive=$interactive, notification_first_time=$notification_first_time, max_id=$max_id)\n" if ($verbose); + } else { + print $stdout "-- DEBUG: NOT updating notification marker (interactive=$interactive, keeping last_notification_marker=$last_notification_marker)\n" if ($verbose); + } + + $notification_first_time = 0 if ($max_id || !scalar(@{ $my_json_ref })); + $notification_display_only = 0; # Reset sound suppression flag + print $stdout "-- notification bookmark is $last_notification_marker.\n" if ($verbose); + + return 0; +} + # this dispatch routine replaces the common logic of deletest, deletedm, # follow, leave and the favourites system. # this is a modified, abridged version of &updatest. @@ -6834,10 +7164,48 @@ sub foruuser { return 1; } + # Substitute account ID into URL template + my $api_url = $basef; + $api_url =~ s/%I/$account_id/g; + + print $stdout "-- DEBUG: Calling follow/unfollow API with account_id=$account_id, basef=$basef\n" if ($verbose); my ($en, $em) = ¢ral_cd_dispatch("id=$account_id", - $interactive, $basef); - print $stdout "-- ok, you have $verb following user $uname.\n" - if ($interactive && !$en); + $interactive, $api_url); + print $stdout "-- DEBUG: Follow API returned - error code: $en, message length: " . length($em) . "\n" if ($verbose); + print $stdout "-- DEBUG: Follow API response: $em\n" if ($verbose && $em); + + # Check for HTTP error responses in the message + if (!$en && $em && $em =~ /