/ cyber-acid.go
cyber-acid.go
1 package main 2 3 import ( 4 "encoding/json" 5 "log" 6 "net/http" 7 "sort" 8 "strconv" 9 "time" 10 11 "github.com/NYTimes/gziphandler" 12 "github.com/foolin/mixer" 13 "github.com/maxence-charriere/go-app/v10/pkg/app" 14 "github.com/mitchellh/mapstructure" 15 shell "github.com/stateless-minds/go-ipfs-api" 16 ) 17 18 const dbNameIssue = "issue" 19 20 const dbNameCitizenReputation = "citizen_reputation" 21 22 const typeShortage = "shortage" 23 24 const ( 25 topicCritical = "critical" 26 topicIssue = "issue" 27 ) 28 29 const ( 30 NotificationSuccess NotificationStatus = "positive" 31 NotificationInfo NotificationStatus = "info" 32 NotificationWarning NotificationStatus = "warning" 33 NotificationDanger NotificationStatus = "negative" 34 SuccessHeader = "Success" 35 ErrorHeader = "Error" 36 ) 37 38 const ( 39 asideTitleCreate = "Suggest Solution" 40 asideTitleList = "List Solutions" 41 ) 42 43 // pubsub is a component that does a simple pubsub on ipfs. A component is a 44 // customizable, independent, and reusable UI element. It is created by 45 // embedding app.Compo into a struct. 46 type acid struct { 47 app.Compo 48 sh *shell.Shell 49 sub *shell.PubSubSubscription 50 citizenID string 51 issues []Issue 52 categoryIssues map[string][]Issue 53 ranks []CitizenReputation 54 delegates []Delegate 55 currentIssueInSlice int 56 Solutions []Solution 57 currentSolutionDescription string 58 notifications map[string]notification 59 notificationID int 60 AsideTitle string 61 } 62 63 type NotificationStatus string 64 65 type notification struct { 66 id int 67 status string 68 header string 69 message string 70 } 71 72 type Issue struct { 73 ID string `mapstructure:"_id" json:"_id" validate:"uuid_rfc4122"` 74 Type string `mapstructure:"type" json:"type" validate:"uuid_rfc4122"` 75 Category string `mapstructure:"category" json:"category" validate:"uuid_rfc4122"` 76 Description string `mapstructure:"description" json:"description" validate:"uuid_rfc4122"` 77 Delegates []Delegate `mapstructure:"delegates" json:"delegates" validate:"uuid_rfc4122"` 78 Solutions []Solution `mapstructure:"solutions" json:"solutions" validate:"uuid_rfc4122"` 79 Voters []string `mapstructure:"voters" json:"voters" validate:"uuid_rfc4122"` 80 } 81 82 type Solution struct { 83 ID string `mapstructure:"_id" json:"_id" validate:"uuid_rfc4122"` 84 Description string `mapstructure:"description" json:"description" validate:"uuid_rfc4122"` 85 Votes int `mapstructure:"votes" json:"votes" validate:"uuid_rfc4122"` 86 } 87 88 type Delegate struct { 89 CitizenID string `mapstructure:"citizenId" json:"citizenId" validate:"uuid_rfc4122"` 90 Votes int `mapstructure:"votes" json:"votes" validate:"uuid_rfc4122"` 91 Selected int `mapstructure:"selected" json:"selected" validate:"uuid_rfc4122"` 92 OwnVote bool `mapstructure:"voted" json:"voted" validate:"uuid_rfc4122"` 93 } 94 95 type CitizenReputation struct { 96 ID string `mapstructure:"_id" json:"_id" validate:"uuid_rfc4122"` 97 Type string `mapstructure:"type" json:"type" validate:"uuid_rfc4122"` 98 CitizenID string `mapstructure:"citizenId" json:"citizenId" validate:"uuid_rfc4122"` 99 ReputationIndex float64 `mapstructure:"reputationIndex" json:"reputationIndex" validate:"uuid_rfc4122"` 100 } 101 102 func (a *acid) OnMount(ctx app.Context) { 103 sh := shell.NewShell("localhost:5001") 104 a.sh = sh 105 myPeer, err := a.sh.ID() 106 if err != nil { 107 log.Fatal(err) 108 } 109 110 citizenID := myPeer.ID[len(myPeer.ID)-8:] 111 // replace password with your own 112 password := "mysecretpassword" 113 114 a.citizenID = mixer.EncodeString(password, citizenID) 115 a.subscribeToCriticalTopic(ctx) 116 a.subscribeToIssueTopic(ctx) 117 a.notifications = make(map[string]notification) 118 a.categoryIssues = make(map[string][]Issue) 119 ctx.Async(func() { 120 // err := a.sh.OrbitDocsDelete(dbNameIssue, "all") 121 // if err != nil { 122 // log.Fatal(err) 123 // } 124 125 // err := a.sh.OrbitDocsDelete(dbNameCitizenReputation, "4") 126 // if err != nil { 127 // log.Fatal(err) 128 // } 129 130 cr, err := a.sh.OrbitDocsQuery(dbNameCitizenReputation, "type", "reputation") 131 if err != nil { 132 log.Fatal(err) 133 } 134 135 var cc []interface{} 136 err = json.Unmarshal(cr, &cc) 137 if err != nil { 138 log.Fatal(err) 139 } 140 141 for _, zz := range cc { 142 r := CitizenReputation{} 143 err = mapstructure.Decode(zz, &r) 144 if err != nil { 145 log.Fatal(err) 146 } 147 ctx.Dispatch(func(ctx app.Context) { 148 a.ranks = append(a.ranks, r) 149 sort.SliceStable(a.ranks, func(i, j int) bool { 150 return a.ranks[i].ID < a.ranks[j].ID 151 }) 152 }) 153 } 154 155 v, err := a.sh.OrbitDocsQuery(dbNameIssue, "type", "shortage") 156 if err != nil { 157 log.Fatal(err) 158 } 159 160 var vv []interface{} 161 err = json.Unmarshal(v, &vv) 162 if err != nil { 163 log.Fatal(err) 164 } 165 166 for _, ii := range vv { 167 i := Issue{} 168 err = mapstructure.Decode(ii, &i) 169 if err != nil { 170 log.Fatal(err) 171 } 172 ctx.Dispatch(func(ctx app.Context) { 173 a.categoryIssues[i.Category] = append(a.categoryIssues[i.Category], i) 174 a.issues = append(a.issues, i) 175 sort.SliceStable(a.issues, func(i, j int) bool { 176 return a.issues[i].ID < a.issues[j].ID 177 }) 178 }) 179 } 180 }) 181 } 182 183 func (a *acid) subscribeToCriticalTopic(ctx app.Context) { 184 ctx.Async(func() { 185 topic := topicCritical 186 subscription, err := a.sh.PubSubSubscribe(topic) 187 if err != nil { 188 log.Fatal(err) 189 } 190 a.sub = subscription 191 a.subscriptionCritical(ctx) 192 }) 193 } 194 195 func (a *acid) subscribeToIssueTopic(ctx app.Context) { 196 ctx.Async(func() { 197 topic := topicIssue 198 subscription, err := a.sh.PubSubSubscribe(topic) 199 if err != nil { 200 log.Fatal(err) 201 } 202 a.sub = subscription 203 a.subscriptionIssue(ctx) 204 }) 205 } 206 207 func (a *acid) subscriptionIssue(ctx app.Context) { 208 ctx.Async(func() { 209 defer a.sub.Cancel() 210 // wait on pubsub 211 res, err := a.sub.Next() 212 if err != nil { 213 log.Fatal(err) 214 } 215 // Decode the string data. 216 str := string(res.Data) 217 log.Println("Subscriber of topic issue received message: " + str) 218 ctx.Async(func() { 219 a.subscribeToIssueTopic(ctx) 220 }) 221 222 s := Issue{} 223 err = json.Unmarshal([]byte(str), &s) 224 if err != nil { 225 log.Fatal(err) 226 } 227 228 id, err := strconv.Atoi(s.ID) 229 if err != nil { 230 log.Fatal(err) 231 } 232 233 ctx.Dispatch(func(ctx app.Context) { 234 a.issues[id-1] = s 235 }) 236 }) 237 } 238 239 func (a *acid) subscriptionCritical(ctx app.Context) { 240 ctx.Async(func() { 241 defer a.sub.Cancel() 242 // wait on pubsub 243 res, err := a.sub.Next() 244 if err != nil { 245 log.Fatal(err) 246 } 247 // Decode the string data. 248 str := string(res.Data) 249 log.Println("Subscriber of topic critical received message: " + str) 250 ctx.Async(func() { 251 a.subscribeToCriticalTopic(ctx) 252 }) 253 254 s := Issue{} 255 err = json.Unmarshal([]byte(str), &s) 256 if err != nil { 257 log.Fatal(err) 258 } 259 260 var lastID int 261 unique := true 262 for n, i := range a.issues { 263 a.categoryIssues[i.Category] = append(a.categoryIssues[i.Category], i) 264 if s.Description == i.Description { 265 unique = false 266 } 267 268 if n == 0 { 269 lastID, err = strconv.Atoi(i.ID) 270 if err != nil { 271 log.Fatal(err) 272 } 273 } else { 274 currentID, err := strconv.Atoi(i.ID) 275 if err != nil { 276 log.Fatal(err) 277 } 278 previousID, err := strconv.Atoi(a.issues[n-1].ID) 279 if err != nil { 280 log.Fatal(err) 281 } 282 if currentID > previousID { 283 lastID = currentID 284 } 285 } 286 287 } 288 if unique { 289 newID := lastID + 1 290 issue := Issue{ 291 ID: strconv.Itoa(newID), 292 Type: typeShortage, 293 Category: s.Category, 294 Description: s.Description, 295 Solutions: []Solution{}, 296 } 297 298 i, err := json.Marshal(issue) 299 if err != nil { 300 log.Fatal(err) 301 } 302 303 err = a.sh.OrbitDocsPut(dbNameIssue, i) 304 if err != nil { 305 log.Fatal(err) 306 } 307 308 err = a.sh.PubSubPublish(topicIssue, string(i)) 309 if err != nil { 310 log.Fatal(err) 311 } 312 313 ctx.Dispatch(func(ctx app.Context) { 314 a.issues = append(a.issues, issue) 315 }) 316 } 317 }) 318 } 319 320 // The Render method is where the component appearance is defined. Here, a 321 // "pubsub World!" is displayed as a heading. 322 func (a *acid) Render() app.UI { 323 return app.Div().Class("l-application").Role("presentation").Body( 324 app.Link().Rel("stylesheet").Href("https://assets.ubuntu.com/v1/vanilla-framework-version-3.8.0.min.css"), 325 app.Link().Rel("stylesheet").Href("https://use.fontawesome.com/releases/v6.2.0/css/all.css"), 326 app.Link().Rel("stylesheet").Href("/app.css"), 327 app.Header().Class("l-navigation is-collapsed").Body( 328 app.Div().Class("l-navigation__drawer").Body( 329 app.Div().Class("p-panel is-dark").Body( 330 app.Div().Class("p-panel__header is-sticky").Body( 331 app.A().Class("p-panel__logo").Href("#").Body( 332 app.H5().Class("p-heading--2").Text("Cyber Acid"), 333 ), 334 ), 335 app.Hr(), 336 app.P().Class("p-heading--6").Body( 337 app.Text("Liquid democracy politics simulator based on the automated data feed from the moneyless economy simulator "), 338 app.A().Href("https://github.com/stateless-minds/cyber-stasis").Text("Cyber Stasis"), 339 ).Style("padding", "0 10%;"), 340 app.Hr(), 341 app.Div().Class("p-panel__content").Body( 342 app.Div().Class("p-side-navigation--icons is-dark").ID("drawer-icons").Body( 343 app.Nav().Aria("label", "Main"), 344 app.Ul().Class("p-side-navigation__list").Body( 345 app.Li().Class("p-side-navigation__item--title").Body( 346 app.A().Class("p-side-navigation__link").Href("#").Body( 347 app.I().Class("p-icon--help is-light p-side-navigation__icon"), 348 app.Span().Class("p-side-navigation__label").Text("How to play"), 349 ).OnClick(a.openHowToDialog), 350 app.A().Class("p-side-navigation__link").Href("#").Body( 351 app.I().Class("p-icon--warning is-light p-side-navigation__icon"), 352 app.Span().Class("p-side-navigation__label").Text("Shortages"), 353 ).Aria("current", "page"), 354 app.A().Class("p-side-navigation__link").Href("#").Body( 355 app.I().Class("p-icon--share is-light p-side-navigation__icon"), 356 app.Span().Class("p-side-navigation__label").Text("Delegate rankings"), 357 ).OnClick(a.openRankingsDialog), 358 ), 359 ), 360 ), 361 ), 362 ), 363 ), 364 ), 365 app.Main().Class("l-main").Body( 366 app.Div().Class("p-panel").Body( 367 app.If(len(a.notifications) > 0, func() app.UI { 368 return app.Range(a.notifications).Map(func(s string) app.UI { 369 return app.Div().Class("p-notification--" + a.notifications[s].status).Body( 370 app.Div().Class("p-notification__content").Body( 371 app.H5().Class("p-notification__title").Text(a.notifications[s].header), 372 app.P().Class("p-notification__message").Text(a.notifications[s].message), 373 ), 374 ) 375 }) 376 }), 377 app.Div().Class("p-panel__header").Body( 378 app.H4().Class("p-panel__title").Text("Open Issues"), 379 ), 380 app.Div().Class("p-panel__content").Body( 381 app.Div().Class("u-fixed-width").Body( 382 app.If(len(a.categoryIssues) > 0, func() app.UI { 383 return app.Range(a.categoryIssues).Map(func(s string) app.UI { 384 return app.Table().Aria("label", "Issues table").Class("p-main-table").Body( 385 app.THead().Body( 386 app.Tr().Body( 387 app.Th().Body( 388 app.Span().Class("status-icon is-running").Text("Category "+s), 389 ), 390 app.Th().Text("Actions"), 391 ), 392 ), 393 app.If(len(a.categoryIssues[s]) > 0, func() app.UI { 394 return app.TBody().Body( 395 app.Range(a.categoryIssues[s]).Slice(func(i int) app.UI { 396 return app.Tr().DataSet("id", i).Body( 397 app.Td().DataSet("column", "issue").Body( 398 app.Div().Text(a.categoryIssues[s][i].Description), 399 ), 400 app.Td().DataSet("column", "action").Body( 401 app.Div().Body( 402 app.Button().Class("u-no-margin--bottom").Text("List Solutions").Value(a.categoryIssues[s][i].ID).OnMouseOver(a.asidePreloadList).OnClick(a.asideOpenList), 403 app.Button().Class("u-no-margin--bottom").Text("Suggest Solution").Value(a.categoryIssues[s][i].ID).OnMouseOver(a.asidePreloadCreate).OnClick(a.asideOpenCreate), 404 ), 405 ), 406 ) 407 }), 408 ) 409 }), 410 ) 411 }) 412 }), 413 ), 414 app.Div().Class("p-modal").ID("howto-modal").Style("display", "none").Body( 415 app.Section().Class("p-modal__dialog").Role("dialog").Aria("modal", true).Aria("labelledby", "modal-title").Aria("describedby", "modal-description").Body( 416 app.Header().Class("p-modal__header").Body( 417 app.H2().Class("p-modal__title").ID("modal-title").Text("How to play"), 418 app.Button().Class("p-modal__close").Aria("label", "Close active modal").Aria("controls", "modal").OnClick(a.closeHowToModal), 419 ), 420 app.Div().Class("p-heading-icon--small").Body( 421 app.Aside().Class("p-accordion").Body( 422 app.Ul().Class("p-accordion__list").Body( 423 app.Li().Class("p-accordion__group").Body( 424 app.Div().Role("heading").Aria("level", "3").Class("p-accordion__heading").Body( 425 app.Button().Type("button").Class("p-accordion__tab").ID("tab1").Aria("controls", "tab1-section").Aria("expanded", true).Text("What is Cyber Acid").Value("tab1-section").OnClick(a.toggleAccordion), 426 ), 427 app.Section().Class("p-accordion__panel").ID("tab1-section").Aria("hidden", false).Aria("labelledby", "tab1").Body( 428 app.P().Text("Cyber Acid is a political simulator based on the liquid democracy concept. It is designed as an integration module that works with Cyber Stasis - the moneyless economy simulator."), 429 ), 430 ), 431 app.Li().Class("p-accordion__group").Body( 432 app.Div().Role("heading").Aria("level", "3").Class("p-accordion__heading").Body( 433 app.Button().Type("button").Class("p-accordion__tab").ID("tab2").Aria("controls", "tab2-section").Aria("expanded", true).Text("What is liquid democracy").Value("tab2-section").OnClick(a.toggleAccordion), 434 ), 435 app.Section().Class("p-accordion__panel").ID("tab2-section").Aria("hidden", true).Aria("labelledby", "tab1").Body( 436 app.P().Text("Liquid democracy meets the transparency and accountability of direct democracy with the easy of use of representative democracy. Vote directly for what you want and delegate one-time voting rights per topic."), 437 ), 438 ), 439 app.Li().Class("p-accordion__group").Body( 440 app.Div().Role("heading").Aria("level", "3").Class("p-accordion__heading").Body( 441 app.Button().Type("button").Class("p-accordion__tab").ID("tab3").Aria("controls", "tab3-section").Aria("expanded", true).Text("How it works").Value("tab3-section").OnClick(a.toggleAccordion), 442 ), 443 app.Section().Class("p-accordion__panel").ID("tab3-section").Aria("hidden", true).Aria("labelledby", "tab3").Body( 444 app.P().Text("The simulator receives live data from Cyber Stasis about critical shortages of production and resources. The goal of all participants is to suggest solutions to those issues. For example - replacing a resource with another one, researching new technologies etc."), 445 ), 446 ), 447 app.Li().Class("p-accordion__group").Body( 448 app.Div().Role("heading").Aria("level", "3").Class("p-accordion__heading").Body( 449 app.Button().Type("button").Class("p-accordion__tab").ID("tab4").Aria("controls", "tab4-section").Aria("expanded", true).Text("Features").Value("tab4-section").OnClick(a.toggleAccordion), 450 ), 451 app.Section().Class("p-accordion__panel").ID("tab4-section").Aria("hidden", true).Aria("labelledby", "tab3").Body( 452 app.Ul().Class("p-matrix").Body( 453 app.Li().Class("p-matrix__item").Body( 454 app.Div().Class("p-matrix__content").Body( 455 app.H3().Class("p-matrix__title").Text("Check shortages"), 456 app.Div().Class("p-matrix__desc").Body( 457 app.P().Text("Review pressing issues."), 458 ), 459 ), 460 ), 461 app.Li().Class("p-matrix__item").Body( 462 app.Div().Class("p-matrix__content").Body( 463 app.H3().Class("p-matrix__title").Text("Suggest a solution"), 464 app.Div().Class("p-matrix__desc").Body( 465 app.P().Text("Contribute with your expertise."), 466 ), 467 ), 468 ), 469 app.Li().Class("p-matrix__item").Body( 470 app.Div().Class("p-matrix__content").Body( 471 app.H3().Class("p-matrix__title").Text("Vote for solutions"), 472 app.Div().Class("p-matrix__desc").Body( 473 app.P().Text("Vote for the best solution."), 474 ), 475 ), 476 ), 477 app.Li().Class("p-matrix__item").Body( 478 app.Div().Class("p-matrix__content").Body( 479 app.H3().Class("p-matrix__title").Text("Delegate your vote"), 480 app.Div().Class("p-matrix__desc").Body( 481 app.P().Text("Not competent? Delegate your vote."), 482 ), 483 ), 484 ), 485 app.Li().Class("p-matrix__item").Body( 486 app.Div().Class("p-matrix__content").Body( 487 app.H3().Class("p-matrix__title").Text("Infinite delegation"), 488 app.Div().Class("p-matrix__desc").Body( 489 app.P().Text("Delegation can be chained for maximum participation."), 490 ), 491 ), 492 ), 493 app.Li().Class("p-matrix__item").Body( 494 app.Div().Class("p-matrix__content").Body( 495 app.H3().Class("p-matrix__title").Text("Cross delegation"), 496 app.Div().Class("p-matrix__desc").Body( 497 app.P().Text("Cross delegation is also supported."), 498 ), 499 ), 500 ), 501 ), 502 ), 503 ), 504 app.Li().Class("p-accordion__group").Body( 505 app.Div().Role("heading").Aria("level", "3").Class("p-accordion__heading").Body( 506 app.Button().Type("button").Class("p-accordion__tab").ID("tab5").Aria("controls", "tab5-section").Aria("expanded", true).Text("Support us").Value("tab5-section").OnClick(a.toggleAccordion), 507 ), 508 app.Section().Class("p-accordion__panel").ID("tab5-section").Aria("hidden", true).Aria("labelledby", "tab5").Body( 509 app.A().Href("https://opencollective.com/stateless-minds-collective").Text("https://opencollective.com/stateless-minds-collective"), 510 ), 511 ), 512 app.Li().Class("p-accordion__group").Body( 513 app.Div().Role("heading").Aria("level", "3").Class("p-accordion__heading").Body( 514 app.Button().Type("button").Class("p-accordion__tab").ID("tab6").Aria("controls", "tab6-section").Aria("expanded", true).Text("Terms of service").Value("tab6-section").OnClick(a.toggleAccordion), 515 ), 516 app.Section().Class("p-accordion__panel").ID("tab6-section").Aria("hidden", true).Aria("labelledby", "tab6").Body( 517 app.Div().Class("p-card").Body( 518 app.H3().Text("Introduction"), 519 app.P().Class("p-card__content").Text("Cyber Acid is a liquid democracy political simulator in the form of a fictional game based on real-time data from Cyber Stasis. By using the application you are implicitly agreeing to share your peer id with the IPFS public network."), 520 ), 521 app.Div().Class("p-card").Body( 522 app.H3().Text("Application Hosting"), 523 app.P().Class("p-card__content").Text("Cyber Acid is a decentralized application and is hosted on a public peer to peer network. By using the application you agree to host it on the public IPFS network free of charge for as long as your usage is."), 524 ), 525 app.Div().Class("p-card").Body( 526 app.H3().Text("User-Generated Content"), 527 app.P().Class("p-card__content").Text("All published content is user-generated, fictional and creators are not responsible for it."), 528 ), 529 ), 530 ), 531 app.Li().Class("p-accordion__group").Body( 532 app.Div().Role("heading").Aria("level", "3").Class("p-accordion__heading").Body( 533 app.Button().Type("button").Class("p-accordion__tab").ID("tab7").Aria("controls", "tab7-section").Aria("expanded", true).Text("Privacy policy").Value("tab7-section").OnClick(a.toggleAccordion), 534 ), 535 app.Section().Class("p-accordion__panel").ID("tab7-section").Aria("hidden", true).Aria("labelledby", "tab7").Body( 536 app.Div().Class("p-card").Body( 537 app.H3().Text("Personal data"), 538 app.P().Class("p-card__content").Text("There is no personal information collected within Cyber Acid. We store a small portion of your peer ID encrypted as a non-unique identifier which is used for displaying the ranks interface."), 539 ), 540 app.Div().Class("p-card").Body( 541 app.H3().Text("Coookies"), 542 app.P().Class("p-card__content").Text("Cyber Acid does not use cookies."), 543 ), 544 app.Div().Class("p-card").Body( 545 app.H3().Text("Links to Cyber Stasis"), 546 app.P().Class("p-card__content").Text("Cyber Acid contains links to its sister project Cyber Stasis and depends on its data to function properly."), 547 ), 548 app.Div().Class("p-card").Body( 549 app.H3().Text("Changes to this privacy policy"), 550 app.P().Class("p-card__content").Text("This Privacy Policy might be updated from time to time. Thus, it is advised to review this page periodically for any changes. You will be notified of any changes from this page. Changes are effective immediately after they are posted on this page."), 551 ), 552 ), 553 ), 554 ), 555 ), 556 ), 557 ).Style("left", "10%").Style("width", "80%"), 558 ), 559 app.Div().Class("p-modal").ID("rankings-modal").Style("display", "none").Body( 560 app.Section().Class("p-modal__dialog").Role("dialog").Aria("modal", true).Aria("labelledby", "modal-title").Aria("describedby", "modal-description").Body( 561 app.Header().Class("p-modal__header").Body( 562 app.H2().Class("p-modal__title").ID("modal-title").Text("Delegate rankings"), 563 app.Button().Class("p-modal__close").Aria("label", "Close active modal").Aria("controls", "modal").OnClick(a.closeRankingsModal), 564 ), 565 app.Table().Aria("label", "Rankings table").Class("p-main-table").Body( 566 app.THead().Body( 567 app.Tr().Body( 568 app.Th().Body( 569 app.Span().Class("status-icon is-blocked").Text("Delegate ID"), 570 ), 571 app.Th().Text("Trust"), 572 ), 573 ), 574 app.If(len(a.delegates) > 0, func() app.UI { 575 return app.TBody().Body( 576 app.Range(a.delegates).Slice(func(i int) app.UI { 577 return app.Tr().DataSet("id", i).Body( 578 app.Td().DataSet("column", "delegate").Body( 579 app.Div().Text(a.delegates[i].CitizenID), 580 ), 581 app.Td().DataSet("column", "trust").Body( 582 app.Div().Text(a.delegates[i].Selected), 583 ), 584 ) 585 }), 586 ) 587 }), 588 ), 589 ).Style("left", "10%").Style("width", "80%"), 590 ), 591 ), 592 ), 593 ), 594 app.Aside().Class("l-aside is-collapsed").ID("aside-panel").Body( 595 app.Div().Class("p-panel").Body( 596 app.Div().Class("p-panel__header").Body( 597 app.H4().Class("p-panel__title").Text(a.AsideTitle), 598 app.Div().Class("p-panel__controls").Body( 599 app.Button().Class("p-button--base u-no-margin--bottom has-icon").Body(app.I().Class("p-icon--close")).OnClick(a.asideClose), 600 ), 601 ), 602 app.If(a.AsideTitle == asideTitleCreate, func() app.UI { 603 return app.Div().Class("p-panel__content").Body( 604 app.Div().Class("p-form p-form--stacked").Body( 605 app.Div().Class("p-form__group row").Body( 606 app.Textarea().ID("solution").Name("solution").Rows(3).OnKeyUp(a.onSolution), 607 ), 608 ), 609 app.Div().Class("row").Body( 610 app.Div().Class("col-12").Body( 611 app.Button().Class("p-button--positive u-float-right").Name("submit-solution").Text("Submit Solution").OnClick(a.submitSolution), 612 ), 613 ), 614 ) 615 }).ElseIf(a.AsideTitle == asideTitleList, func() app.UI { 616 return app.Div().Class("p-panel__content").Body( 617 app.Ul().Class("p-list-tree").Aria("multiselectable", true).Role("tree").Body( 618 app.Li().Class("p-list-tree__item p-list-tree__item--group").Role("treeitem").Body( 619 app.Button().Class("p-list-tree__toggle").ID("sub-1-btn").Aria("controls", "sub-1").Aria("expanded", true).Text("Suggested Solutions"), 620 app.Ul().Class("p-list-tree").Role("group").ID("sub-1").Aria("hidden", false).Aria("labelledby", "sub-1-btn").Body( 621 app.Range(a.Solutions).Slice(func(i int) app.UI { 622 return app.Li().Class("p-list-tree__item").Role("treeitem").Body( 623 app.P().Text(a.Solutions[i].Description), 624 app.If(len(a.issues[a.currentIssueInSlice].Voters) > 0, func() app.UI { 625 return app.If(sliceContains(a.issues[a.currentIssueInSlice].Voters, a.citizenID), func() app.UI { 626 return app.Button(). 627 Class("p-button is-small is-inline"). 628 Text("Vote"). 629 Value(a.Solutions[i].ID). 630 OnClick(a.vote). 631 Disabled(true). 632 Body( 633 app.I().Class("fa-solid fa-thumbs-up"), // Icon 634 app.Span(). 635 Class("p-badge"). 636 Aria("label", strconv.Itoa(a.Solutions[i].Votes)+" votes"). 637 Text(strconv.Itoa(a.Solutions[i].Votes)), // Votes count 638 ) 639 }).Else( func() app.UI { 640 return app.Div().Class("vote-delegate-container").Body( 641 // Vote Button with Icon 642 app.Button(). 643 Class("p-button is-small is-inline"). 644 Text("Vote"). 645 Value(a.Solutions[i].ID). 646 OnClick(a.vote). 647 Body( 648 app.I().Class("fa-regular fa-thumbs-up"), // Icon for Vote 649 ), 650 651 // Delegate Button 652 app.Button(). 653 Class("p-button is-small is-inline"). 654 ID("show-modal"). 655 Text("Delegate..."). 656 Aria("controls", "modal"). 657 Value(a.Solutions[i].ID). 658 OnClick(a.openDelegateDialog), 659 660 // Modal for Delegation 661 app.Div(). 662 Class("p-modal"). 663 ID("delegate-modal"). 664 Style("display", "none"). // Initially hidden 665 Body( 666 app.Section(). 667 Class("p-modal__dialog"). 668 Role("dialog"). 669 Aria("modal", true). 670 Aria("labelledby", "modal-title"). 671 Aria("describedby", "modal-description"). 672 Body( 673 app.Header(). 674 Class("p-modal__header"). 675 Body( 676 app.H2(). 677 Class("p-modal__title"). 678 ID("modal-title"). 679 Text("Delegate"), 680 app.Button(). 681 Class("p-modal__close"). 682 Aria("label", "Close active modal"). 683 Aria("controls", "modal"). 684 Value(a.Solutions[i].ID). 685 OnClick(a.closeDelegateModal), 686 ), 687 app.P(). 688 ID("modal-description"). 689 Text("Select a citizen to represent your vote for this issue:"), 690 app.Div(). 691 Class("p-heading-icon--small"). 692 Body( 693 app.Range(a.ranks).Slice(func(i int) app.UI { 694 return app.If(a.ranks[i].CitizenID != a.citizenID, func() app.UI { 695 return app.Div().Class("p-heading-icon__header").Body( 696 app.Button(). 697 Class("p-chip"). 698 Aria("pressed", true). 699 Disabled(true). 700 Body( 701 app.Span().Class("p-chip__value").Text("Citizen"), 702 app.Span().Class("p-badge").Aria("label", "Citizen").Text(a.ranks[i].CitizenID), 703 ), 704 app.Button(). 705 Class("p-chip"). 706 Aria("pressed", true). 707 Disabled(true). 708 Body( 709 app.Span().Class("p-chip__value").Text("Reputation"), 710 app.Span().Class("p-badge").Aria("label", "Reputation").Text(a.ranks[i].ReputationIndex), 711 ), 712 app.Button(). 713 Class("p-chip"). 714 Body( 715 app.Span().Class("p-chip__value").Text("Select"), 716 ).Value(a.ranks[i].CitizenID).OnClick(a.delegate), 717 ) 718 }) 719 }), 720 ), 721 ), 722 ), 723 ) 724 }) 725 }).Else( func() app.UI { 726 return app.Div().Class("vote-delegate-container").Body( 727 // Vote Button with Icon 728 app.Button(). 729 Class("p-button is-small is-inline"). 730 Text("Vote"). 731 Value(a.Solutions[i].ID). 732 OnClick(a.vote). 733 Body( 734 app.I().Class("fa-regular fa-thumbs-up"), // Icon for Vote 735 ), 736 737 // Delegate Button 738 app.Button(). 739 Class("p-button is-small is-inline"). 740 ID("show-modal"). 741 Text("Delegate..."). 742 Aria("controls", "modal"). 743 Value(a.Solutions[i].ID). 744 OnClick(a.openDelegateDialog), 745 746 // Modal for Delegation 747 app.Div(). 748 Class("p-modal"). 749 ID("delegate-modal"). 750 Style("display", "none"). // Initially hidden 751 Body( 752 app.Section(). 753 Class("p-modal__dialog"). 754 Role("dialog"). 755 Aria("modal", true). 756 Aria("labelledby", "modal-title"). 757 Aria("describedby", "modal-description"). 758 Body( 759 app.Header(). 760 Class("p-modal__header"). 761 Body( 762 app.H2(). 763 Class("p-modal__title"). 764 ID("modal-title"). 765 Text("Delegate"), 766 app.Button(). 767 Class("p-modal__close"). 768 Aria("label", "Close active modal"). 769 Aria("controls", "modal"). 770 Value(a.Solutions[i].ID). 771 OnClick(a.closeDelegateModal), 772 ), 773 app.P(). 774 ID("modal-description"). 775 Text("Select a citizen to represent your vote for this issue:"), 776 app.Div(). 777 Class("p-heading-icon--small"). 778 Body( 779 app.Range(a.ranks).Slice(func(i int) app.UI { 780 return app.If(a.ranks[i].CitizenID != a.citizenID, func() app.UI { 781 return app.Div().Class("p-heading-icon__header").Body( 782 app.Button(). 783 Class("p-chip"). 784 Aria("pressed", true). 785 Disabled(true). 786 Body( 787 app.Span().Class("p-chip__value").Text("Citizen"), 788 app.Span().Class("p-badge").Aria("label", "Citizen").Text(a.ranks[i].CitizenID), 789 ), 790 app.Button(). 791 Class("p-chip"). 792 Aria("pressed", true). 793 Disabled(true). 794 Body( 795 app.Span().Class("p-chip__value").Text("Reputation"), 796 app.Span().Class("p-badge").Aria("label", "Reputation").Text(a.ranks[i].ReputationIndex), 797 ), 798 app.Button(). 799 Class("p-chip"). 800 Body( 801 app.Span().Class("p-chip__value").Text("Select"), 802 ).Value(a.ranks[i].CitizenID).OnClick(a.delegate), 803 ) 804 }) 805 }), 806 ), 807 ), 808 ), 809 ) 810 }), 811 ) 812 }), 813 ), 814 ), 815 ), 816 ) 817 }), 818 ), 819 ), 820 ) 821 } 822 823 func (a *acid) asidePreloadList(ctx app.Context, e app.Event) { 824 issueID := ctx.JSSrc().Get("value").String() 825 issueIDInt, err := strconv.Atoi(issueID) 826 if err != nil { 827 log.Fatal(err) 828 } 829 a.currentIssueInSlice = issueIDInt - 1 830 a.Solutions = a.issues[issueIDInt-1].Solutions 831 a.AsideTitle = asideTitleList 832 } 833 834 func (a *acid) asideOpenList(ctx app.Context, e app.Event) { 835 app.Window().Get("document").Call("querySelector", ".l-aside").Get("classList").Call("remove", "is-collapsed") 836 } 837 838 func (a *acid) asidePreloadCreate(ctx app.Context, e app.Event) { 839 a.AsideTitle = asideTitleCreate 840 } 841 842 func (a *acid) asideOpenCreate(ctx app.Context, e app.Event) { 843 app.Window().Get("document").Call("querySelector", ".l-aside").Get("classList").Call("remove", "is-collapsed") 844 app.Window().Get("document").Call("querySelector", ".p-button--positive").Call("setAttribute", "id", ctx.JSSrc().Get("value").String()) 845 } 846 847 func (a *acid) asideClose(ctx app.Context, e app.Event) { 848 app.Window().Get("document").Call("querySelector", ".l-aside").Get("classList").Call("add", "is-collapsed") 849 } 850 851 func (a *acid) onSolution(ctx app.Context, e app.Event) { 852 a.currentSolutionDescription = ctx.JSSrc().Get("value").String() 853 } 854 855 func (a *acid) vote(ctx app.Context, e app.Event) { 856 ctx.JSSrc().Get("firstChild").Get("classList").Call("remove", "fa-regular") 857 858 val := ctx.JSSrc().Get("value").String() 859 solutionID, err := strconv.Atoi(val) 860 if err != nil { 861 log.Fatal(err) 862 } 863 864 currentIssue := a.issues[a.currentIssueInSlice] 865 var delegate bool 866 var delegatedVotes int 867 var ownVote bool 868 // delegated voting logic 869 for i, d := range currentIssue.Delegates { 870 if a.citizenID == d.CitizenID { 871 delegate = true 872 ownVote = currentIssue.Delegates[i].OwnVote 873 if !d.OwnVote { 874 currentIssue.Delegates[i].OwnVote = true 875 876 } 877 delegatedVotes = d.Votes 878 currentIssue.Delegates[i].Votes = 0 879 } 880 } 881 882 currentIssue.Voters = append(currentIssue.Voters, a.citizenID) 883 884 if delegate { 885 if !ownVote { 886 currentIssue.Solutions[solutionID-1].Votes += delegatedVotes + 1 887 } else { 888 currentIssue.Solutions[solutionID-1].Votes += delegatedVotes 889 } 890 891 } else { 892 currentIssue.Solutions[solutionID-1].Votes++ 893 } 894 895 i, err := json.Marshal(currentIssue) 896 if err != nil { 897 log.Fatal(err) 898 } 899 ctx.Async(func() { 900 err = a.sh.OrbitDocsPut(dbNameIssue, i) 901 if err != nil { 902 ctx.Dispatch(func(ctx app.Context) { 903 a.createNotification(ctx, NotificationDanger, ErrorHeader, "Could not vote for solution. Try again later.") 904 log.Fatal(err) 905 }) 906 } 907 err = a.sh.PubSubPublish(topicIssue, string(i)) 908 if err != nil { 909 log.Fatal(err) 910 } 911 ctx.Dispatch(func(ctx app.Context) { 912 a.issues[a.currentIssueInSlice] = currentIssue 913 a.createNotification(ctx, NotificationSuccess, SuccessHeader, "Vote accepted.") 914 }) 915 }) 916 } 917 918 func (a *acid) openRankingsDialog(ctx app.Context, e app.Event) { 919 for _, i := range a.issues { 920 a.delegates = append(a.delegates, i.Delegates...) 921 } 922 sort.SliceStable(a.delegates, func(i, j int) bool { 923 return a.delegates[i].Selected > a.delegates[j].Selected 924 }) 925 app.Window().GetElementByID("rankings-modal").Set("style", "display:flex") 926 } 927 928 func (a *acid) openHowToDialog(ctx app.Context, e app.Event) { 929 app.Window().GetElementByID("howto-modal").Set("style", "display:flex") 930 } 931 932 func (a *acid) openDelegateDialog(ctx app.Context, e app.Event) { 933 app.Window().GetElementByID("delegate-modal").Set("style", "display:flex") 934 } 935 936 func (a *acid) delegate(ctx app.Context, e app.Event) { 937 citizenID := ctx.JSSrc().Get("value").String() 938 issue := a.issues[a.currentIssueInSlice] 939 940 var delegateExists bool 941 var delegate Delegate 942 votesTransfer := 1 943 if len(issue.Delegates) > 0 { 944 for ii, dd := range issue.Delegates { 945 // recursive delegation logic 946 if dd.CitizenID == a.citizenID { 947 // transfer origin votes to recipient plus own vote 948 votesTransfer = dd.Votes + 1 949 // set origin delegator's votes to zero 950 issue.Delegates[ii].Votes = 0 951 // set origin delegator as voted 952 issue.Delegates[ii].OwnVote = true 953 } 954 } 955 956 for i, d := range issue.Delegates { 957 if d.CitizenID == citizenID { 958 issue.Delegates[i].Votes += votesTransfer 959 issue.Delegates[i].Selected++ 960 delegateExists = true 961 } 962 } 963 } 964 965 if len(issue.Delegates) == 0 || !delegateExists { 966 delegate = Delegate{ 967 CitizenID: citizenID, 968 Votes: votesTransfer, 969 Selected: 1, 970 } 971 issue.Delegates = append(issue.Delegates, delegate) 972 } 973 issue.Voters = append(issue.Voters, a.citizenID) 974 var voters []string 975 for _, v := range issue.Voters { 976 // if the delegate already voted previously remove from voters so he can vote again on new delegation 977 if citizenID != v { 978 voters = append(voters, v) 979 } 980 } 981 issue.Voters = voters 982 983 i, err := json.Marshal(issue) 984 if err != nil { 985 log.Fatal(err) 986 } 987 988 ctx.Async(func() { 989 err = a.sh.OrbitDocsPut(dbNameIssue, i) 990 if err != nil { 991 log.Fatal(err) 992 } 993 994 err = a.sh.PubSubPublish(topicIssue, string(i)) 995 if err != nil { 996 log.Fatal(err) 997 } 998 999 ctx.Dispatch(func(ctx app.Context) { 1000 a.issues[a.currentIssueInSlice] = issue 1001 a.closeDelegateModal(ctx, e) 1002 a.createNotification(ctx, NotificationSuccess, SuccessHeader, "Vote delegated.") 1003 }) 1004 }) 1005 } 1006 1007 func (a *acid) toggleAccordion(ctx app.Context, e app.Event) { 1008 id := ctx.JSSrc().Get("value").String() 1009 attr := app.Window().GetElementByID(id).Get("attributes") 1010 aria := attr.Get("aria-hidden").Get("value").String() 1011 if aria == "false" { 1012 app.Window().GetElementByID(id).Call("setAttribute", "aria-hidden", "true") 1013 } else { 1014 app.Window().GetElementByID(id).Call("setAttribute", "aria-hidden", "false") 1015 } 1016 } 1017 1018 func (a *acid) closeRankingsModal(ctx app.Context, e app.Event) { 1019 app.Window().GetElementByID("rankings-modal").Set("style", "display:none") 1020 } 1021 1022 func (a *acid) closeHowToModal(ctx app.Context, e app.Event) { 1023 app.Window().GetElementByID("howto-modal").Set("style", "display:none") 1024 } 1025 1026 func (a *acid) closeDelegateModal(ctx app.Context, e app.Event) { 1027 app.Window().GetElementByID("delegate-modal").Set("style", "display:none") 1028 } 1029 1030 func (a *acid) submitSolution(ctx app.Context, e app.Event) { 1031 idStr := ctx.JSSrc().Get("id").String() 1032 id, err := strconv.Atoi(idStr) 1033 if err != nil { 1034 log.Fatal(err) 1035 } 1036 1037 lastSolutionID := 0 1038 unique := true 1039 if len(a.issues[id-1].Solutions) > 0 { 1040 solutions := a.issues[id-1].Solutions 1041 for n, s := range solutions { 1042 if s.Description == a.currentSolutionDescription { 1043 unique = false 1044 } 1045 if n > 0 { 1046 currentID, err := strconv.Atoi(s.ID) 1047 if err != nil { 1048 log.Fatal(err) 1049 } 1050 previousID, err := strconv.Atoi(solutions[n-1].ID) 1051 if err != nil { 1052 log.Fatal(err) 1053 } 1054 if currentID > previousID { 1055 lastSolutionID = currentID 1056 } 1057 } else { 1058 lastSolutionID = 1 1059 } 1060 } 1061 } 1062 1063 if unique { 1064 solution := Solution{ 1065 ID: strconv.Itoa(lastSolutionID + 1), 1066 Description: a.currentSolutionDescription, 1067 Votes: 0, 1068 } 1069 1070 a.issues[id-1].Solutions = append(a.issues[id-1].Solutions, solution) 1071 1072 i, err := json.Marshal(a.issues[id-1]) 1073 if err != nil { 1074 log.Fatal(err) 1075 } 1076 1077 ctx.Async(func() { 1078 err = a.sh.OrbitDocsPut(dbNameIssue, i) 1079 if err != nil { 1080 ctx.Dispatch(func(ctx app.Context) { 1081 a.createNotification(ctx, NotificationDanger, ErrorHeader, "Could not create solution. Try again later.") 1082 log.Fatal(err) 1083 }) 1084 } 1085 err = a.sh.PubSubPublish(topicIssue, string(i)) 1086 if err != nil { 1087 log.Fatal(err) 1088 } 1089 1090 ctx.Dispatch(func(ctx app.Context) { 1091 app.Window().Get("document").Call("querySelector", ".l-aside").Get("classList").Call("add", "is-collapsed") 1092 a.createNotification(ctx, NotificationSuccess, SuccessHeader, "Solution submited.") 1093 }) 1094 }) 1095 } 1096 } 1097 1098 func (a *acid) createNotification(ctx app.Context, s NotificationStatus, h string, msg string) { 1099 a.notificationID++ 1100 a.notifications[strconv.Itoa(a.notificationID)] = notification{ 1101 id: a.notificationID, 1102 status: string(s), 1103 header: h, 1104 message: msg, 1105 } 1106 1107 ntfs := a.notifications 1108 ctx.Async(func() { 1109 for n := range ntfs { 1110 time.Sleep(5 * time.Second) 1111 delete(ntfs, n) 1112 ctx.Async(func() { 1113 ctx.Dispatch(func(ctx app.Context) { 1114 a.notifications = ntfs 1115 }) 1116 }) 1117 } 1118 }) 1119 } 1120 1121 // https://play.golang.org/p/Qg_uv_inCek 1122 // contains checks if a string is present in a slice 1123 func sliceContains(s []string, str string) bool { 1124 for _, v := range s { 1125 if v == str { 1126 return true 1127 } 1128 } 1129 1130 return false 1131 } 1132 1133 // The main function is the entry point where the app is configured and started. 1134 // It is executed in 2 different environments: A client (the web browser) and a 1135 // server. 1136 func main() { 1137 // The first thing to do is to associate the hello component with a path. 1138 // 1139 // This is done by calling the Route() function, which tells go-app what 1140 // component to display for a given path, on both client and server-side. 1141 app.Route("/", func() app.Composer{ 1142 return &acid{} 1143 }) 1144 1145 // Once the routes set up, the next thing to do is to either launch the app 1146 // or the server that serves the app. 1147 // 1148 // When executed on the client-side, the RunWhenOnBrowser() function 1149 // launches the app, starting a loop that listens for app events and 1150 // executes client instructions. Since it is a blocking call, the code below 1151 // it will never be executed. 1152 // 1153 // When executed on the server-side, RunWhenOnBrowser() does nothing, which 1154 // lets room for server implementation without the need for precompiling 1155 // instructions. 1156 app.RunWhenOnBrowser() 1157 1158 // Finally, launching the server that serves the app is done by using the Go 1159 // standard HTTP package. 1160 // 1161 // The Handler is an HTTP handler that serves the client and all its 1162 // required resources to make it work into a web browser. Here it is 1163 // configured to handle requests with a path that starts with "/". 1164 1165 withGz := gziphandler.GzipHandler(&app.Handler{ 1166 Name: "cyber-acid", 1167 Description: "Cyber Acid - Liquid democracy politics simulator based on personal reputation index", 1168 Styles: []string{ 1169 "https://assets.ubuntu.com/v1/vanilla-framework-version-3.8.0.min.css", 1170 "https://use.fontawesome.com/releases/v6.2.0/css/all.css", 1171 }, 1172 Scripts: []string{}, 1173 }) 1174 http.Handle("/", withGz) 1175 1176 if err := http.ListenAndServe(":7000", nil); err != nil { 1177 log.Fatal(err) 1178 } 1179 }