For now? I'm experimenting on an old concept.
1
fork

Configure Feed

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

[unfinished] feat: Implement modem for SPA routing.

+147 -121
+1
Justfile
··· 1 + [private] 1 2 default: 2 3 @just --list 3 4
+17 -17
client/src/lumina_client.gleam
··· 32 32 import gleamy_lights/premixed 33 33 import lumina_client/dom 34 34 import lumina_client/helpers.{login_view_checker, model_local_storage_key} 35 - import lumina_client/message_type.{ 36 - type Msg, FocusLostEmailField, Logout, Past150ms, SubmitLogin, SubmitSignup, 37 - ToLandingPage, ToLoginPage, ToRegisterPage, UpdateEmailField, 38 - UpdateLastRefreshRequestTime, UpdatePasswordConfirmField, UpdatePasswordField, 39 - UpdateUsernameField, WSTryReconnect, WsDisconnectDefinitive, WsWrapper, 40 - } 41 35 import lumina_client/model_type.{ 42 - type Model, HomeTimeline, Landing, Login, LoginFields, Model, Register, 43 - RegisterPageFields, 36 + type Model, type Msg, FocusLostEmailField, HomeTimeline, Landing, Licence, 37 + Login, LoginFields, Logout, Model, NotFound, Past150ms, Register, 38 + RegisterPageFields, SubmitLogin, SubmitSignup, ToLandingPage, ToLoginPage, 39 + ToRegisterPage, UpdateEmailField, UpdateLastRefreshRequestTime, 40 + UpdatePasswordConfirmField, UpdatePasswordField, UpdateUsernameField, 41 + WSTryReconnect, WsDisconnectDefinitive, WsWrapper, 44 42 } 43 + 45 44 import lumina_client/view.{view} 46 45 import lumina_client/view/homepage 47 46 import lustre ··· 179 178 180 179 pub fn start_tracking_mouse_movements(x: Float, y: Float) { 181 180 use dispatcher <- effect.from 182 - dom.start_dragging_modal_box(x, y, message_type.MoveModalBoxTo, dispatcher) 181 + dom.start_dragging_modal_box(x, y, model_type.MoveModalBoxTo, dispatcher) 183 182 } 184 183 185 184 pub fn count_to_150() { ··· 496 495 } 497 496 } 498 497 } 499 - message_type.TimeLineTo(tid) -> { 498 + model_type.TimeLineTo(tid) -> { 500 499 let assert model_type.WsConnectionConnected(socket) = model.ws 501 500 as "Socket not connected" 502 501 let model = case model.page { ··· 531 530 } 532 531 #(model, requ) 533 532 } 534 - message_type.LoadMorePosts(timeline_name) -> { 533 + model_type.LoadMorePosts(timeline_name) -> { 535 534 let effect = request_next_timeline_page(model, timeline_name) 536 535 #(model, effect) 537 536 } 538 - message_type.SetModal(to) -> { 537 + model_type.SetModal(to) -> { 539 538 case model.page { 540 539 HomeTimeline(timeline_name:, modal: _) -> #( 541 540 Model( ··· 547 546 _ -> #(model, effect.none()) 548 547 } 549 548 } 550 - message_type.CloseModal -> { 549 + model_type.CloseModal -> { 551 550 case model.page { 552 551 HomeTimeline(timeline_name:, modal: _) -> #( 553 552 Model(..model, page: HomeTimeline(timeline_name:, modal: None)), ··· 556 555 _ -> #(model, effect.none()) 557 556 } 558 557 } 559 - message_type.StartDraggingModalBox(x, y) -> { 558 + model_type.StartDraggingModalBox(x, y) -> { 560 559 // Start a sideffect that tracks mouse movements and sends MoveModalBoxTo messages 561 560 #(model, start_tracking_mouse_movements(x, y)) 562 561 } 563 - message_type.MoveModalBoxTo(x, y) -> { 562 + model_type.MoveModalBoxTo(x, y) -> { 564 563 case model.page { 565 564 HomeTimeline(timeline_name:, modal: Some(#("mdl-postedit", params))) -> { 566 565 let new_params = ··· 686 685 } 687 686 Ok(AuthenticationFailure) -> { 688 687 case model.page { 689 - model_type.Landing | HomeTimeline(..) -> session_destroy() 688 + model_type.Landing | HomeTimeline(..) | NotFound(..) | Licence -> 689 + session_destroy() 690 690 Login(fields:, success: _) -> #( 691 691 Model(..model, page: Login(fields:, success: Some(False))), 692 692 effect.none(), ··· 947 947 |> timestamp.to_unix_seconds 948 948 |> float.truncate 949 949 use dispatcher <- effect.from 950 - dispatcher(message_type.UpdateLastRefreshRequestTime(current_time)) 950 + dispatcher(model_type.UpdateLastRefreshRequestTime(current_time)) 951 951 case model.last_refresh_request_time - current_time < 30 { 952 952 True -> { 953 953 Nil
+4 -4
client/src/lumina_client/dom.gleam
··· 18 18 // along with this program. If not, see <https://www.gnu.org/licenses/>. 19 19 20 20 import gleam/dynamic/decode 21 - import lumina_client/message_type 21 + import lumina_client/model_type 22 22 23 23 /// Get the color scheme of the user's system (media query) 24 24 @external(javascript, "./dom_ffi.mjs", "get_color_scheme") ··· 34 34 pub fn start_dragging_modal_box( 35 35 curr_x: Float, 36 36 curr_y: Float, 37 - constructor: fn(Float, Float) -> message_type.Msg, 38 - dispatch: fn(message_type.Msg) -> Nil, 37 + constructor: fn(Float, Float) -> model_type.Msg, 38 + dispatch: fn(model_type.Msg) -> Nil, 39 39 ) -> Nil 40 40 41 41 /// Get the window dimensions in pixels 42 42 /// Returns: #(width_px, height_px) 43 - /// 43 + /// 44 44 /// // This should be used in an effect and saved to the model, not called directly in views, but is for now called as an helper in views. 45 45 @external(javascript, "./dom_ffi.mjs", "get_window_dimensions_px") 46 46 pub fn get_window_dimensions_px() -> #(Int, Int)
+1 -4
client/src/lumina_client/helpers.gleam
··· 17 17 // You should have received a copy of the GNU Affero General Public License 18 18 // along with this program. If not, see <https://www.gnu.org/licenses/>. 19 19 20 - import gleam/float 21 20 import gleam/int 22 21 import gleam/list 23 - import gleam/result 24 22 import lumina_client/dom 25 - import lumina_client/message_type.{type Msg} 26 - import lumina_client/model_type.{type LoginFields} 23 + import lumina_client/model_type.{type LoginFields, type Msg} 27 24 import lustre/attribute 28 25 import plinth/javascript/global 29 26
-54
client/src/lumina_client/message_type.gleam
··· 1 - // Lumina/Peonies 2 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. 3 - // 4 - // This program is free software: you can redistribute it and/or modify 5 - // it under the terms of the GNU Affero General Public License as published 6 - // by the Free Software Foundation, either version 3 of the License, or 7 - // (at your option) any later version. 8 - // 9 - // This program is distributed in the hope that it will be useful, 10 - // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 - // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 - // GNU Affero General Public License for more details. 13 - // 14 - // You should have received a copy of the GNU Affero General Public License 15 - // along with this program. If not, see <https://www.gnu.org/licenses/>. 16 - 17 - import lustre_websocket 18 - 19 - pub type Msg { 20 - WSTryReconnect 21 - Past150ms 22 - UpdateLastRefreshRequestTime(Int) 23 - WsDisconnectDefinitive 24 - WsWrapper(lustre_websocket.WebSocketEvent) 25 - ToLoginPage 26 - SubmitLogin(List(#(String, String))) 27 - ToRegisterPage 28 - SubmitSignup(List(#(String, String))) 29 - ToLandingPage 30 - // Can be re-used for both login and register pages 31 - UpdateEmailField(String) 32 - UpdatePasswordField(String) 33 - // Register page 34 - UpdateUsernameField(String) 35 - UpdatePasswordConfirmField(String) 36 - FocusLostEmailField 37 - /// Travel to a different timeline. 38 - TimeLineTo(String) 39 - /// Load more posts for the current timeline 40 - LoadMorePosts(String) 41 - /// Log the user out (destroys session and recreates model) 42 - Logout 43 - /// Close current modal 44 - CloseModal 45 - /// Browse modal to different page 46 - SetModal(String) 47 - /// Start dragging the modal box 48 - /// Parameters: the event, current mouse x and y positions 49 - /// Starts a sideffect that tracks mouse movements and sends MoveModalBoxTo messages 50 - StartDraggingModalBox(Float, Float) 51 - /// Move the modal box to a new position 52 - /// Parameters: new x and y positions 53 - MoveModalBoxTo(Float, Float) 54 - }
+80 -15
client/src/lumina_client/model_type.gleam
··· 22 22 import gleam/json 23 23 import gleam/list 24 24 import gleam/option.{type Option, None, Some} 25 + import gleam/uri.{type Uri} 25 26 import lustre_websocket 26 27 28 + pub type Msg { 29 + WSTryReconnect 30 + Past150ms 31 + UpdateLastRefreshRequestTime(Int) 32 + WsDisconnectDefinitive 33 + WsWrapper(lustre_websocket.WebSocketEvent) 34 + ToLoginPage 35 + SubmitLogin(List(#(String, String))) 36 + ToRegisterPage 37 + SubmitSignup(List(#(String, String))) 38 + ToLandingPage 39 + // Can be re-used for both login and register pages 40 + UpdateEmailField(String) 41 + UpdatePasswordField(String) 42 + // Register page 43 + UpdateUsernameField(String) 44 + UpdatePasswordConfirmField(String) 45 + FocusLostEmailField 46 + /// Travel to a different timeline. 47 + TimeLineTo(String) 48 + /// Load more posts for the current timeline 49 + LoadMorePosts(String) 50 + /// Log the user out (destroys session and recreates model) 51 + Logout 52 + /// Close current modal 53 + CloseModal 54 + /// Browse modal to different page 55 + SetModal(String) 56 + /// Start dragging the modal box 57 + /// Parameters: the event, current mouse x and y positions 58 + /// Starts a sideffect that tracks mouse movements and sends MoveModalBoxTo messages 59 + StartDraggingModalBox(Float, Float) 60 + /// Move the modal box to a new position 61 + /// Parameters: new x and y positions 62 + MoveModalBoxTo(Float, Float) 63 + } 64 + 65 + pub type Route = 66 + Page 67 + 68 + pub fn parse_route(uri: Uri) -> Route { 69 + case uri.path_segments(uri.path) { 70 + [] | [""] -> Landing 71 + ["login"] -> Login(fields: LoginFields("", ""), success: None) 72 + ["signup"] -> 73 + Register(fields: RegisterPageFields("", "", "", ""), ready: None) 74 + ["publication", _post_id] -> { 75 + todo as "We don't have a publication zoom Page variant yet." 76 + } 77 + ["home"] | ["timeline"] -> HomeTimeline(None, None) 78 + ["timeline", tid] -> HomeTimeline(Some(tid), None) 79 + ["licence"] | ["license"] -> Licence 80 + 81 + _ -> NotFound(uri:) 82 + } 83 + } 84 + 85 + /// # Page 86 + /// 87 + /// Lumina has always been an SPA behind the login page, splitting the three "main" pages: Login, Signup, and Home from "subpages". Home contained subpages like Dashboard, Profile, and Settings, etc. 88 + /// In this model, Login and Dashboard would be equal. The model keeps track of the current page and the user's authentication status. 89 + /// The Page type is, pretty explanatory, an enum of all the pages in the app. Nested if needed, to track fields like the current tab in the Dashboard or the username form field in the login page. 90 + pub type Page { 91 + Landing 92 + Register(fields: RegisterPageFields, ready: Option(Result(Nil, String))) 93 + Login(fields: LoginFields, success: Option(Bool)) 94 + HomeTimeline( 95 + timeline_name: Option(String), 96 + modal: Option(#(String, Dict(String, String))), 97 + ) 98 + Licence 99 + NotFound(uri: Uri) 100 + } 101 + 27 102 /// # Model 28 103 /// 29 104 pub type Model { 30 105 Model( 31 106 /// Page currently browsing. 107 + /// This is synced to the url through modem, but can contain more context. 32 108 page: Page, 33 109 /// User, if known 34 110 user: Option(UserSubmodel), ··· 197 273 ) 198 274 } 199 275 200 - /// # Page 201 - /// 202 - /// Lumina has always been an SPA behind the login page, splitting the three "main" pages: Login, Signup, and Home from "subpages". Home contained subpages like Dashboard, Profile, and Settings, etc. 203 - /// In this model, Login and Dashboard would be equal. The model keeps track of the current page and the user's authentication status. 204 - /// The Page type is, pretty explanatory, an enum of all the pages in the app. Nested if needed, to track fields like the current tab in the Dashboard or the username form field in the login page. 205 - pub type Page { 206 - Landing 207 - Register(fields: RegisterPageFields, ready: Option(Result(Nil, String))) 208 - Login(fields: LoginFields, success: Option(Bool)) 209 - HomeTimeline( 210 - timeline_name: Option(String), 211 - modal: Option(#(String, Dict(String, String))), 212 - ) 213 - } 214 - 215 276 fn encode_page(page: Page) -> json.Json { 216 277 case page { 217 278 Landing -> json.object([#("type", json.string("landing"))]) ··· 260 321 Some(i) -> [#("modal", json.string(i.0))] 261 322 }), 262 323 ) 324 + NotFound(_) -> json.object([#("type", json.string("landing"))]) 325 + 326 + Licence -> json.object([#("type", json.string("licence"))]) 263 327 } 264 328 } 265 329 ··· 267 331 use variant <- decode.field("type", decode.string) 268 332 case variant { 269 333 "landing" -> decode.success(Landing) 334 + "licence" -> decode.success(Licence) 270 335 "register" -> { 271 336 use fields <- decode.field("fields", { 272 337 use usernamefield <- decode.field("usernamefield", decode.string)
+14 -12
client/src/lumina_client/view.gleam
··· 25 25 import lumina_client/helpers.{ 26 26 get_color_scheme, login_view_checker, model_local_storage_key, 27 27 } 28 - import lumina_client/message_type.{ 29 - type Msg, SubmitLogin, SubmitSignup, ToLandingPage, ToLoginPage, 28 + import lumina_client/model_type.{ 29 + type Model, type Msg, HomeTimeline, Landing, Licence, Login, NotFound, 30 + Register, SubmitLogin, SubmitSignup, ToLandingPage, ToLoginPage, 30 31 ToRegisterPage, UpdateEmailField, UpdatePasswordConfirmField, 31 32 UpdatePasswordField, UpdateUsernameField, WSTryReconnect, 32 - } 33 - import lumina_client/model_type.{ 34 - type Model, HomeTimeline, Landing, Login, Register, 35 33 } 36 34 import lumina_client/view/common_view_parts.{common_view_parts} 37 35 import lumina_client/view/common_view_parts/svgs ··· 51 49 model_local_storage_key, 52 50 model_type.serialize(model), 53 51 ) 52 + let content = case model.page { 53 + Landing -> view_landing() 54 + Register(..) -> view_register(model) 55 + Login(..) -> view_login(model) 56 + HomeTimeline(..) -> view_homepage(model) 57 + NotFound(uri:) -> todo as "No 404 page yet." 58 + Licence -> 59 + todo as "Licence should be shown by the client if it's not shown by the server." 60 + } 54 61 html.div( 55 62 [get_color_scheme(model), attribute.class("w-screen h-screen content")], 56 63 [ ··· 115 122 model_type.WsConnectionConnected(..) | model_type.WsConnectionUnsure -> 116 123 element.none() 117 124 }, 118 - case model.page { 119 - Landing -> view_landing() 120 - Register(..) -> view_register(model) 121 - Login(..) -> view_login(model) 122 - HomeTimeline(..) -> view_homepage(model) 123 - }, 125 + content, 124 126 ], 125 127 ) 126 128 } ··· 518 520 attribute.value(fieldvalues.emailfield), 519 521 event.on_input(UpdateEmailField), 520 522 event.on("focusout", { 521 - decode.success(message_type.FocusLostEmailField) 523 + decode.success(model_type.FocusLostEmailField) 522 524 }), 523 525 ]), 524 526 html.label([attribute.class("fieldset-label")], [
+16 -1
client/src/lumina_client/view/common_view_parts.gleam
··· 17 17 // You should have received a copy of the GNU Affero General Public License 18 18 // along with this program. If not, see <https://www.gnu.org/licenses/>. 19 19 20 - import lumina_client/message_type.{type Msg} 20 + import gleam/option.{Some} 21 + import lumina_client/model_type.{type Msg, type Page} 21 22 import lustre/attribute 22 23 import lustre/element.{type Element} 23 24 import lustre/element/html ··· 58 59 ), 59 60 ]) 60 61 } 62 + 63 + pub fn href(route: Page) -> attribute.Attribute(Msg) { 64 + case route { 65 + model_type.Landing -> "/" 66 + model_type.Register(_, _) -> "/signup/" 67 + model_type.Login(_, _) -> "/login/" 68 + model_type.HomeTimeline(timeline_name: Some(m), modal:) -> 69 + "/timeline/" <> m <> "/" 70 + model_type.HomeTimeline(timeline_name: option.None, modal:) -> "/home/" 71 + model_type.Licence -> "/licence" 72 + model_type.NotFound(_) -> "/404" 73 + } 74 + |> attribute.href() 75 + }
+2 -2
client/src/lumina_client/view/common_view_parts/svgs.gleam
··· 18 18 // along with this program. If not, see <https://www.gnu.org/licenses/>. 19 19 20 20 import gleam/list 21 - import lumina_client/message_type 21 + import lumina_client/model_type 22 22 import lustre/attribute.{attribute, class} 23 23 import lustre/element 24 24 import lustre/element/svg ··· 35 35 36 36 /// Lists the SVG functions in a random order with their source URLs. 37 37 pub fn sources_solar_linear() -> List( 38 - #(fn(String) -> element.Element(message_type.Msg), String), 38 + #(fn(String) -> element.Element(model_type.Msg), String), 39 39 ) { 40 40 sourcelist_solar_linear |> list.shuffle() 41 41 }
+7 -7
client/src/lumina_client/view/homepage.gleam
··· 31 31 import gleam/time/timestamp 32 32 import lumina_client/dom 33 33 import lumina_client/helpers 34 - import lumina_client/message_type.{ 35 - type Msg, CloseModal, Logout, SetModal, StartDraggingModalBox, 34 + import lumina_client/model_type.{ 35 + type CachedTimeline, type Model, type Msg, CachedTimeline, CloseModal, Logout, 36 + SetModal, StartDraggingModalBox, 36 37 } 37 - import lumina_client/model_type.{type CachedTimeline, type Model, CachedTimeline} 38 38 import lumina_client/view/common_view_parts.{common_view_parts} 39 39 import lumina_client/view/common_view_parts/svgs 40 40 import lumina_client/view/homepage/post_editor ··· 380 380 return: fn() { attribute.class("menu-active") }, 381 381 otherwise: fn() { attribute.none() }, 382 382 ), 383 - event.on_click(message_type.TimeLineTo("global")), 383 + event.on_click(model_type.TimeLineTo("global")), 384 384 ], 385 385 [ 386 386 svgs.globe("inline h-5 w-5 mr-2"), ··· 396 396 return: fn() { attribute.class("menu-active") }, 397 397 otherwise: fn() { attribute.none() }, 398 398 ), 399 - event.on_click(message_type.TimeLineTo("following")), 399 + event.on_click(model_type.TimeLineTo("following")), 400 400 ], 401 401 [ 402 402 svgs.follows("inline h-5 w-5 mr-2"), ··· 412 412 return: fn() { attribute.class("menu-active") }, 413 413 otherwise: fn() { attribute.none() }, 414 414 ), 415 - event.on_click(message_type.TimeLineTo("mutuals")), 415 + event.on_click(model_type.TimeLineTo("mutuals")), 416 416 ], 417 417 [ 418 418 // SVG: Heart and star overlapping for 'Mutuals' ··· 498 498 html.button( 499 499 [ 500 500 attribute.class("btn btn-primary font-menuitems"), 501 - event.on_click(message_type.LoadMorePosts(timeline_name)), 501 + event.on_click(model_type.LoadMorePosts(timeline_name)), 502 502 ], 503 503 [element.text("Load More Posts")], 504 504 ),
+1 -2
client/src/lumina_client/view/homepage/post_editor.gleam
··· 18 18 // along with this program. If not, see <https://www.gnu.org/licenses/>. 19 19 20 20 import gleam/dict 21 - import lumina_client/message_type.{type Msg} 22 - import lumina_client/model_type 21 + import lumina_client/model_type.{type Msg} 23 22 import lumina_client/view/common_view_parts/svgs 24 23 import lustre/attribute 25 24 import lustre/element.{type Element}
+3 -2
client/src/lumina_client/view/homepage/posts.gleam
··· 19 19 20 20 import gleam/dict 21 21 import gleam/list 22 - import lumina_client/message_type.{type Msg} 23 - import lumina_client/model_type.{type CachedTimeline, type Model, CachedTimeline} 22 + import lumina_client/model_type.{ 23 + type CachedTimeline, type Model, type Msg, CachedTimeline, 24 + } 24 25 import lustre/attribute.{attribute} 25 26 import lustre/element.{type Element} 26 27 import lustre/element/html
+1 -1
notes/Design choices/Rationale/Backend > Timeline carries most.md
··· 22 22 aware. 23 23 24 24 Which is why we also would need to log every timeline request to be able to identify for example over-requested 25 - timelines. 25 + timelines.