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
13pub 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 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 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 let id =
120 abstio::http_post(format!("{}/create", PROPOSAL_HOST_URL), edits_json)
121 .await?;
122 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}