this repo has no description
0
fork

Configure Feed

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

feat: Notifications in dock

+155 -77
+24 -2
client/src/lumina_client.gleam
··· 618 618 _ -> #(model, effect.none()) 619 619 } 620 620 } 621 - Ok(OwnUserInformationResponse(username:, email:, avatar:, uuid:)) -> { 621 + Ok(OwnUserInformationResponse( 622 + username:, 623 + email:, 624 + avatar:, 625 + uuid:, 626 + unread_notifications:, 627 + )) -> { 622 628 // avatar is Option(#(String, String)) == Option((mime, base64)) 623 629 let avatar_string = case avatar { 624 630 Some(#(mime, b64)) -> "data:" <> mime <> ";base64," <> b64 ··· 641 647 Model( 642 648 ..model, 643 649 cache: model_type.Cached(..model.cache, cached_users: new_users), 644 - user: Some(model_type.User(username, email, avatar_string)), 650 + user: Some(model_type.UserSubmodel( 651 + uid: uuid, 652 + username:, 653 + email:, 654 + avatar: avatar_string, 655 + notifs: model_type.NotificationsSubModel( 656 + unread_count: unread_notifications, 657 + cached_notifications: [], 658 + ), 659 + )), 645 660 ), 646 661 effect.none(), 647 662 ) ··· 870 885 // Optional field populated with mime type and base64 of a profile picture. 871 886 avatar: option.Option(#(String, String)), 872 887 uuid: String, 888 + /// Number of unread notifications, a timeline request for "notifications" can be used to get the actual notifications and fill the cache. 889 + unread_notifications: Int, 873 890 ) 874 891 Undecodable 875 892 } ··· 998 1015 "own_user_information_response" -> { 999 1016 use username <- decode.field("username", decode.string) 1000 1017 use email <- decode.field("email", decode.string) 1018 + use unread_notifications <- decode.field( 1019 + "unread_notifications", 1020 + decode.int, 1021 + ) 1001 1022 // avatar may be null or an array [mime, base64] 1002 1023 use avatar_list_opt <- decode.field( 1003 1024 "avatar", ··· 1017 1038 email:, 1018 1039 avatar:, 1019 1040 uuid:, 1041 + unread_notifications:, 1020 1042 )) 1021 1043 } 1022 1044 g -> {
+25 -5
client/src/lumina_client/model_type.gleam
··· 31 31 /// Page currently browsing. 32 32 page: Page, 33 33 /// User, if known 34 - user: Option(User), 34 + user: Option(UserSubmodel), 35 35 /// WebSocket connection 36 36 ws: WsConnectionStatus, 37 37 /// Used to restore sessions ··· 50 50 /// If send_refresh_request(), it will update this value. If the last refresh request was over 30 seconds ago, 51 51 /// the client will send a new refresh request to the server. 52 52 last_refresh_request_time: Int, 53 + ) 54 + } 55 + 56 + pub type NotificationsSubModel { 57 + NotificationsSubModel( 58 + /// Unread notifications count, calculated by the server based on the last time the user checked notifications 59 + unread_count: Int, 60 + /// Cached notifications 61 + cached_notifications: List(Nil), 53 62 ) 54 63 } 55 64 ··· 316 325 LoginFields(emailfield: String, passwordfield: String) 317 326 } 318 327 319 - /// # User 328 + /// # User submodel 320 329 /// 321 330 /// The User type is a struct that holds the user's data. It's an Option in the Model because the user might not be logged in. 322 331 /// Authentication STATUS is not stored in the Model, but in the websocket connection (the token is). The user is only stored in the Model for the UI to easy displaying the user's data. 323 - pub type User { 324 - User(username: String, email: String, avatar: String) 332 + pub type UserSubmodel { 333 + UserSubmodel( 334 + /// User ID (uuid) 335 + uid: String, 336 + /// Username 337 + username: String, 338 + /// Email 339 + email: String, 340 + /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string 341 + avatar: String, 342 + /// Notifications 343 + notifs: NotificationsSubModel, 344 + ) 325 345 } 326 346 327 347 pub type SerializableModel { ··· 358 378 } 359 379 360 380 pub fn serialize(normal_model: Model) { 361 - let Model(page, _, _, token, _, _, _, _): Model = normal_model 381 + let Model(page:, token:, ..): Model = normal_model 362 382 SerializableModel(page:, token:) 363 383 |> serialize_serializable_model 364 384 |> json.to_string
+11 -38
client/src/lumina_client/view.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 gleam/list 21 22 import gleam/option.{None, Some} 22 23 import gleam/result 23 24 import gleam/string ··· 246 247 html.h5([attribute.class("text-[1.100rem] font-bold mb-2")], [ 247 248 html.text("Solar Linear icon set"), 248 249 ]), 249 - html.div([attribute.class("flex flex-row items-center w-full")], [ 250 - html.a( 251 - [attribute.href("https://www.svgrepo.com/svg/524520/earth")], 252 - [svgs.globe("w-6 h-6 me-2")], 253 - ), 254 - html.a( 255 - [attribute.href("https://www.svgrepo.com/svg/524793/pen-2")], 256 - [svgs.pen("w-6 h-6 me-2")], 257 - ), 258 - html.a( 259 - [attribute.href("https://www.svgrepo.com/svg/524361/camera")], 260 - [svgs.camera("w-6 h-6 me-2")], 261 - ), 262 - html.a( 263 - [ 264 - attribute.href( 265 - "https://www.svgrepo.com/svg/524800/pen-new-square", 266 - ), 267 - ], 268 - [svgs.pen_paper("w-6 h-6 me-2")], 269 - ), 270 - html.a( 271 - [ 272 - attribute.href( 273 - "https://www.svgrepo.com/svg/524621/hashtag-square", 274 - ), 275 - ], 276 - [svgs.hashtag_square("w-6 h-6 me-2")], 277 - ), 278 - html.a( 279 - [ 280 - attribute.href( 281 - "https://www.svgrepo.com/svg/524223/add-square", 282 - ), 283 - ], 284 - [svgs.add_square("w-6 h-6 me-2")], 285 - ), 286 - ]), 250 + html.div( 251 + [attribute.class("flex flex-row items-center w-full")], 252 + svgs.sources_solar_linear() 253 + |> list.map(fn(am: #(fn(String) -> Element(Msg), String)) { 254 + let #(svg_fn, link) = am 255 + html.a([attribute.href(link)], [ 256 + svg_fn("w-6 h-6 me-2 hover:scale-110"), 257 + ]) 258 + }), 259 + ), 287 260 html.text("Vectors and icons by "), 288 261 html.a( 289 262 [
+60
client/src/lumina_client/view/common_view_parts/svgs.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/list 21 + import lumina_client/message_type 20 22 import lustre/attribute.{attribute, class} 23 + import lustre/element 21 24 import lustre/element/svg 25 + 26 + const sourcelist_solar_linear = [ 27 + #(globe, "https://www.svgrepo.com/svg/524520/earth"), 28 + #(pen, "https://www.svgrepo.com/svg/524793/pen-2"), 29 + #(camera, "https://www.svgrepo.com/svg/524361/camera"), 30 + #(pen_paper, "https://www.svgrepo.com/svg/524800/pen-new-square"), 31 + #(hashtag_square, "https://www.svgrepo.com/svg/524621/hashtag-square"), 32 + #(add_square, "https://www.svgrepo.com/svg/524223/add-square"), 33 + #(archive_box, "https://www.svgrepo.com/svg/523982/archive"), 34 + ] 35 + 36 + /// Lists the SVG functions in a random order with their source URLs. 37 + pub fn sources_solar_linear() -> List( 38 + #(fn(String) -> element.Element(message_type.Msg), String), 39 + ) { 40 + sourcelist_solar_linear |> list.shuffle() 41 + } 22 42 23 43 /// Globe SVG icon used in various parts of the Lumina client. 24 44 /// ··· 320 340 ], 321 341 ) 322 342 } 343 + 344 + /// Archive box icon for notifications. 345 + /// From svgrepo. 346 + pub fn archive_box(classes: String) { 347 + svg.svg( 348 + [ 349 + attribute("xmlns", "http://www.w3.org/2000/svg"), 350 + attribute("fill", "none"), 351 + attribute("viewBox", "0 0 24 24"), 352 + class(classes), 353 + ], 354 + [ 355 + svg.path([ 356 + attribute("stroke-width", "1.5"), 357 + attribute("stroke", "currentColor"), 358 + attribute( 359 + "d", 360 + "M9 12C9 11.5341 9 11.3011 9.07612 11.1173C9.17761 10.8723 9.37229 10.6776 9.61732 10.5761C9.80109 10.5 10.0341 10.5 10.5 10.5H13.5C13.9659 10.5 14.1989 10.5 14.3827 10.5761C14.6277 10.6776 14.8224 10.8723 14.9239 11.1173C15 11.3011 15 11.5341 15 12C15 12.4659 15 12.6989 14.9239 12.8827C14.8224 13.1277 14.6277 13.3224 14.3827 13.4239C14.1989 13.5 13.9659 13.5 13.5 13.5H10.5C10.0341 13.5 9.80109 13.5 9.61732 13.4239C9.37229 13.3224 9.17761 13.1277 9.07612 12.8827C9 12.6989 9 12.4659 9 12Z", 361 + ), 362 + ]), 363 + svg.path([ 364 + attribute("stroke-linecap", "round"), 365 + attribute("stroke-width", "1.5"), 366 + attribute("stroke", "currentColor"), 367 + attribute( 368 + "d", 369 + "M20.5 7V13C20.5 16.7712 20.5 18.6569 19.3284 19.8284C18.1569 21 16.2712 21 12.5 21H11.5C7.72876 21 5.84315 21 4.67157 19.8284C3.5 18.6569 3.5 16.7712 3.5 13V7", 370 + ), 371 + ]), 372 + svg.path([ 373 + attribute("stroke-width", "1.5"), 374 + attribute("stroke", "currentColor"), 375 + attribute( 376 + "d", 377 + "M2 5C2 4.05719 2 3.58579 2.29289 3.29289C2.58579 3 3.05719 3 4 3H20C20.9428 3 21.4142 3 21.7071 3.29289C22 3.58579 22 4.05719 22 5C22 5.94281 22 6.41421 21.7071 6.70711C21.4142 7 20.9428 7 20 7H4C3.05719 7 2.58579 7 2.29289 6.70711C2 6.41421 2 5.94281 2 5Z", 378 + ), 379 + ]), 380 + ], 381 + ) 382 + }
+31 -32
client/src/lumina_client/view/homepage.gleam
··· 60 60 user:, 61 61 .., 62 62 ) = model 63 + let assert Some(user) = user 64 + as "User must be logged in to see homepage, got None from model where a user-submodel was expected." 63 65 64 66 let timeline_name = option.unwrap(timeline_name, "global") 65 67 let modal_element = case ··· 257 259 html.span([attribute.class("dock-label")], [html.text("Create")]), 258 260 ], 259 261 ), 262 + 260 263 html.button([], [ 261 - html.section([], [html.text("Notifications svg here")]), 264 + html.div([attribute.class("indicator")], [ 265 + case user.notifs.unread_count { 266 + 0 -> element.none() 267 + n -> 268 + html.span( 269 + [attribute.class("indicator-item badge badge-secondary")], 270 + [html.text(int.to_string(n))], 271 + ) 272 + }, 273 + svgs.archive_box("size-[1.2em]"), 274 + ]), 262 275 html.span([attribute.class("dock-label")], [ 263 276 html.text("Notifications"), 264 277 ]), ··· 426 439 ]), 427 440 ], 428 441 ), 429 - html.li([attribute.class("lg:hidden ")], [ 430 - html.label( 442 + html.li([], [ 443 + html.button( 431 444 [ 432 - attribute.class("drawer-button btn md:btn-neutral btn-ghost"), 433 - attribute.for("timelineswitcher"), 445 + attribute.class("btn md:btn-neutral btn-ghost"), 446 + event.on_click(SetModal("selfmenu")), 447 + ], 448 + [ 449 + html.span([attribute.class("hidden md:inline")], [ 450 + element.text("@" <> user.username), 451 + ]), 452 + html.div([attribute.class("avatar")], [ 453 + html.div([attribute.class("h-8 w-8 mask-squircle mask")], [ 454 + html.img([ 455 + attribute.src(user.avatar), 456 + attribute.alt(user.username), 457 + ]), 458 + ]), 459 + ]), 434 460 ], 435 - [element.text("Switch timeline")], 436 461 ), 437 462 ]), 438 - case user { 439 - Some(user) -> { 440 - html.li([], [ 441 - html.button( 442 - [ 443 - attribute.class("btn md:btn-neutral btn-ghost"), 444 - event.on_click(SetModal("selfmenu")), 445 - ], 446 - [ 447 - html.span([attribute.class("hidden md:inline")], [ 448 - element.text("@" <> user.username), 449 - ]), 450 - html.div([attribute.class("avatar")], [ 451 - html.div([attribute.class("h-8 w-8 mask-squircle mask")], [ 452 - html.img([ 453 - attribute.src(user.avatar), 454 - attribute.alt(user.username), 455 - ]), 456 - ]), 457 - ]), 458 - ], 459 - ), 460 - ]) 461 - } 462 - None -> element.none() 463 - }, 464 463 ]) 465 464 } 466 465
+4
server/src/client_communication.rs
··· 335 335 STANDARD.encode(include_bytes!("../../assets/svgs/dummy_user_120px.svg")), 336 336 )), 337 337 uuid: user.id.to_string(), 338 + //TODO: Fetch actual unread notification count 339 + //Based on how many notifications are younger than last time user checked notifications. (WsMessage is sent when user opens notifications) 340 + unread_notifications: 11 338 341 }; 339 342 let msg_json = msgtojson(response); 340 343 // println!("Sending own user information response: {}", msg_json); ··· 503 506 // Optional field populated with mime type and base64 of a profile picture. 504 507 avatar: Option<(String, String)>, 505 508 uuid: String, 509 + unread_notifications: u64, 506 510 }, 507 511 /// Requests a list of strings to represent a certain timeline or bubble timeline. 508 512 #[serde(rename = "timeline_request")]