game/common/
share.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use abstutil::Timer;
6use map_gui::tools::grey_out_map;
7use widgetry::tools::URLManager;
8use widgetry::tools::{open_browser, FutureLoader, PopupMsg};
9use widgetry::{EventCtx, GfxCtx, Key, Line, Panel, SimpleState, State, Text, TextExt, Widget};
10
11use crate::app::{App, Transition};
12
13//pub const PROPOSAL_HOST_URL: &str = "http://localhost:8080/v1";
14pub const PROPOSAL_HOST_URL: &str = "https://aorta-routes.appspot.com/v1";
15
16pub struct ShareProposal {
17    url: Option<String>,
18    url_flag: &'static str,
19}
20
21impl ShareProposal {
22    /// This will point to a URL with the new edits and the current map, but the caller needs to
23    /// indicate a flag to reach the proper mode of A/B Street.
24    pub fn new_state(ctx: &mut EventCtx, app: &App, url_flag: &'static str) -> Box<dyn State<App>> {
25        let checksum = app.primary.map.get_edits().get_checksum(&app.primary.map);
26        let mut url = None;
27        let mut col = vec![Widget::row(vec![
28            Line("Share this proposal").small_heading().into_widget(ctx),
29            ctx.style().btn_close_widget(ctx),
30        ])];
31        if UploadedProposals::load().md5sums.contains(&checksum) {
32            let map_path = app
33                .primary
34                .map
35                .get_name()
36                .path()
37                .strip_prefix(&abstio::path(""))
38                .unwrap()
39                .to_string();
40            url = Some(format!(
41                "https://play.abstreet.org/{}/abstreet.html?{}&{}&--edits=remote/{}",
42                map_gui::tools::version(),
43                url_flag,
44                map_path,
45                checksum
46            ));
47
48            if cfg!(target_arch = "wasm32") {
49                col.push("Proposal uploaded! Share your browser's URL.".text_widget(ctx));
50            } else {
51                col.push("Proposal uploaded! Share the URL below.".text_widget(ctx));
52            }
53            col.push(
54                ctx.style()
55                    .btn_plain
56                    .btn()
57                    .label_underlined_text(url.as_ref().unwrap())
58                    .build_widget(ctx, "open in browser"),
59            );
60
61            if cfg!(target_arch = "wasm32") {
62                col.push(ctx.style().btn_plain.text("Back").build_def(ctx));
63            } else {
64                col.push(Widget::row(vec![
65                    ctx.style()
66                        .btn_solid_primary
67                        .text("Copy URL to clipboard")
68                        .build_def(ctx),
69                    ctx.style().btn_plain.text("Back").build_def(ctx),
70                ]));
71            }
72        } else {
73            let mut txt = Text::new();
74            // The Creative Commons licenses all require attribution, but we have no user accounts
75            // or ways of proving identity yet!
76            txt.add_line(Line(
77                "You'll upload this proposal anonymously, in the public domain",
78            ));
79            txt.add_line(Line("You can't delete or edit it after uploading"));
80            txt.add_line(Line(
81                "(But you can upload and share new versions of the proposal)",
82            ));
83            col.push(txt.into_widget(ctx));
84            col.push(Widget::row(vec![
85                ctx.style()
86                    .btn_solid_primary
87                    .text("Upload")
88                    .hotkey(Key::Enter)
89                    .build_def(ctx),
90                ctx.style().btn_plain.text("Cancel").build_def(ctx),
91            ]));
92        }
93
94        let panel = Panel::new_builder(Widget::col(col)).build(ctx);
95        <dyn SimpleState<_>>::new_state(panel, Box::new(ShareProposal { url, url_flag }))
96    }
97}
98
99impl SimpleState<App> for ShareProposal {
100    fn on_click(
101        &mut self,
102        ctx: &mut EventCtx,
103        app: &mut App,
104        x: &str,
105        _: &mut Panel,
106    ) -> Transition {
107        match x {
108            "close" | "Cancel" | "Back" => Transition::Pop,
109            "Upload" => {
110                let (_, outer_progress_rx) = futures_channel::mpsc::channel(1);
111                let (_, inner_progress_rx) = futures_channel::mpsc::channel(1);
112                let edits_json =
113                    abstutil::to_json(&app.primary.map.get_edits().to_permanent(&app.primary.map));
114                let url_flag = self.url_flag;
115                return Transition::Replace(FutureLoader::<App, String>::new_state(
116                    ctx,
117                    Box::pin(async move {
118                        // We don't really need this ID from the API; it's the md5sum.
119                        let id =
120                            abstio::http_post(format!("{}/create", PROPOSAL_HOST_URL), edits_json)
121                                .await?;
122                        // TODO I'm so lost in this type magic
123                        let wrapper: Box<dyn Send + FnOnce(&App) -> String> = Box::new(move |_| id);
124                        Ok(wrapper)
125                    }),
126                    outer_progress_rx,
127                    inner_progress_rx,
128                    "Uploading proposal",
129                    Box::new(move |ctx, app, result| match result {
130                        Ok(id) => {
131                            URLManager::update_url_param(
132                                "--edits".to_string(),
133                                format!("remote/{}", id),
134                            );
135                            info!("Proposal uploaded! {}/get?id={}", PROPOSAL_HOST_URL, id);
136                            UploadedProposals::proposal_uploaded(id);
137                            Transition::Replace(ShareProposal::new_state(ctx, app, url_flag))
138                        }
139                        Err(err) => Transition::Multi(vec![
140                            Transition::Pop,
141                            Transition::Push(ShareProposal::new_state(ctx, app, url_flag)),
142                            Transition::Push(PopupMsg::new_state(
143                                ctx,
144                                "Failure",
145                                vec![format!("Couldn't upload proposal: {}", err)],
146                            )),
147                        ]),
148                    }),
149                ));
150            }
151            "Copy URL to clipboard" => {
152                widgetry::tools::set_clipboard(self.url.clone().unwrap());
153                Transition::Keep
154            }
155            "open in browser" => {
156                open_browser(self.url.as_ref().unwrap());
157                Transition::Keep
158            }
159            _ => unreachable!(),
160        }
161    }
162
163    fn draw(&self, g: &mut GfxCtx, app: &App) {
164        grey_out_map(g, app);
165    }
166}
167
168#[derive(Serialize, Deserialize, Debug)]
169pub struct UploadedProposals {
170    pub md5sums: BTreeSet<String>,
171}
172
173impl UploadedProposals {
174    pub fn load() -> UploadedProposals {
175        abstio::maybe_read_json::<UploadedProposals>(
176            abstio::path_player("uploaded_proposals.json"),
177            &mut Timer::throwaway(),
178        )
179        .unwrap_or_else(|_| UploadedProposals {
180            md5sums: BTreeSet::new(),
181        })
182    }
183
184    fn proposal_uploaded(checksum: String) {
185        let mut uploaded = UploadedProposals::load();
186        uploaded.md5sums.insert(checksum);
187        abstio::write_json(abstio::path_player("uploaded_proposals.json"), &uploaded);
188    }
189}