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