mail based rss feed aggregator
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

managing subscriptions through the UI!! (mostly) TODO: handle starting new fetcher/sender & stop when not needed anymore

ollie cb6eaa8e 89b8a7cb

+688 -112
+664 -98
src/eater/ui/main_ui.gleam
··· 19 19 import eater/configuration 20 20 import eater/database 21 21 import eater/feed/rss 22 + import eater/fetcher 23 + import eater/pubsub 24 + import eater/sender 22 25 import eater/smtp 23 26 import eater/ui/toaster 24 27 import eater/user 25 28 import formal/form.{type Form} 26 29 import gcourier/smtp as gsmtp 30 + import glaze/oat/button 27 31 import glaze/oat/card 28 32 import glaze/oat/form as gform 33 + import glaze/oat/table 29 34 import glaze/oat/toast 30 35 import gleam/bit_array 31 36 import gleam/crypto ··· 33 38 import gleam/int 34 39 import gleam/list 35 40 import gleam/option.{None, Some} 41 + import gleam/otp/actor 42 + import gleam/result 36 43 import gleam/string 44 + import gleam/uri 37 45 import lustre 38 46 import lustre/attribute 39 47 import lustre/effect.{type Effect} ··· 62 70 Context( 63 71 csrf_token: String, 64 72 database: sqlight.Connection, 73 + registry: pubsub.Registry, 74 + sender_factory: sender.FactoryName, 75 + fetcher_factory: fetcher.FactoryName, 65 76 smtp_environment: smtp.SmtpEnvironment, 66 77 configuration: configuration.AppConfig, 67 78 toasts: List(toaster.Toast), ··· 84 95 pub fn new_context( 85 96 csrf_token _csrf_token: String, 86 97 database database: sqlight.Connection, 98 + registry registry: pubsub.Registry, 99 + sender_factory sender_factory: sender.FactoryName, 100 + fetcher_factory fetcher_factory: fetcher.FactoryName, 87 101 smtp_environment smtp_environment: smtp.SmtpEnvironment, 88 102 configuration configuration: configuration.AppConfig, 89 103 ) -> Context { ··· 94 108 Context( 95 109 csrf_token: session_id, 96 110 database:, 111 + registry:, 112 + sender_factory:, 113 + fetcher_factory:, 97 114 smtp_environment:, 98 115 configuration:, 99 116 toasts: [], ··· 125 142 } 126 143 127 144 type UserData { 128 - UserData(self: user.User, subscriptions: List(rss.Location)) 145 + UserData( 146 + self: user.User, 147 + subscriptions: List(rss.Location), 148 + add_subscription_form: Form(uri.Uri), 149 + ) 129 150 } 130 151 131 152 type AdminData { ··· 133 154 self: user.User, 134 155 subscriptions: List(rss.Location), 135 156 users: List(user.User), 157 + add_subscription_form: Form(uri.Uri), 136 158 ) 137 159 } 138 160 139 161 /// describe a model using structured data 140 162 /// 141 - fn describe_model(model: Model) { 163 + fn describe_model(model: Model) -> List(#(String, String)) { 142 164 case model { 143 165 SignUpForm(_data, form) -> [ 144 166 woof.field("model", "SignUpForm"), ··· 159 181 woof.field("model", "LoginForm"), 160 182 woof.field("form-email", form.field_value(form, "email")), 161 183 ] 162 - User(context: _, data: UserData(self:, subscriptions:)) -> [ 184 + User( 185 + context: _, 186 + data: UserData(self:, subscriptions:, add_subscription_form: _), 187 + ) -> [ 163 188 woof.field("model", "User"), 164 189 woof.field("user-email", self.email), 165 190 woof.int_field("subscription-count", subscriptions |> list.length()), 166 191 ] 167 - Admin(context: _, data: AdminData(self:, subscriptions:, users: _)) -> [ 192 + Admin( 193 + context: _, 194 + data: AdminData(self:, subscriptions:, users: _, add_subscription_form: _), 195 + ) -> [ 168 196 woof.field("model", "Admin"), 169 197 woof.field("user-email", self.email), 170 198 woof.int_field("subscription-count", subscriptions |> list.length()), ··· 194 222 195 223 // toast 196 224 TimerDismissedToast 197 - // old 198 225 /// 199 226 ///```gleam 200 227 ///Result(Result(user.User, email), db_error) ··· 204 231 UserClickedGotoLogIn 205 232 DatabaseReturnedAllUsers(Result(List(user.User), sqlight.Error)) 206 233 DatabaseReturnedSubscriptions(Result(List(rss.Location), sqlight.Error)) 234 + UserClickedUnsubscribe(rss.Location) 235 + DatabaseDeletedSubscription(Result(rss.Location, sqlight.Error)) 236 + UnsubscribedInBackend 237 + UserSubmittedSubscription(Result(uri.Uri, Form(uri.Uri))) 238 + DatabaseReturnedFeed(Result(List(rss.Location), sqlight.Error), uri.Uri) 239 + DatabaseAddedFeed(Result(rss.Location, sqlight.Error)) 240 + DatabasePersistedSubscription(Result(rss.Location, sqlight.Error)) 241 + ServerCreatedNewFeed(rss.Location) 242 + ServerStartedNewSender( 243 + Result(actor.Started(Nil), actor.StartError), 244 + user.User, 245 + ) 246 + ServerStartedNewFetcher( 247 + Result(actor.Started(Nil), actor.StartError), 248 + rss.Location, 249 + ) 250 + SubscribedInBackend(rss.Location) 207 251 } 208 252 209 253 /// describe a message using structured data 210 254 /// 211 - fn describe_message(message: Message) { 255 + fn describe_message(message: Message) -> List(#(String, String)) { 212 256 case message { 213 257 UserSubmittedLoginForm(Ok(login)) -> [ 214 258 woof.field("message", "UserSubmittedEmail"), ··· 310 354 woof.field("status", "error"), 311 355 woof.field("details", string.inspect(db_error)), 312 356 ] 357 + UserClickedUnsubscribe(location) -> [ 358 + woof.field("message", "UserClickedUnsubscribe"), 359 + woof.field("feed", location.link |> uri.to_string()), 360 + ] 361 + DatabaseDeletedSubscription(Ok(location)) -> [ 362 + woof.field("message", "DatabaseDeletedSubscription"), 363 + woof.field("status", "ok"), 364 + woof.field("feed", location.link |> uri.to_string()), 365 + ] 366 + DatabaseDeletedSubscription(Error(db_error)) -> [ 367 + woof.field("message", "DatabaseDeletedSubscription"), 368 + woof.field("status", "error"), 369 + woof.field("details", string.inspect(db_error)), 370 + ] 371 + UnsubscribedInBackend -> [ 372 + woof.field("message", "UnsubscribedInBackend"), 373 + ] 374 + SubscribedInBackend(feed) -> [ 375 + woof.field("message", "SubscribedInBackend"), 376 + woof.field("status", "ok"), 377 + woof.field("feed", feed.link |> uri.to_string()), 378 + ] 379 + UserSubmittedSubscription(Ok(link)) -> [ 380 + woof.field("message", "UserSubmittedSubscription"), 381 + woof.field("status", "ok"), 382 + woof.field("feed", link |> uri.to_string()), 383 + ] 384 + UserSubmittedSubscription(Error(_)) -> [ 385 + woof.field("message", "UserSubmittedSubscription"), 386 + woof.field("status", "error"), 387 + ] 388 + DatabaseReturnedFeed(Ok([]), _feed) -> [ 389 + woof.field("message", "DatabaseReturnedFeed"), 390 + woof.field("status", "ok"), 391 + ] 392 + DatabaseReturnedFeed(Ok([feed, ..]), _feed) -> [ 393 + woof.field("message", "DatabaseReturnedFeed"), 394 + woof.field("status", "ok"), 395 + woof.field("feed-link", feed.link |> uri.to_string()), 396 + woof.field("feed-id", feed.id |> uuid.to_string()), 397 + ] 398 + DatabaseReturnedFeed(Error(db_error), feed) -> [ 399 + woof.field("message", "DatabaseReturnedFeed"), 400 + woof.field("status", "error"), 401 + woof.field("feed", feed |> uri.to_string()), 402 + woof.field("details", string.inspect(db_error)), 403 + ] 404 + DatabaseAddedFeed(Ok(feed)) -> [ 405 + woof.field("message", "DatabaseAddedFeed"), 406 + woof.field("status", "ok"), 407 + woof.field("feed-link", feed.link |> uri.to_string()), 408 + woof.field("feed-id", feed.id |> uuid.to_string()), 409 + ] 410 + DatabaseAddedFeed(Error(db_error)) -> [ 411 + woof.field("message", "DatabaseAddedFeed"), 412 + woof.field("status", "error"), 413 + woof.field("details", string.inspect(db_error)), 414 + ] 415 + DatabasePersistedSubscription(Ok(feed)) -> [ 416 + woof.field("message", "DatabasePersistedSubscription"), 417 + woof.field("status", "ok"), 418 + woof.field("feed-link", feed.link |> uri.to_string()), 419 + woof.field("feed-id", feed.id |> uuid.to_string()), 420 + ] 421 + DatabasePersistedSubscription(Error(db_error)) -> [ 422 + woof.field("message", "DatabasePersistedSubscription"), 423 + woof.field("status", "error"), 424 + woof.field("details", string.inspect(db_error)), 425 + ] 426 + ServerCreatedNewFeed(feed) -> [ 427 + woof.field("message", "ServerCreatedNewFeed"), 428 + woof.field("feed-link", feed.link |> uri.to_string()), 429 + woof.field("feed-id", feed.id |> uuid.to_string()), 430 + ] 431 + ServerStartedNewSender(_, _) -> todo 432 + ServerStartedNewFetcher(_, _) -> todo 313 433 } 314 434 } 315 435 ··· 369 489 effect, 370 490 ) 371 491 } 372 - ServerSentPassword(Error(_), password_to_confirm: _, user: _), _ -> { 373 - log_model_n_message( 374 - model:, 375 - message:, 376 - at: woof.Error, 377 - with: "Database error", 492 + ServerSentPassword(Error(_), password_to_confirm: _, user: _), _ -> 493 + log_and_toast( 494 + model, 495 + message, 496 + "gcourier error", 497 + Some("Something went wrong"), 498 + "I ran into an issue :/", 378 499 ) 379 500 380 - let toast = 381 - toaster.Toast( 382 - title: None, 383 - message: "I ran into a problem while sending the one time password", 384 - options: toast.default_options(toast.Danger), 385 - ) 386 - do_toast(toast, model) 387 - } 388 - 389 501 // one time password matches, add a new user 390 502 UserSubmittedOneTimePassword(Ok(password)), 391 503 ConfirmOneTimePassword(password_to_confirm:, context: _, user:, ..) ··· 397 509 persist_new_user(user, model.context.database), 398 510 ) 399 511 DatabasePersistedNewUser(Ok(user)), _ -> #( 400 - User(model.context, UserData(user, [])), 512 + User(model.context, UserData(user, [], add_subscription_form())), 401 513 fetch_logged_in_data(model.context.database, user), 402 514 ) 403 - DatabasePersistedNewUser(Error(_)), _ -> { 404 - log_model_n_message( 405 - model:, 406 - message:, 407 - at: woof.Error, 408 - with: "Database error", 515 + DatabasePersistedNewUser(Error(_)), _ -> 516 + log_and_toast( 517 + model, 518 + message, 519 + "Database error", 520 + Some("Something went wrong"), 521 + "I ran into an issue :/", 409 522 ) 410 - 411 - let toast = 412 - toaster.Toast( 413 - title: Some("Something went wrong"), 414 - message: "I ran into an issue :/", 415 - options: toast.default_options(toast.Danger), 416 - ) 417 - do_toast(toast, model) 418 - } 419 523 420 524 // doesnt match, let the user retry up to 3 times 421 525 UserSubmittedOneTimePassword(Ok(_)), ··· 511 615 } 512 616 } 513 617 } 514 - DatabaseReturnedUser(Error(_), _), _ -> { 515 - log_model_n_message( 516 - model:, 517 - message:, 518 - at: woof.Error, 519 - with: "Database error", 618 + DatabaseReturnedUser(Error(_), _), _ -> 619 + log_and_toast( 620 + model, 621 + message, 622 + "Database error", 623 + None, 624 + "I ran into a problem", 520 625 ) 521 626 522 - let toast = 523 - toaster.Toast( 524 - title: None, 525 - message: "I ran into a problem", 526 - options: toast.default_options(toast.Danger), 527 - ) 528 - do_toast(toast, model) 529 - } 530 - 531 627 // login admin 532 628 ServerVerifiedLogin(valid: True, user:), _ if user.is_admin -> #( 533 - Admin(model.context, AdminData(user, [], [])), 629 + Admin(model.context, AdminData(user, [], [], add_subscription_form())), 534 630 fetch_logged_in_data(model.context.database, user), 535 631 ) 536 632 // login normal user 537 633 ServerVerifiedLogin(valid: True, user:), _ -> #( 538 - User(model.context, UserData(user, [])), 634 + User(model.context, UserData(user, [], add_subscription_form())), 539 635 fetch_logged_in_data(model.context.database, user), 540 636 ) 541 637 ··· 577 673 578 674 // user data didnt arrive 579 675 // nu uh 580 - DatabaseReturnedSubscriptions(Error(_)), _ -> { 581 - log_model_n_message( 582 - model:, 583 - message:, 584 - at: woof.Error, 585 - with: "Database error", 676 + DatabaseReturnedSubscriptions(Error(_)), _ -> 677 + log_and_toast( 678 + model, 679 + message, 680 + "Database error", 681 + Some("Failed to fetch subscriptions"), 682 + "I failed to get your subscriptions. Try again in a bit.", 586 683 ) 587 684 588 - let toast = 589 - toaster.Toast( 590 - title: Some("Failed to fetch subscriptions"), 591 - message: "I failed to get your subscriptions. Try again in a bit.", 592 - options: toast.default_options(toast.Warning), 593 - ) 594 - 595 - do_toast(toast, model) 596 - } 597 - 598 685 DatabaseReturnedAllUsers(Ok(users)), Admin(context:, data:) -> #( 599 686 Admin(context:, data: AdminData(..data, users:)), 600 687 effect.none(), 601 688 ) 602 - DatabaseReturnedAllUsers(Error(_)), Admin(..) -> { 603 - log_model_n_message( 689 + DatabaseReturnedAllUsers(Error(_)), Admin(..) -> 690 + log_and_toast( 691 + model, 692 + message, 693 + "Database error", 694 + Some("Failed to fetch users"), 695 + "I failed to fetch all users. Try again in a bit.", 696 + ) 697 + 698 + // nu uh 699 + DatabaseReturnedAllUsers(_), _ -> #(model, effect.none()) 700 + 701 + // removing subscriptions --------------------------------------------------- 702 + UserClickedUnsubscribe(feed), Admin(data: AdminData(self:, ..), ..) 703 + | UserClickedUnsubscribe(feed), User(data: UserData(self:, ..), ..) 704 + -> #(model, remove_subscription_from_database(model.context, self, feed)) 705 + // nu uh 706 + UserClickedUnsubscribe(_), _ -> #(model, effect.none()) 707 + 708 + DatabaseDeletedSubscription(Ok(feed)), User(context:, data:) -> #( 709 + User( 710 + context:, 711 + data: UserData( 712 + ..data, 713 + subscriptions: drop_subscription_from_list(data.subscriptions, feed), 714 + ), 715 + ), 716 + notify_backend_of_subscription_removal( 717 + model.context, 718 + model.data.self, 719 + feed, 720 + ), 721 + ) 722 + 723 + DatabaseDeletedSubscription(Ok(feed)), Admin(context:, data:) -> #( 724 + Admin( 725 + context:, 726 + data: AdminData( 727 + ..data, 728 + subscriptions: drop_subscription_from_list(data.subscriptions, feed), 729 + ), 730 + ), 731 + notify_backend_of_subscription_removal( 732 + model.context, 733 + model.data.self, 734 + feed, 735 + ), 736 + ) 737 + // nu uh 738 + DatabaseDeletedSubscription(Ok(_)), _ -> #(model, effect.none()) 739 + DatabaseDeletedSubscription(Error(_)), _ -> 740 + log_and_toast( 741 + model, 742 + message, 743 + "Database error", 744 + Some("Failed to unsubscribe"), 745 + "I failed to unsubscribe you from that feed. Try again in a bit.", 746 + ) 747 + 748 + UnsubscribedInBackend, _ -> #(model, effect.none()) 749 + 750 + // adding subscriptions ----------------------------------------------------- 751 + // new subscriptions; invalid url 752 + UserSubmittedSubscription(Error(form)), User(context:, data:) -> #( 753 + User(context:, data: UserData(..data, add_subscription_form: form)), 754 + effect.none(), 755 + ) 756 + UserSubmittedSubscription(Error(form)), Admin(context:, data:) -> #( 757 + Admin(context:, data: AdminData(..data, add_subscription_form: form)), 758 + effect.none(), 759 + ) 760 + // nu uh 761 + UserSubmittedSubscription(Error(_)), _ -> #(model, effect.none()) 762 + 763 + // new subscriptions; valid url 764 + UserSubmittedSubscription(Ok(url)), _ -> #( 765 + model, 766 + get_feed_from_database(model.context, url), 767 + ) 768 + 769 + // feed already exists in database, just need to add a subscription 770 + DatabaseReturnedFeed(Ok([feed, ..]), _), User(data: UserData(self:, ..), ..) 771 + | DatabaseReturnedFeed(Ok([feed, ..]), _), 772 + Admin(data: AdminData(self:, ..), ..) 773 + -> #(model, persist_subscription(model.context, self, feed)) 774 + 775 + // feed doesnt exist yet, create a new `rss.Location` for it 776 + DatabaseReturnedFeed(Ok([]), uri), _ -> #(model, create_new_feed(uri)) 777 + 778 + // nu uh 779 + DatabaseReturnedFeed(Ok(_), _), _ -> #(model, effect.none()) 780 + 781 + DatabaseReturnedFeed(Error(_), _), _ -> 782 + log_and_toast( 783 + model, 784 + message, 785 + "Database error", 786 + Some("Failed to subscribe"), 787 + "I failed to subscribe you to that feed. Try again in a bit.", 788 + ) 789 + 790 + // we made a new `rss.Location`, lets save it 791 + ServerCreatedNewFeed(feed), _ -> #( 792 + model, 793 + persist_new_feed(model.context, feed), 794 + ) 795 + 796 + // the new `rss.Location` has been saved to the database 797 + // lets also save a new subscription with it 798 + DatabaseAddedFeed(Ok(feed)), User(context:, data: UserData(self:, ..)) -> #( 799 + model, 800 + persist_subscription(context, self, feed), 801 + ) 802 + DatabaseAddedFeed(Ok(feed)), Admin(context:, data: AdminData(self:, ..)) -> #( 803 + model, 804 + persist_subscription(context, self, feed), 805 + ) 806 + // nu uh 807 + DatabaseAddedFeed(Ok(_)), _ -> #(model, effect.none()) 808 + 809 + DatabaseAddedFeed(Error(_)), _ -> 810 + log_and_toast( 604 811 model:, 605 812 message:, 606 - at: woof.Error, 607 - with: "Database error", 813 + error_message: "Database error", 814 + toast_title: Some("Welp"), 815 + toast_message: "I ran into a problem while subscribing you to that feed. Try again in a bit.", 608 816 ) 609 817 610 - let toast = 611 - toaster.Toast( 612 - title: Some("Failed to fetch users"), 613 - message: "I failed to fetch all users. Try again in a bit.", 614 - options: toast.default_options(toast.Warning), 615 - ) 818 + // add the feed to the list, tell the backend about it and clear the form 819 + DatabasePersistedSubscription(Ok(feed)), 820 + User(context:, data: UserData(self:, subscriptions:, ..)) 821 + -> #( 822 + User( 823 + context:, 824 + data: UserData( 825 + ..model.data, 826 + subscriptions: [feed, ..subscriptions], 827 + add_subscription_form: add_subscription_form(), 828 + ), 829 + ), 830 + subscribe_in_backend(context, self, feed), 831 + ) 832 + DatabasePersistedSubscription(Ok(feed)), 833 + Admin(context:, data: AdminData(self:, subscriptions:, ..)) 834 + -> #( 835 + Admin( 836 + context:, 837 + data: AdminData( 838 + ..model.data, 839 + subscriptions: [feed, ..subscriptions], 840 + add_subscription_form: add_subscription_form(), 841 + ), 842 + ), 843 + subscribe_in_backend(context, self, feed), 844 + ) 845 + // nu uh 846 + DatabasePersistedSubscription(Ok(_)), _ -> #(model, effect.none()) 616 847 617 - do_toast(toast, model) 848 + DatabasePersistedSubscription(Error(_)), _ -> 849 + log_and_toast( 850 + model:, 851 + message:, 852 + error_message: "Database error", 853 + toast_title: Some("Ooppsie"), 854 + toast_message: "I failed to subscribe you to that feed right now. Try again in a bit.", 855 + ) 856 + SubscribedInBackend(_), _ -> #(model, effect.none()) 857 + 858 + ServerStartedNewSender(_, _), _ -> todo 859 + ServerStartedNewFetcher(_, _), _ -> todo 860 + } 861 + } 862 + 863 + /// log an error message, and show a toast about a db error 864 + /// 865 + fn log_and_toast( 866 + model model: Model, 867 + message message: Message, 868 + error_message error_message: String, 869 + toast_title title: option.Option(String), 870 + toast_message toast_message: String, 871 + ) -> #(Model, Effect(Message)) { 872 + log_model_n_message(model:, message:, at: woof.Error, with: error_message) 873 + 874 + let toast = 875 + toaster.Toast( 876 + title:, 877 + message: toast_message, 878 + options: toast.default_options(toast.Warning), 879 + ) 880 + 881 + do_toast(toast, model) 882 + } 883 + 884 + /// persist a new subscription to the database 885 + /// 886 + /// `DatabasePersistedSubscription` 887 + /// 888 + fn persist_subscription( 889 + context: Context, 890 + user: user.User, 891 + feed: rss.Location, 892 + ) -> Effect(Message) { 893 + use dispatch <- effect.from 894 + 895 + database.add_subscription(user, feed, context.database) 896 + |> DatabasePersistedSubscription 897 + |> dispatch 898 + } 899 + 900 + /// create a new `rss.Location` using a uri 901 + /// 902 + /// `ServerCreatedNewFeed` 903 + /// 904 + fn create_new_feed(feed: uri.Uri) -> Effect(Message) { 905 + use dispatch <- effect.from 906 + 907 + rss.new_location(feed) 908 + |> ServerCreatedNewFeed 909 + |> dispatch 910 + } 911 + 912 + /// persist a new feed to the database 913 + /// 914 + /// `DatabaseAddedFeed` 915 + /// 916 + fn persist_new_feed(context: Context, feed: rss.Location) -> Effect(Message) { 917 + use dispatch <- effect.from 918 + 919 + database.add_feed(feed, context.database) 920 + |> DatabaseAddedFeed 921 + |> dispatch 922 + } 923 + 924 + /// get a feed from the database using a uri 925 + /// 926 + /// `DatabaseReturnedFeed` 927 + /// 928 + fn get_feed_from_database(context: Context, uri: uri.Uri) -> Effect(Message) { 929 + use dispatch <- effect.from 930 + 931 + database.feed_by_link(uri, context.database) 932 + |> DatabaseReturnedFeed(uri) 933 + |> dispatch 934 + } 935 + 936 + /// notify the currently running backend components 937 + /// and start the ones that arnt running 938 + /// 939 + fn subscribe_in_backend( 940 + context: Context, 941 + user: user.User, 942 + feed: rss.Location, 943 + ) -> Effect(Message) { 944 + effect.batch([ 945 + ensure_sender_running(context, user), 946 + ensure_fetcher_running(context, feed), 947 + notify_backend_of_subscription(context, user, feed), 948 + ]) 949 + } 950 + 951 + /// notify the backend of a new subscription 952 + /// 953 + /// `SubscribedInBackend` 954 + /// 955 + fn notify_backend_of_subscription( 956 + context: Context, 957 + user: user.User, 958 + feed: rss.Location, 959 + ) -> Effect(Message) { 960 + use dispatch <- effect.from 961 + 962 + pubsub.publish_subscribed_to_feed(user, feed, context.registry) 963 + 964 + dispatch(SubscribedInBackend(feed)) 965 + } 966 + 967 + /// ensure the sender for this user is running 968 + /// 969 + fn ensure_sender_running(context: Context, user: user.User) -> Effect(Message) { 970 + let Context(database:, registry:, sender_factory:, smtp_environment:, ..) = 971 + context 972 + 973 + case pubsub.subscriber_count_user(user, registry) { 974 + [] -> { 975 + use dispatch <- effect.from 976 + sender.start_new( 977 + sender_factory, 978 + sender.Start(database:, registry:, user:, smtp_environment:), 979 + ) 980 + |> ServerStartedNewSender(user) 981 + |> dispatch 982 + } 983 + [_, ..] -> effect.none() 984 + } 985 + } 986 + 987 + /// ensure the fetcher for this feed is running 988 + /// 989 + fn ensure_fetcher_running( 990 + context: Context, 991 + feed: rss.Location, 992 + ) -> Effect(Message) { 993 + let Context(database:, registry:, fetcher_factory:, ..) = context 994 + 995 + case pubsub.subscriber_count_feed(feed, registry) { 996 + [] -> { 997 + use dispatch <- effect.from 998 + 999 + fetcher.start_new( 1000 + fetcher_factory, 1001 + fetcher.Start(feed:, registry:, database:), 1002 + ) 1003 + |> ServerStartedNewFetcher(feed) 1004 + |> dispatch 618 1005 } 619 - // nu uh 620 - DatabaseReturnedAllUsers(_), _ -> #(model, effect.none()) 1006 + [_, ..] -> effect.none() 621 1007 } 622 1008 } 623 1009 1010 + /// handles unsubscribing in the backend 1011 + /// 1012 + /// `UnsubscribedInBackend` 1013 + /// 1014 + fn notify_backend_of_subscription_removal( 1015 + context: Context, 1016 + user: user.User, 1017 + feed: rss.Location, 1018 + ) -> Effect(Message) { 1019 + use dispatch <- effect.from 1020 + 1021 + pubsub.publish_unsubscribed_from_feed(user, feed, context.registry) 1022 + 1023 + dispatch(UnsubscribedInBackend) 1024 + } 1025 + 1026 + /// remove a specific subscription from a list 1027 + /// 1028 + fn drop_subscription_from_list( 1029 + from subscriptions: List(rss.Location), 1030 + remove to_remove: rss.Location, 1031 + ) -> List(rss.Location) { 1032 + list.filter(subscriptions, fn(subscription) { 1033 + subscription.id != to_remove.id 1034 + }) 1035 + } 1036 + 1037 + /// remove a given user -> feed relation from the database 1038 + /// 1039 + /// `DatabaseDeletedSubscription` 1040 + /// 1041 + fn remove_subscription_from_database( 1042 + context: Context, 1043 + user: user.User, 1044 + feed: rss.Location, 1045 + ) -> Effect(Message) { 1046 + use dispatch <- effect.from 1047 + 1048 + database.delete_subscription(user, feed, context.database) 1049 + |> DatabaseDeletedSubscription 1050 + |> dispatch 1051 + } 1052 + 624 1053 /// fetch data required when logged in 625 1054 /// user stuff 626 1055 /// admin stuff if applicaple ··· 671 1100 /// 672 1101 /// `ServerCreatedNewUser` 673 1102 /// 674 - fn create_new_user(user: FormUser) { 1103 + fn create_new_user(user: FormUser) -> Effect(Message) { 675 1104 use dispatch <- effect.from 676 1105 677 1106 user.new(user.email, user.hash_password(user.password)) ··· 742 1171 /// 743 1172 /// `ServerGeneratedPassword` 744 1173 /// 745 - fn generate_one_time_password(user: FormUser) { 1174 + fn generate_one_time_password(user: FormUser) -> Effect(Message) { 746 1175 use dispatch <- effect.from 747 1176 748 1177 crypto.strong_random_bytes(6) ··· 782 1211 busy: False, 783 1212 allow_signups: context.configuration.allow_signups, 784 1213 ) 1214 + 785 1215 User(context:, data:) -> view_logged_in_user(context, data) 786 1216 Admin(context:, data:) -> view_logged_in_admin(context, data) 787 1217 }, ··· 851 1281 ) 852 1282 } 853 1283 854 - fn view_logged_in_user(_context: Context, _user: UserData) -> Element(Message) { 855 - html.div([], [ 856 - html.h1([], [element.text("*hacker voice* im in!")]), 1284 + /// logged in user view 1285 + /// 1286 + fn view_logged_in_user(_context: Context, data: UserData) -> Element(Message) { 1287 + html.div([attribute.class("container")], [ 1288 + html.h2([], [element.text("Hello " <> data.self.email)]), 1289 + html.hr([]), 1290 + view_feed_list(data.add_subscription_form, data.subscriptions), 1291 + ]) 1292 + } 1293 + 1294 + /// logged in admin view 1295 + /// 1296 + fn view_logged_in_admin(_context: Context, data: AdminData) -> Element(Message) { 1297 + html.div([attribute.class("container")], [ 1298 + html.h2([], [element.text("Hello " <> data.self.email <> " (admin)")]), 1299 + html.hr([]), 1300 + html.div([], [ 1301 + view_feed_list(data.add_subscription_form, data.subscriptions), 1302 + ]), 857 1303 ]) 858 1304 } 859 1305 860 - fn view_logged_in_admin( 861 - _context: Context, 862 - _admin: AdminData, 1306 + /// table of feed-url with unsubscribe buttons 1307 + /// and a form to subscribe to new feeds 1308 + /// 1309 + fn view_feed_list( 1310 + form: Form(uri.Uri), 1311 + subscriptions: List(rss.Location), 863 1312 ) -> Element(Message) { 864 - html.div([], [ 865 - html.h1([], [element.text("*hacker voice* im in-er!")]), 1313 + let submitted = fn(fields) { 1314 + form 1315 + |> form.add_values(fields) 1316 + |> form.run 1317 + |> UserSubmittedSubscription 1318 + } 1319 + 1320 + table.container([], [ 1321 + html.h4([], [element.text("Your feeds")]), 1322 + table.table([], [ 1323 + // table.thead([], [ 1324 + // table.th([], [element.text("Feed url")]), 1325 + // table.th([], []), 1326 + // ]), 1327 + table.tbody([], list.map(subscriptions, view_feed_row)), 1328 + ]), 1329 + 1330 + html.hr([]), 1331 + 1332 + gform.form( 1333 + [ 1334 + attribute.method("POST"), 1335 + event.on_submit(submitted), 1336 + ], 1337 + [ 1338 + html.h4([], [element.text("Subscribe to new feed")]), 1339 + html.div([attribute.class("row")], [ 1340 + field_input_no_label( 1341 + [ 1342 + attribute.class("col-8"), 1343 + attribute.style("align-self", "baseline"), 1344 + ], 1345 + form, 1346 + name: "feed-uri", 1347 + kind: "text", 1348 + ), 1349 + button.button( 1350 + [ 1351 + attribute.class("col-4"), 1352 + attribute.style("width", "fit-content"), 1353 + attribute.style("height", "fit-content"), 1354 + attribute.style("align-self", "baseline"), 1355 + attribute.type_("submit"), 1356 + ], 1357 + [element.text("Subscribe")], 1358 + ), 1359 + ]), 1360 + ], 1361 + ), 866 1362 ]) 867 1363 } 1364 + 1365 + /// view a `rss.Location` as a `table.tr` with an unsubscribe button 1366 + /// 1367 + fn view_feed_row(location: rss.Location) -> Element(Message) { 1368 + table.tr([], [ 1369 + table.td([], [element.text(location.link |> uri.to_string())]), 1370 + // TODO: maybe put a status badge here 1371 + table.td([], [ 1372 + button.button([event.on_click(UserClickedUnsubscribe(location))], [ 1373 + element.text("Unsubscribe"), 1374 + ]), 1375 + ]), 1376 + ]) 1377 + } 1378 + 1379 + // login / signup --------------------------------------------------------------- 868 1380 869 1381 fn view_one_time_password_form( 870 1382 form form: Form(String), ··· 974 1486 975 1487 // formal ----------------------------------------------------------------------- 976 1488 1489 + /// 1490 + /// 1491 + fn add_subscription_form() -> Form(uri.Uri) { 1492 + form.new({ 1493 + let uri_parser = 1494 + form.parse(fn(values) { 1495 + case values { 1496 + [] -> Error(#(uri.empty, "Please supply a valid link")) 1497 + ["", ..] -> Error(#(uri.empty, "Please supply a valid link")) 1498 + [url, ..] -> 1499 + uri.parse(url) 1500 + |> result.replace_error(#( 1501 + uri.empty, 1502 + "The link you supplied is invalid.", 1503 + )) 1504 + } 1505 + }) 1506 + 1507 + use uri <- form.field("feed-uri", uri_parser) 1508 + 1509 + form.success(uri) 1510 + }) 1511 + } 1512 + 977 1513 fn login_form() -> Form(FormUser) { 978 1514 form.new({ 979 1515 use email <- form.field("email", { form.parse_email }) ··· 1049 1585 ]) 1050 1586 } 1051 1587 1588 + /// Render a single HTML form field. 1589 + /// 1590 + /// If the field already has a value then it is used as the HTML input value. 1591 + /// If the field has an error it is displayed. 1592 + /// 1593 + fn field_input_no_label( 1594 + attributes: List(attribute.Attribute(a)), 1595 + form: Form(t), 1596 + name name: String, 1597 + kind kind: String, 1598 + ) -> Element(a) { 1599 + let errors = form.field_error_messages(form, name) 1600 + 1601 + html.div([attribute.for(name), ..attributes], [ 1602 + // The input, for the user to type into 1603 + gform.input([ 1604 + attribute.type_(kind), 1605 + attribute.name(name), 1606 + attribute.default_value(form.field_value(form, name)), 1607 + ..case errors { 1608 + [] -> [attribute.none()] 1609 + _ -> [gform.invalid(), gform.described_by(name <> "-hint")] 1610 + } 1611 + ]), 1612 + // Any errors presented below 1613 + ..list.map(errors, fn(msg) { 1614 + html.small([attribute.id(name <> "-hint"), gform.hint(), gform.error()], [ 1615 + element.text(msg), 1616 + ]) 1617 + }) 1618 + ]) 1619 + } 1620 + 1052 1621 // helpers ---------------------------------------------------------------------- 1053 1622 1054 1623 /// dispatch a given message after a given timeout 1055 1624 /// 1056 - pub fn schedule_message( 1057 - dispatch message: a, 1058 - after timeout: Int, 1059 - ) -> effect.Effect(a) { 1625 + pub fn schedule_message(dispatch message: a, after timeout: Int) -> Effect(a) { 1060 1626 use dispatch <- effect.from 1061 1627 1062 1628 use <- run_after(timeout) ··· 1076 1642 1077 1643 /// update the model and schedule dismissal of a new toast 1078 1644 /// 1079 - fn do_toast(toast: toaster.Toast, model: Model) { 1645 + fn do_toast(toast: toaster.Toast, model: Model) -> #(Model, Effect(Message)) { 1080 1646 let context = 1081 1647 Context(..model.context, toasts: [toast, ..model.context.toasts]) 1082 1648
+24 -14
src/eater/webserver.gleam
··· 2 2 //// the webserver used to serve the lustre ui `main_ui` 3 3 4 4 import eater/configuration 5 + import eater/fetcher 5 6 import eater/pubsub 7 + import eater/sender 6 8 import eater/smtp 7 9 import eater/ui/main_ui 8 10 import ewe 9 11 import gleam/bytes_tree 10 12 import gleam/erlang/application 11 - import gleam/erlang/process.{type Selector} 13 + import gleam/erlang/process 12 14 import gleam/http/request.{type Request} 13 15 import gleam/http/response.{type Response} 14 16 import gleam/json 15 17 import gleam/list 16 18 import gleam/option.{None, Some} 17 19 import gleam/result 18 - import group_registry 19 20 import lustre 20 21 import lustre/attribute.{attribute} 21 22 import lustre/element ··· 27 28 pub fn supervised( 28 29 database database, 29 30 registry registry: pubsub.Registry, 31 + sender_factory sender_factory: sender.FactoryName, 32 + fetcher_factory fetcher_factory: fetcher.FactoryName, 30 33 smtp_environment smtp_environment, 31 34 configuration configuration: configuration.AppConfig, 32 35 ) { ··· 41 44 ["static", "oat.css"] -> serve_oat_css() 42 45 ["ws"] -> 43 46 serve_component( 44 - request, 45 - registry, 46 - database, 47 - smtp_environment, 48 - configuration, 49 - csrf_token, 47 + request:, 48 + registry:, 49 + sender_factory:, 50 + fetcher_factory:, 51 + database:, 52 + smtp_environment:, 53 + configuration:, 54 + expected_csrf_token: csrf_token, 50 55 ) 51 56 _ -> response.new(404) |> response.set_body(ewe.Empty) 52 57 } ··· 194 199 } 195 200 196 201 fn serve_component( 197 - request: Request(ewe.Connection), 198 - registry: pubsub.Registry, 199 - database: sqlight.Connection, 200 - smtp_environment: smtp.SmtpEnvironment, 201 - configuration: configuration.AppConfig, 202 - expected_csrf_token: String, 202 + request request: Request(ewe.Connection), 203 + registry registry: pubsub.Registry, 204 + sender_factory sender_factory: sender.FactoryName, 205 + fetcher_factory fetcher_factory: fetcher.FactoryName, 206 + database database: sqlight.Connection, 207 + smtp_environment smtp_environment: smtp.SmtpEnvironment, 208 + configuration configuration: configuration.AppConfig, 209 + expected_csrf_token expected_csrf_token: String, 203 210 ) -> Response(ewe.ResponseBody) { 204 211 // Extract the CSRF token from the query parameters of the initial WebSocket 205 212 // connection request. Browsers do not allow JavaScript to set custom headers ··· 227 234 with: main_ui.new_context( 228 235 csrf_token: token, 229 236 database:, 237 + registry:, 238 + sender_factory:, 239 + fetcher_factory:, 230 240 smtp_environment:, 231 241 configuration: configuration, 232 242 ),