From f1764867b58d7f500282265756170f640a88fec1 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:19:40 +0100 Subject: [PATCH 1/2] Share play invitations reach the Notification Center and show without a party --- crates/anyps5-core/src/notifications.rs | 2 + .../anyps5-launcher/src/app/online_share.rs | 101 +++++++++++++++++- crates/anyps5-launcher/src/app/panels.rs | 2 +- .../src/app/panels/notifications.rs | 18 ++++ crates/anyps5-launcher/src/app/preview.rs | 49 ++++++++- .../src/screens/online/share_play.rs | 6 +- .../src/screens/online/share_play/tests.rs | 14 +++ docs/DESIGN.md | 1 + 8 files changed, 182 insertions(+), 11 deletions(-) diff --git a/crates/anyps5-core/src/notifications.rs b/crates/anyps5-core/src/notifications.rs index 565b5659..56241cc3 100644 --- a/crates/anyps5-core/src/notifications.rs +++ b/crates/anyps5-core/src/notifications.rs @@ -41,6 +41,8 @@ pub enum Act { JoinInvitation(String), /// Join the party this invitation (its ID) is for. JoinParty(String), + /// Accept the share play invitation (its ID) from a party member. + JoinShare(String), /// Open the friends list on its first page. OpenFriends, /// Open the party page. diff --git a/crates/anyps5-launcher/src/app/online_share.rs b/crates/anyps5-launcher/src/app/online_share.rs index 852ea369..ab5223ac 100644 --- a/crates/anyps5-launcher/src/app/online_share.rs +++ b/crates/anyps5-launcher/src/app/online_share.rs @@ -2,6 +2,7 @@ //! asks for another mode, changes what they may do, or ends the share, and when a command is //! refused (docs/DESIGN.md 17, "Share play"). +use anyps5_core::notifications::{Act, Kind}; use anyps5_core::online::api::ShareMode; use anyps5_core::online::{Failure, SharePlayNotice}; @@ -11,16 +12,28 @@ use crate::app::overlay::Overlay; use crate::screens::online_share_strings::ShareText as X; use crate::ui::i18n::Arg; use crate::ui::icons::Icon; +use crate::ui::sound::Cue; /// Something about a share changed under the player. pub fn notice(model: &mut Model, notice: SharePlayNotice) { let language = model.language(); match notice { SharePlayNotice::Invited(invitation) => { - // The open panel already shows the new rows. - if model.overlay != Some(Overlay::Friends) { - let text = X::ToastInvited.format(language, &[Arg::Str(&invitation.from.handle)]); - model.toast(text, Icon::GAME_CONTROLLER); + // The history keeps it either way, like a party invitation: the guest may be on + // another page, and the invitation runs out. The open panel already shows the new + // rows, so only there the toast stays away. + let text = X::ToastInvited.format(language, &[Arg::Str(&invitation.from.handle)]); + let act = Act::JoinShare(invitation.id.clone()); + if model.overlay == Some(Overlay::Friends) { + model.note(Kind::Invitation, text, act); + } else { + model.announce( + Kind::Invitation, + act, + text, + Icon::GAME_CONTROLLER, + Cue::Invite, + ); } } SharePlayNotice::Requested(_) => { @@ -66,3 +79,83 @@ pub fn failed(model: &mut Model, failure: &Failure) { }; model.toast(text, Icon::WARNING); } + +#[cfg(test)] +mod tests { + use anyps5_core::online::api::{Account, AccountId, ShareInvitation}; + use anyps5_core::online::control::Event; + use anyps5_core::online::{Line, Output}; + use anyps5_core::paths::AppPaths; + use anyps5_core::settings::Preferences; + + use super::*; + use crate::app::panels::notifications::primary_of; + use crate::screens::notification_strings::NotifyText; + + fn invitation() -> ShareInvitation { + ShareInvitation { + id: "i1".into(), + from: Account { + id: AccountId(2), + handle: "Lumen".into(), + }, + share: "s1".into(), + title_id: "T".into(), + title: "Astro Bot".into(), + mode: ShareMode::Watch, + expires: 99, + } + } + + fn model_with_invitation() -> Model { + let mut model = Model::new( + Preferences::default(), + AppPaths::under(std::path::Path::new("C:/none")), + false, + ); + model + .online + .apply(Line::Output(Output::Event(Event::ShareInvitation { + invitation: invitation(), + }))); + model + } + + #[test] + fn an_invitation_is_toasted_and_kept_in_the_history_with_an_accept() { + let mut model = model_with_invitation(); + notice(&mut model, SharePlayNotice::Invited(invitation())); + assert_eq!( + model.toast.as_ref().map(|t| t.text.as_str()), + Some("Lumen invites you to share play") + ); + let entry = model.notes.newest_first().next().expect("an entry"); + assert_eq!(entry.kind, Kind::Invitation); + assert_eq!(entry.act, Act::JoinShare("i1".into())); + assert_eq!( + primary_of(&entry.act, &model), + Some(NotifyText::Accept), + "the entry has the primary answer while the invitation waits" + ); + } + + #[test] + fn with_the_friends_panel_open_only_the_history_gets_it() { + let mut model = model_with_invitation(); + model.overlay = Some(Overlay::Friends); + notice(&mut model, SharePlayNotice::Invited(invitation())); + assert!(model.toast.is_none()); + assert_eq!(model.notes.newest_first().count(), 1); + } + + #[test] + fn an_answered_or_expired_invitation_leaves_no_button() { + let mut model = model_with_invitation(); + model.online.apply(Line::Output(Output::Event(Event::Share { + can_start: false, + share: None, + }))); + let act = Act::JoinShare("gone".into()); + assert_eq!(primary_of(&act, &model), None); + } +} diff --git a/crates/anyps5-launcher/src/app/panels.rs b/crates/anyps5-launcher/src/app/panels.rs index a2dff205..c731aa0a 100644 --- a/crates/anyps5-launcher/src/app/panels.rs +++ b/crates/anyps5-launcher/src/app/panels.rs @@ -26,7 +26,7 @@ mod compare; mod friends; mod hub; pub(crate) mod lfg; -mod notifications; +pub(crate) mod notifications; mod profile; use std::collections::HashMap; diff --git a/crates/anyps5-launcher/src/app/panels/notifications.rs b/crates/anyps5-launcher/src/app/panels/notifications.rs index f0367427..d0f80d6e 100644 --- a/crates/anyps5-launcher/src/app/panels/notifications.rs +++ b/crates/anyps5-launcher/src/app/panels/notifications.rs @@ -5,6 +5,7 @@ use anyps5_core::links::SettingsPage; use anyps5_core::notifications::Act; +use anyps5_core::online::SharePlayCommand; use anyps5_core::online::api::AccountId; use super::PanelHost; @@ -40,6 +41,12 @@ pub(crate) fn primary_of(act: &Act, model: &Model) -> Option { .iter() .any(|i| i.id == *id) .then_some(NT::Join), + Act::JoinShare(id) => online + .share_play + .invitations() + .iter() + .any(|i| i.id == *id) + .then_some(NT::Accept), Act::OpenTrophies(game) => model .games .iter() @@ -163,6 +170,17 @@ impl PanelHost { ); model.notes.remove(id); } + // The same command as the Accept row on the party page. + Act::JoinShare(invitation) => { + online_action( + model, + OnlineAction::SharePlay(SharePlayCommand::Answer { + id: invitation, + accept: true, + }), + ); + model.notes.remove(id); + } Act::OpenFriends => self.open_friends_on(model, None), Act::OpenParty => self.open_friends_on(model, Some(Page::Party)), Act::OpenMessages(None) => self.open_friends_on(model, Some(Page::Messages)), diff --git a/crates/anyps5-launcher/src/app/preview.rs b/crates/anyps5-launcher/src/app/preview.rs index 40b206b6..fc0b0f82 100644 --- a/crates/anyps5-launcher/src/app/preview.rs +++ b/crates/anyps5-launcher/src/app/preview.rs @@ -179,6 +179,10 @@ pub enum Scene { /// The Notification Center with a message longer than two lines: the sentence is cut and the /// chip reads it whole once the focus has rested on the entry. NotificationsLong, + /// The Notification Center with a share play invitation on top of the older entries. + NotificationsShare, + /// An empty library with a share play invitation waiting: the bell carries the badge. + HomeEmptyShare, /// Signed in, the session just ended: the question "How did it run?" below the facts. HomeAsk, /// Signed in, enough players reported: the Runs fact. @@ -281,6 +285,8 @@ impl Scene { "notifications" => Scene::Notifications, "notifications-empty" => Scene::NotificationsEmpty, "notifications-long" => Scene::NotificationsLong, + "notifications-share" => Scene::NotificationsShare, + "home-empty-share" => Scene::HomeEmptyShare, "home-ask" => Scene::HomeAsk, "home-runs" => Scene::HomeRuns, "home-friends-playing" => Scene::HomeFriendsPlaying, @@ -315,7 +321,7 @@ impl Scene { }) } - pub const ALL_NAMES: [&'static str; 67] = [ + pub const ALL_NAMES: [&'static str; 69] = [ "onboarding-language", "onboarding-look", "onboarding-look-access", @@ -362,6 +368,8 @@ impl Scene { "notifications", "notifications-empty", "notifications-long", + "notifications-share", + "home-empty-share", "home-ask", "home-runs", "home-friends-playing", @@ -829,7 +837,7 @@ pub fn apply_scene(m: &mut crate::app::model::Model, scene: Scene, games: Vec { + notify_sample(m, &ids[at(1)], 2); + share_invitation_sample(m); + m.switch_overlay(crate::app::overlay::Overlay::Notifications); + } + Scene::HomeEmptyShare => share_invitation_sample(m), Scene::HomeMessages => { use anyps5_core::online::api::{ Account, AccountId, Conversation, ConversationKind, Conversations, @@ -1268,6 +1282,37 @@ pub fn apply_scene(m: &mut crate::app::model::Model, scene: Scene, games: Vec, rows: &mut Vec) { rows.extend(group); } -/// The line on Home: only while there is something to say, and only in a party. +/// The line on Home: only while there is something to say. An invitation shows even before +/// the player is in a party: it must be seen. pub(super) fn home_row(data: &OnlineData<'_>, rows: &mut Vec) { let online = data.online; let play = &online.share_play; let language = data.language; - if online.social.party.is_none() { - return; - } let detail = if let Some(share) = play.share() { let name = share.peer.handle.as_str(); match share.role { diff --git a/crates/anyps5-launcher/src/screens/online/share_play/tests.rs b/crates/anyps5-launcher/src/screens/online/share_play/tests.rs index d149640e..07c99fda 100644 --- a/crates/anyps5-launcher/src/screens/online/share_play/tests.rs +++ b/crates/anyps5-launcher/src/screens/online/share_play/tests.rs @@ -321,3 +321,17 @@ fn a_stalled_picture_is_said_in_words() { Some("The picture has stopped") ); } + +#[test] +fn an_invitation_shows_on_home_even_before_the_player_is_in_a_party() { + let mut online = online(); + online.social.apply_party(None); + invite(&mut online, "i1", ShareMode::Watch); + let mut rows = Vec::new(); + home_row(&data(&online), &mut rows); + assert_eq!(ids(&rows), ["sharePlay"]); + assert_eq!( + rows[0].detail.as_deref(), + Some("Lumen invites you to watch Astro Bot") + ); +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9a14bd6c..bbf974d2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -3412,6 +3412,7 @@ Gallery scenes: `friends-screen-ready`, `friends-screen-hosting`, `friends-scree - *The host.* While the invitation waits: "Waiting for Lumen" (value "Invitation sent") and Cancel Invitation. Then "Lumen sees your game" (value Connecting… / Live / The picture has stopped, detail the game and the path), the Mode row (Watch, Take over, Together; one choice row, the note under it says what the mode means, and that Select and Start held for a second take the pad back), and Stop Sharing. A guest's request shows as a sentence ("Lumen would like to play with your pad", "Choose a mode below to agree.") and changes nothing. - *The guest.* An invitation is a sentence ("Lumen invites you to play Astro Bot with their pad") with Accept (the first row, the primary action) and Decline. In a share: "Lumen shares Astro Bot" (phase, path; the picture opens in its own window), "You" with what the mode lets them do, Ask to Take Over and Ask to Play Together for the modes they do not have ("The host decides."), and Leave. - *Toasts.* An invitation, a request, a change of what the guest may do, and the end, each one plain sentence; a refused command toasts one plain sentence (`no_game`, `not_in_party`, `busy`, `no_encoder`, `no_decoder`, `not_found`, `not_allowed`). Strings are in English, Turkish and German; Spanish, French and Portuguese follow with the languages pull request. + - *Seen from anywhere (0.3.9).* A share invitation expires, and the guest is not always on the party page (a real run had them on Notifications and on an empty Home). So an invitation does what a party invitation does: the toast shows on every page but the open Friends panel, and the history gets the entry either way ("Lumen invites you to share play", kind Invitation, `Act::JoinShare`) with Accept as its button and Dismiss. Accept runs the same `SharePlayCommand::Answer` as the Accept row on the party page and takes the entry with it; once the invitation is answered or has run out the entry has no button. Declining stays on the party page, as it does for party invitations. An unread entry puts the badge on the bell, so an empty library ("No games yet") shows the invitation too; the Friends Home row "Share Play" no longer waits for a party when an invitation is the reason for it. No new strings. Scenes: `notifications-share`, `home-empty-share`. - *Not here.* The picture, the codec and the numbers stay with the worker's window. Stream and input delay are not reported by the net service yet. Gallery scenes: `friends-share-ready`, `friends-share-waiting`, `friends-share-hosting`, `friends-share-invited`, `friends-share-guest`, `friends-share-home`. From e432b0104699d2bde32d198e4592ce367c98f128 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:30:13 +0100 Subject: [PATCH 2/2] Gallery: set up the empty-library share invitation scene before the early return --- crates/anyps5-launcher/src/app/preview.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/anyps5-launcher/src/app/preview.rs b/crates/anyps5-launcher/src/app/preview.rs index fc0b0f82..f82d766a 100644 --- a/crates/anyps5-launcher/src/app/preview.rs +++ b/crates/anyps5-launcher/src/app/preview.rs @@ -847,6 +847,9 @@ pub fn apply_scene(m: &mut crate::app::model::Model, scene: Scene, games: Vec share_invitation_sample(m), Scene::HomeMessages => { use anyps5_core::online::api::{ Account, AccountId, Conversation, ConversationKind, Conversations,