a mini social media app for small communities

add notifications! welcome notifications are sent to new accounts or when a user is mentioned in a post

+5
config.maple
··· 52 52 bio_max_len = 200 53 53 bio_pattern = '(.|\s)*' 54 54 } 55 + 56 + welcome = { 57 + summary = 'welcome!' 58 + body = 'hello %s and welcome to beep! i hope you enjoy your stay here :D' 59 + }
+3 -2
doc/todo.md
··· 4 4 5 5 ## in-progress 6 6 7 - - [ ] post:mentioning ('tagging') other users in posts 8 - 9 7 ## planing 10 8 11 9 - [ ] post:replies ··· 24 22 - [x] user:nicknames 25 23 - [x] user:bio/about me 26 24 - [x] user:listed pronouns 25 + - [x] user:notifications 27 26 - [x] post:likes/dislikes 27 + - [x] post:mentioning ('tagging') other users in posts 28 + - [x] post:mentioning:who mentioned you (send notifications when a user mentions you) 28 29 - [ ] ~~site:stylesheet (and a toggle for html-only mode)~~ 29 30 - replaced with per-user optional stylesheets 30 31 - [x] site:message of the day (admins can add a welcome message displayed on index.html)
+55 -5
src/api.v
··· 2 2 3 3 import veb 4 4 import auth 5 - import entity { Like, LikeCache, Post, Site, User } 5 + import entity { Like, LikeCache, Post, Site, User, Notification } 6 6 7 - ////// Users ////// 7 + ////// user ////// 8 8 9 9 @['/api/user/register'; post] 10 10 fn (mut app App) api_user_register(mut ctx Context, username string, password string) veb.Result { ··· 46 46 println('reg: ${username}') 47 47 48 48 if x := app.get_user_by_name(username) { 49 + app.send_notification_to( 50 + x.id, 51 + app.config.welcome.summary.replace('%s', x.get_name()), 52 + app.config.welcome.body.replace('%s', x.get_name()) 53 + ) 49 54 token := app.auth.add_token(x.id, ctx.ip()) or { 50 55 eprintln(err) 51 - ctx.error('could not create token for user with id ${user.id}') 56 + ctx.error('could not create token for user with id ${x.id}') 52 57 return ctx.redirect('/') 53 58 } 54 59 ctx.set_cookie( ··· 281 286 return ctx.text(user.get_name()) 282 287 } 283 288 284 - ////// Posts ////// 289 + /// user/notification /// 290 + 291 + @['/api/user/notification/clear'] 292 + fn (mut app App) api_user_notification_clear(mut ctx Context, id int) veb.Result { 293 + if !ctx.is_logged_in() { 294 + ctx.error('you are not logged in!') 295 + return ctx.redirect('/login') 296 + } 297 + sql app.db { 298 + delete from Notification where id == id 299 + } or { 300 + ctx.error('failed to delete notification') 301 + return ctx.redirect('/inbox') 302 + } 303 + return ctx.redirect('/inbox') 304 + } 305 + 306 + @['/api/user/notification/clear_all'] 307 + fn (mut app App) api_user_notification_clear_all(mut ctx Context) veb.Result { 308 + user := app.whoami(mut ctx) or { 309 + ctx.error('you are not logged in!') 310 + return ctx.redirect('/login') 311 + } 312 + sql app.db { 313 + delete from Notification where user_id == user.id 314 + } or { 315 + ctx.error('failed to delete notifications') 316 + return ctx.redirect('/inbox') 317 + } 318 + return ctx.redirect('/inbox') 319 + } 320 + 321 + ////// post ////// 285 322 286 323 @['/api/post/new_post'; post] 287 324 fn (mut app App) api_post_new_post(mut ctx Context, title string, body string) veb.Result { ··· 319 356 ctx.error('failed to post!') 320 357 println('failed to post: ${post} from user ${user.id}') 321 358 return ctx.redirect('/me') 359 + } 360 + 361 + // find the post's id to process mentions with 362 + if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 363 + app.process_post_mentions(x) 364 + } else { 365 + ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 322 366 } 323 367 324 368 return ctx.redirect('/me') ··· 440 484 } 441 485 } 442 486 443 - ////// Site ////// 487 + @['/api/post/get_title'] 488 + fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 489 + post := app.get_post_by_id(id) or { return ctx.server_error('no such post') } 490 + return ctx.text(post.title) 491 + } 492 + 493 + ////// site ////// 444 494 445 495 @['/api/site/set_motd'; post] 446 496 fn (mut app App) api_site_set_motd(mut ctx Context, motd string) veb.Result {
+86 -1
src/app.v
··· 2 2 3 3 import veb 4 4 import db.pg 5 + import regex 6 + import time 5 7 import auth 6 - import entity { LikeCache, Like, Post, Site, User } 8 + import entity { LikeCache, Like, Post, Site, User, Notification } 7 9 8 10 pub struct App { 9 11 veb.StaticHandler ··· 85 87 select from Post where id == id limit 1 86 88 } or { [] } 87 89 if posts.len != 1 { 90 + return none 91 + } 92 + return posts[0] 93 + } 94 + 95 + pub fn (app &App) get_post_by_author_and_timestamp(author_id int, timestamp time.Time) ?Post { 96 + posts := sql app.db { 97 + select from Post where author_id == author_id && posted_at == timestamp order by posted_at desc limit 1 98 + } or { [] } 99 + if posts.len == 0 { 88 100 return none 89 101 } 90 102 return posts[0] ··· 238 250 return configs[0] 239 251 } 240 252 253 + @[inline] 241 254 pub fn (app &App) get_motd() string { 242 255 site := app.get_or_create_site_config() 243 256 return site.motd 244 257 } 258 + 259 + pub fn (app &App) get_notifications_for(user_id int) []Notification { 260 + notifications := sql app.db { 261 + select from Notification where user_id == user_id 262 + } or { [] } 263 + return notifications 264 + } 265 + 266 + pub fn (app &App) get_notification_count(user_id int, limit int) int { 267 + notifications := sql app.db { 268 + select from Notification where user_id == user_id limit limit 269 + } or { [] } 270 + return notifications.len 271 + } 272 + 273 + pub fn (app &App) get_notification_count_for_frontend(user_id int, limit int) string { 274 + count := app.get_notification_count(user_id, limit) 275 + if count == 0 { 276 + return '' 277 + } else if count > limit { 278 + return ' (${count}+)' 279 + } else { 280 + return ' (${count})' 281 + } 282 + } 283 + 284 + pub fn (app &App) send_notification_to(user_id int, summary string, body string) { 285 + notification := Notification{ 286 + user_id: user_id 287 + summary: summary 288 + body: body 289 + } 290 + sql app.db { 291 + insert notification into Notification 292 + } or { 293 + eprintln('failed to send notification ${notification}') 294 + } 295 + } 296 + 297 + // sends notifications to each user mentioned in a post 298 + pub fn (app &App) process_post_mentions(post &Post) { 299 + author := app.get_user_by_id(post.author_id) or { 300 + eprintln('process_post_mentioned called on a post with a non-existent author: ${post}') 301 + return 302 + } 303 + author_name := author.get_name() 304 + 305 + mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 306 + eprintln('failed to compile regex for process_post_mentions (err: ${err})') 307 + return 308 + } 309 + matches := re.find_all_str(post.body) 310 + mut mentioned_users := []int{} 311 + for mat in matches { 312 + println('found mentioned user: ${mat}') 313 + username := mat#[2..-1] 314 + user := app.get_user_by_name(username) or { 315 + continue 316 + } 317 + 318 + if user.id in mentioned_users || user.id == author.id { 319 + continue 320 + } 321 + mentioned_users << user.id 322 + 323 + app.send_notification_to( 324 + user.id, 325 + '${author_name} mentioned you!', 326 + 'you have been mentioned in this post: *(${post.id})' 327 + ) 328 + } 329 + }
+9
src/config.v
··· 52 52 bio_max_len int 53 53 bio_pattern string 54 54 } 55 + welcome struct { 56 + pub mut: 57 + summary string 58 + body string 59 + } 55 60 } 56 61 57 62 pub fn load_config_from(file_path string) Config { ··· 101 106 config.user.bio_min_len = loaded_user.get('bio_min_len').to_int() 102 107 config.user.bio_max_len = loaded_user.get('bio_max_len').to_int() 103 108 config.user.bio_pattern = loaded_user.get('bio_pattern').to_str() 109 + 110 + loaded_welcome := loaded.get('welcome') 111 + config.welcome.summary = loaded_welcome.get('summary').to_str() 112 + config.welcome.body = loaded_welcome.get('body').to_str() 104 113 105 114 return config 106 115 }
+9
src/entity/notification.v
··· 1 + module entity 2 + 3 + pub struct Notification { 4 + pub mut: 5 + id int @[primary; sql: serial] 6 + user_id int 7 + summary string 8 + body string 9 + }
+6
src/main.v
··· 13 13 create table entity.Post 14 14 create table entity.Like 15 15 create table entity.LikeCache 16 + create table entity.Notification 16 17 }! 17 18 } 18 19 19 20 fn main() { 20 21 config := load_config_from(os.args[1]) 21 22 23 + println('-> connecting to db...') 22 24 mut db := pg.connect(pg.Config{ 23 25 host: config.postgres.host 24 26 dbname: config.postgres.db ··· 26 28 password: config.postgres.password 27 29 port: config.postgres.port 28 30 })! 31 + println('<- connected') 29 32 30 33 defer { 31 34 db.close() ··· 48 51 // vfmt on 49 52 50 53 app.mount_static_folder_at(app.config.static_path, '/static')! 54 + 55 + println('-> initializing database...') 51 56 init_db(db)! 57 + println('<- done') 52 58 53 59 // make the website config, if it does not exist 54 60 app.get_or_create_site_config()
+10
src/pages.v
··· 39 39 return $veb.html() 40 40 } 41 41 42 + fn (mut app App) inbox(mut ctx Context) veb.Result { 43 + user := app.whoami(mut ctx) or { 44 + ctx.error('not logged in') 45 + return ctx.redirect('/login') 46 + } 47 + ctx.title = '${app.config.instance.name} inbox' 48 + notifications := app.get_notifications_for(user.id) 49 + return $veb.html() 50 + } 51 + 42 52 @['/user/:username'] 43 53 fn (mut app App) user(mut ctx Context, username string) veb.Result { 44 54 user := app.whoami(mut ctx) or { User{} }
-11
src/static/js/post.js
··· 11 11 }) 12 12 window.location.reload() 13 13 } 14 - 15 - const render_post_body = async (id, mention_pattern) => { 16 - const element = document.getElementById(`post-${id}`) 17 - var body = element.innerText 18 - const matches = body.matchAll(new RegExp(mention_pattern, 'g')) 19 - for (const match of matches) { 20 - (await fetch('/api/user/get_name?username=' + match[0].substring(2, match[0].length - 1))).text().then(s => { 21 - element.innerHTML = element.innerHTML.replace(match[0], '<a href="/user/' + match[0].substring(2, match[0].length - 1) + '">' + s + '</a>') 22 - }) 23 - } 24 - }
+47
src/static/js/render_body.js
··· 1 + // TODO: move this to the backend? 2 + const render_body = async id => { 3 + const element = document.getElementById(id) 4 + var body = element.innerText 5 + 6 + const matches = body.matchAll(/[@#*]\([a-zA-Z0-9_.-]*\)/g) 7 + const cache = {} 8 + for (const match of matches) { 9 + // mention 10 + if (match[0][0] == '@') { 11 + if (cache.hasOwnProperty(match[0])) { 12 + element.innerHTML = element.innerHTML.replace(match[0], cache[match[0]]) 13 + continue 14 + } 15 + (await fetch('/api/user/get_name?username=' + match[0].substring(2, match[0].length - 1))).text().then(s => { 16 + if (s == 'no such user') { 17 + return 18 + } 19 + const link = document.createElement('a') 20 + link.href = `/user/${match[0].substring(2, match[0].length - 1)}` 21 + link.innerText = s 22 + cache[match[0]] = link.outerHTML 23 + element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 24 + }) 25 + } 26 + // hashtag 27 + else if (match[0][0] == '#') { 28 + } 29 + // post reference 30 + else if (match[0][0] == '*') { 31 + if (cache.hasOwnProperty(match[0])) { 32 + element.innerHTML = element.innerHTML.replace(match[0], cache[match[0]]) 33 + continue 34 + } 35 + (await fetch('/api/post/get_title?id=' + match[0].substring(2, match[0].length - 1))).text().then(s => { 36 + if (s == 'no such post') { 37 + return 38 + } 39 + const link = document.createElement('a') 40 + link.href = `/post/${match[0].substring(2, match[0].length - 1)}` 41 + link.innerText = s 42 + cache[match[0]] = link.outerHTML 43 + element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 44 + }) 45 + } 46 + } 47 + }
+6 -3
src/static/style.css
··· 1 - .post { 1 + .post, 2 + .notification { 2 3 border: 2px solid; 3 4 padding: 8px; 4 5 } 5 6 6 - .post p { 7 + .post p, 8 + .notification p { 7 9 margin: 0; 8 10 } 9 11 10 - .post + .post { 12 + .post + .post, 13 + .notification + .notification { 11 14 margin-top: 6px; 12 15 } 13 16
+30
src/templates/inbox.html
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + <script src="/static/js/render_body.js"></script> 5 + 6 + <h1>inbox</h1> 7 + 8 + <div> 9 + @if notifications.len == 0 10 + <p>your inbox is empty!</p> 11 + @else 12 + <a href="/api/user/notification/clear_all">clear all</a> 13 + <hr> 14 + @for notification in notifications.reverse() 15 + <div class="notification"> 16 + <p><strong>@notification.summary</strong></p> 17 + <pre id="notif-@{notification.id}">@notification.body</pre> 18 + <a href="/api/user/notification/clear?id=@{notification.id}">clear</a> 19 + <script> 20 + render_body('notif-@{notification.id}') 21 + </script> 22 + </div> 23 + @end 24 + @end 25 + </div> 26 + @else 27 + <p>uh oh, you need to be logged in to view this page</p> 28 + @end 29 + 30 + @include 'partial/footer.html'
+2
src/templates/partial/header.html
··· 34 34 @if ctx.is_logged_in() 35 35 <a href="/me">profile</a> 36 36 - 37 + <a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a> 38 + - 37 39 <a href="/api/user/logout">log out</a> 38 40 @else 39 41 <a href="/login">log in</a>
+2 -1
src/templates/post.html
··· 1 1 @include 'partial/header.html' 2 2 3 3 <script src="/static/js/post.js"></script> 4 + <script src="/static/js/render_body.js"></script> 4 5 5 6 <div class="post post-full"> 6 7 <h2><a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a> - @post.title</h2> ··· 53 54 </div> 54 55 55 56 <script type="module"> 56 - await render_post_body(@{post.id}, '@@\\(@{app.config.user.username_pattern}\\)') 57 + await render_body('post-@{post.id}') 57 58 </script> 58 59 59 60 @include 'partial/footer.html'