Show current plan in user menu (#15513)

Marshall Bowers and Max created

This PR updates the user menu to show the user's current plan.

Also adds a new RPC message to send this information down to the client
when Zed starts.

This is behind a feature flag.

Release Notes:

- N/A

---------

Co-authored-by: Max <max@zed.dev>

Change summary

Cargo.lock                                |  1 
crates/client/src/user.rs                 | 19 +++++++++++++
crates/collab/src/rpc.rs                  | 23 ++++++++++++++++
crates/feature_flags/src/feature_flags.rs |  5 +++
crates/proto/proto/zed.proto              | 12 +++++++
crates/proto/src/proto.rs                 |  1 
crates/title_bar/Cargo.toml               |  1 
crates/title_bar/src/title_bar.rs         | 35 ++++++++++++++++++------
crates/zed/src/zed.rs                     |  8 +++++
crates/zed_actions/src/lib.rs             |  1 
10 files changed, 95 insertions(+), 11 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -11186,6 +11186,7 @@ dependencies = [
  "dev_server_projects",
  "editor",
  "extensions_ui",
+ "feature_flags",
  "feedback",
  "gpui",
  "http_client",

crates/client/src/user.rs πŸ”—

@@ -92,6 +92,7 @@ pub struct UserStore {
     by_github_login: HashMap<String, u64>,
     participant_indices: HashMap<u64, ParticipantIndex>,
     update_contacts_tx: mpsc::UnboundedSender<UpdateContacts>,
+    current_plan: Option<proto::Plan>,
     current_user: watch::Receiver<Option<Arc<User>>>,
     contacts: Vec<Arc<Contact>>,
     incoming_contact_requests: Vec<Arc<User>>,
@@ -139,6 +140,7 @@ impl UserStore {
         let (mut current_user_tx, current_user_rx) = watch::channel();
         let (update_contacts_tx, mut update_contacts_rx) = mpsc::unbounded();
         let rpc_subscriptions = vec![
+            client.add_message_handler(cx.weak_model(), Self::handle_update_plan),
             client.add_message_handler(cx.weak_model(), Self::handle_update_contacts),
             client.add_message_handler(cx.weak_model(), Self::handle_update_invite_info),
             client.add_message_handler(cx.weak_model(), Self::handle_show_contacts),
@@ -147,6 +149,7 @@ impl UserStore {
             users: Default::default(),
             by_github_login: Default::default(),
             current_user: current_user_rx,
+            current_plan: None,
             contacts: Default::default(),
             incoming_contact_requests: Default::default(),
             participant_indices: Default::default(),
@@ -280,6 +283,18 @@ impl UserStore {
         Ok(())
     }
 
+    async fn handle_update_plan(
+        this: Model<Self>,
+        message: TypedEnvelope<proto::UpdateUserPlan>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| {
+            this.current_plan = Some(message.payload.plan());
+            cx.notify();
+        })?;
+        Ok(())
+    }
+
     fn update_contacts(
         &mut self,
         message: UpdateContacts,
@@ -657,6 +672,10 @@ impl UserStore {
         self.current_user.borrow().clone()
     }
 
+    pub fn current_plan(&self) -> Option<proto::Plan> {
+        self.current_plan
+    }
+
     pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
         self.current_user.clone()
     }

crates/collab/src/rpc.rs πŸ”—

@@ -1137,6 +1137,8 @@ impl Server {
                         .await?;
                 }
 
+                update_user_plan(user.id, session).await?;
+
                 let (contacts, dev_server_projects) = future::try_join(
                     self.app_state.db.get_contacts(user.id),
                     self.app_state.db.dev_server_projects_update(user.id),
@@ -3535,6 +3537,27 @@ fn should_auto_subscribe_to_channels(version: ZedVersion) -> bool {
     version.0.minor() < 139
 }
 
+async fn update_user_plan(user_id: UserId, session: &Session) -> Result<()> {
+    let db = session.db().await;
+    let active_subscriptions = db.get_active_billing_subscriptions(user_id).await?;
+
+    let plan = if session.is_staff() || !active_subscriptions.is_empty() {
+        proto::Plan::ZedPro
+    } else {
+        proto::Plan::Free
+    };
+
+    session
+        .peer
+        .send(
+            session.connection_id,
+            proto::UpdateUserPlan { plan: plan.into() },
+        )
+        .trace_err();
+
+    Ok(())
+}
+
 async fn subscribe_to_channels(_: proto::SubscribeToChannels, session: Session) -> Result<()> {
     subscribe_user_to_channels(
         session.user_id().ok_or_else(|| anyhow!("must be a user"))?,

crates/feature_flags/src/feature_flags.rs πŸ”—

@@ -48,6 +48,11 @@ impl FeatureFlag for GroupedDiagnostics {
     const NAME: &'static str = "grouped-diagnostics";
 }
 
+pub struct ZedPro {}
+impl FeatureFlag for ZedPro {
+    const NAME: &'static str = "zed-pro";
+}
+
 pub trait FeatureFlagViewExt<V: 'static> {
     fn observe_flag<T: FeatureFlag, F>(&mut self, callback: F) -> Subscription
     where

crates/proto/proto/zed.proto πŸ”—

@@ -126,6 +126,7 @@ message Envelope {
         Unfollow unfollow = 101;
         GetPrivateUserInfo get_private_user_info = 102;
         GetPrivateUserInfoResponse get_private_user_info_response = 103;
+        UpdateUserPlan update_user_plan = 234; // current max
         UpdateDiffBase update_diff_base = 104;
 
         OnTypeFormatting on_type_formatting = 105;
@@ -256,7 +257,7 @@ message Envelope {
         OpenContext open_context = 212;
         OpenContextResponse open_context_response = 213;
         CreateContext create_context = 232;
-        CreateContextResponse create_context_response = 233; // current max
+        CreateContextResponse create_context_response = 233;
         UpdateContext update_context = 214;
         SynchronizeContexts synchronize_contexts = 215;
         SynchronizeContextsResponse synchronize_contexts_response = 216;
@@ -1680,6 +1681,15 @@ message GetPrivateUserInfoResponse {
     repeated string flags = 3;
 }
 
+enum Plan {
+    Free = 0;
+    ZedPro = 1;
+}
+
+message UpdateUserPlan {
+    Plan plan = 1;
+}
+
 // Entities
 
 message ViewId {

crates/proto/src/proto.rs πŸ”—

@@ -359,6 +359,7 @@ messages!(
     (UpdateParticipantLocation, Foreground),
     (UpdateProject, Foreground),
     (UpdateProjectCollaborator, Foreground),
+    (UpdateUserPlan, Foreground),
     (UpdateWorktree, Foreground),
     (UpdateWorktreeSettings, Foreground),
     (UsersResponse, Foreground),

crates/title_bar/Cargo.toml πŸ”—

@@ -36,6 +36,7 @@ command_palette.workspace = true
 dev_server_projects.workspace = true
 extensions_ui.workspace = true
 feedback.workspace = true
+feature_flags.workspace = true
 gpui.workspace = true
 notifications.workspace = true
 project.workspace = true

crates/title_bar/src/title_bar.rs πŸ”—

@@ -11,6 +11,7 @@ use crate::platforms::{platform_linux, platform_mac, platform_windows};
 use auto_update::AutoUpdateStatus;
 use call::ActiveCall;
 use client::{Client, UserStore};
+use feature_flags::{FeatureFlagAppExt, ZedPro};
 use gpui::{
     actions, div, px, Action, AnyElement, AppContext, Decorations, Element, InteractiveElement,
     Interactivity, IntoElement, Model, MouseButton, ParentElement, Render, Stateful,
@@ -18,7 +19,7 @@ use gpui::{
 };
 use project::{Project, RepositoryEntry};
 use recent_projects::RecentProjects;
-use rpc::proto::DevServerStatus;
+use rpc::proto::{self, DevServerStatus};
 use smallvec::SmallVec;
 use std::sync::Arc;
 use theme::ActiveTheme;
@@ -507,16 +508,32 @@ impl TitleBar {
     }
 
     pub fn render_user_menu_button(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
-        if let Some(user) = self.user_store.read(cx).current_user() {
+        let user_store = self.user_store.read(cx);
+        if let Some(user) = user_store.current_user() {
+            let plan = user_store.current_plan();
             PopoverMenu::new("user-menu")
-                .menu(|cx| {
-                    ContextMenu::build(cx, |menu, _| {
-                        menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
-                            .action("Key Bindings", Box::new(zed_actions::OpenKeymap))
-                            .action("Themes…", theme_selector::Toggle::default().boxed_clone())
-                            .action("Extensions", extensions_ui::Extensions.boxed_clone())
+                .menu(move |cx| {
+                    ContextMenu::build(cx, |menu, cx| {
+                        menu.when(cx.has_flag::<ZedPro>(), |menu| {
+                            menu.action(
+                                format!(
+                                    "Current Plan: {}",
+                                    match plan {
+                                        None => "",
+                                        Some(proto::Plan::Free) => "Free",
+                                        Some(proto::Plan::ZedPro) => "Pro",
+                                    }
+                                ),
+                                zed_actions::OpenAccountSettings.boxed_clone(),
+                            )
                             .separator()
-                            .action("Sign Out", client::SignOut.boxed_clone())
+                        })
+                        .action("Settings", zed_actions::OpenSettings.boxed_clone())
+                        .action("Key Bindings", Box::new(zed_actions::OpenKeymap))
+                        .action("Themes…", theme_selector::Toggle::default().boxed_clone())
+                        .action("Extensions", extensions_ui::Extensions.boxed_clone())
+                        .separator()
+                        .action("Sign Out", client::SignOut.boxed_clone())
                     })
                     .into()
                 })

crates/zed/src/zed.rs πŸ”—

@@ -47,7 +47,7 @@ use workspace::{
     open_new, AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings,
 };
 use workspace::{notifications::DetachAndPromptErr, Pane};
-use zed_actions::{OpenBrowser, OpenSettings, OpenZedUrl, Quit};
+use zed_actions::{OpenAccountSettings, OpenBrowser, OpenSettings, OpenZedUrl, Quit};
 
 actions!(
     zed,
@@ -422,6 +422,12 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
                     );
                 },
             )
+            .register_action(
+                |_: &mut Workspace, _: &OpenAccountSettings, cx: &mut ViewContext<Workspace>| {
+                    let server_url = &client::ClientSettings::get_global(cx).server_url;
+                    cx.open_url(&format!("{server_url}/settings"));
+                },
+            )
             .register_action(
                 move |_: &mut Workspace, _: &OpenTasks, cx: &mut ViewContext<Workspace>| {
                     open_settings_file(