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 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 let id = abstio::http_post(
136 format!("{}/create-ltn", PROPOSAL_HOST_URL),
137 proposal_contents,
138 )
139 .await?;
140 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}