a mini social media app for small communities

add post replying!

Changed files
+176 -25
doc
src
+8 -7
doc/database_spec.md
··· 27 28 > represents a public post 29 30 - | name | type | desc | 31 - |-------------|-----------|----------------------------------------| 32 - | `id` | int | identifier for this post | 33 - | `author_id` | int | id of the user that authored this post | 34 - | `title` | string | the title of this post | 35 - | `body` | string | the body of this post | 36 - | `posted_at` | time.Time | a timestamp of when this post was made | 37 38 ## `Like` 39
··· 27 28 > represents a public post 29 30 + | name | type | desc | 31 + |---------------|-----------|----------------------------------------------| 32 + | `id` | int | identifier for this post | 33 + | `author_id` | int | id of the user that authored this post | 34 + | `replying_to` | ?int | id of the post that this post is replying to | 35 + | `title` | string | the title of this post | 36 + | `body` | string | the body of this post | 37 + | `posted_at` | time.Time | a timestamp of when this post was made | 38 39 ## `Like` 40
+1 -2
doc/todo.md
··· 4 5 ## in-progress 6 7 - - [ ] post:replies 8 - 9 ## planing 10 11 - [ ] post:tags ('hashtags') ··· 30 - [x] post:mentioning ('tagging') other users in posts 31 - [x] post:mentioning:who mentioned you (send notifications when a user mentions you) 32 - [x] post:editing 33 - [ ] ~~site:stylesheet (and a toggle for html-only mode)~~ 34 - replaced with per-user optional stylesheets 35 - [x] site:message of the day (admins can add a welcome message displayed on index.html)
··· 4 5 ## in-progress 6 7 ## planing 8 9 - [ ] post:tags ('hashtags') ··· 28 - [x] post:mentioning ('tagging') other users in posts 29 - [x] post:mentioning:who mentioned you (send notifications when a user mentions you) 30 - [x] post:editing 31 + - [x] post:replies 32 - [ ] ~~site:stylesheet (and a toggle for html-only mode)~~ 33 - replaced with per-user optional stylesheets 34 - [x] site:message of the day (admins can add a welcome message displayed on index.html)
+18 -9
src/api.v
··· 382 ////// post ////// 383 384 @['/api/post/new_post'; post] 385 - fn (mut app App) api_post_new_post(mut ctx Context, title string, body string) veb.Result { 386 user := app.whoami(mut ctx) or { 387 ctx.error('not logged in!') 388 - return ctx.redirect('/') 389 } 390 391 if user.muted { 392 ctx.error('you are muted!') 393 - return ctx.redirect('/me') 394 } 395 396 // validate title 397 if !app.validators.post_title.validate(title) { 398 ctx.error('invalid title') 399 - return ctx.redirect('/me') 400 } 401 402 // validate body 403 if !app.validators.post_body.validate(body) { 404 ctx.error('invalid body') 405 - return ctx.redirect('/me') 406 } 407 408 - post := Post{ 409 author_id: user.id 410 title: title 411 body: body 412 } 413 414 sql app.db { 415 insert post into Post 416 } or { 417 ctx.error('failed to post!') 418 println('failed to post: ${post} from user ${user.id}') 419 - return ctx.redirect('/me') 420 } 421 422 // find the post's id to process mentions with 423 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 424 app.process_post_mentions(x) 425 } else { 426 ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 427 } 428 - 429 - return ctx.redirect('/me') 430 } 431 432 @['/api/post/delete'; post]
··· 382 ////// post ////// 383 384 @['/api/post/new_post'; post] 385 + fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 386 user := app.whoami(mut ctx) or { 387 ctx.error('not logged in!') 388 + return ctx.redirect('/login') 389 } 390 391 if user.muted { 392 ctx.error('you are muted!') 393 + return ctx.redirect('/post/new') 394 } 395 396 // validate title 397 if !app.validators.post_title.validate(title) { 398 ctx.error('invalid title') 399 + return ctx.redirect('/post/new') 400 } 401 402 // validate body 403 if !app.validators.post_body.validate(body) { 404 ctx.error('invalid body') 405 + return ctx.redirect('/post/new') 406 } 407 408 + mut post := Post{ 409 author_id: user.id 410 title: title 411 body: body 412 } 413 414 + if replying_to != 0 { 415 + // check if replying post exists 416 + app.get_post_by_id(replying_to) or { 417 + ctx.error('the post you are trying to reply to does not exist') 418 + return ctx.redirect('/post/new') 419 + } 420 + post.replying_to = replying_to 421 + } 422 + 423 sql app.db { 424 insert post into Post 425 } or { 426 ctx.error('failed to post!') 427 println('failed to post: ${post} from user ${user.id}') 428 + return ctx.redirect('/post/new') 429 } 430 431 // find the post's id to process mentions with 432 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 433 app.process_post_mentions(x) 434 + return ctx.redirect('/post/${x.id}') 435 } else { 436 ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 437 + return ctx.redirect('/me') 438 } 439 } 440 441 @['/api/post/delete'; post]
+19 -3
src/app.v
··· 154 } 155 } 156 157 pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 158 if !ctx.is_logged_in() { 159 return false ··· 308 } 309 author_name := author.get_name() 310 311 mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 312 eprintln('failed to compile regex for process_post_mentions (err: ${err})') 313 return 314 } 315 matches := re.find_all_str(post.body) 316 - mut mentioned_users := []int{} 317 for mat in matches { 318 println('found mentioned user: ${mat}') 319 username := mat#[2..-1] ··· 321 continue 322 } 323 324 - if user.id in mentioned_users || user.id == author.id { 325 continue 326 } 327 - mentioned_users << user.id 328 329 app.send_notification_to( 330 user.id,
··· 154 } 155 } 156 157 + pub fn (app &App) get_unknown_post() Post { 158 + return Post{ 159 + title: 'unknown' 160 + } 161 + } 162 + 163 pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 164 if !ctx.is_logged_in() { 165 return false ··· 314 } 315 author_name := author.get_name() 316 317 + // used so we do not send more than one notification per post 318 + mut notified_users := []int{} 319 + 320 + // notify who we replied to, if applicable 321 + if post.replying_to != none { 322 + if x := app.get_post_by_id(post.replying_to) { 323 + app.send_notification_to(x.author_id, '${author_name} replied to your post!', '${author_name} replied to *(${x.id})') 324 + } 325 + } 326 + 327 + // find mentions 328 mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 329 eprintln('failed to compile regex for process_post_mentions (err: ${err})') 330 return 331 } 332 matches := re.find_all_str(post.body) 333 for mat in matches { 334 println('found mentioned user: ${mat}') 335 username := mat#[2..-1] ··· 337 continue 338 } 339 340 + if user.id in notified_users || user.id == author.id { 341 continue 342 } 343 + notified_users << user.id 344 345 app.send_notification_to( 346 user.id,
+3 -2
src/entity/post.v
··· 4 5 pub struct Post { 6 pub mut: 7 - id int @[primary; sql: serial] 8 - author_id int 9 10 title string 11 body string
··· 4 5 pub struct Post { 6 pub mut: 7 + id int @[primary; sql: serial] 8 + author_id int 9 + replying_to ?int 10 11 title string 12 body string
+46
src/pages.v
··· 77 } 78 ctx.title = '${app.config.instance.name} - ${post.title}' 79 user := app.whoami(mut ctx) or { User{} } 80 return $veb.html() 81 } 82 ··· 97 ctx.title = '${app.config.instance.name} - editing ${post.title}' 98 return $veb.html() 99 }
··· 77 } 78 ctx.title = '${app.config.instance.name} - ${post.title}' 79 user := app.whoami(mut ctx) or { User{} } 80 + 81 + mut replying_to_post := app.get_unknown_post() 82 + mut replying_to_user := app.get_unknown_user() 83 + 84 + if post.replying_to != none { 85 + replying_to_post = app.get_post_by_id(post.replying_to) or { 86 + app.get_unknown_post() 87 + } 88 + replying_to_user = app.get_user_by_id(replying_to_post.author_id) or { 89 + app.get_unknown_user() 90 + } 91 + } 92 + 93 return $veb.html() 94 } 95 ··· 110 ctx.title = '${app.config.instance.name} - editing ${post.title}' 111 return $veb.html() 112 } 113 + 114 + @['/post/:post_id/reply'] 115 + fn (mut app App) reply(mut ctx Context, post_id int) veb.Result { 116 + user := app.whoami(mut ctx) or { 117 + ctx.error('not logged in') 118 + return ctx.redirect('/login') 119 + } 120 + post := app.get_post_by_id(post_id) or { 121 + ctx.error('no such post') 122 + return ctx.redirect('/') 123 + } 124 + ctx.title = '${app.config.instance.name} - reply to ${post.title}' 125 + replying := true 126 + replying_to := post_id 127 + replying_to_user := app.get_user_by_id(post.author_id) or { 128 + ctx.error('no such post') 129 + return ctx.redirect('/') 130 + } 131 + return $veb.html('templates/new_post.html') 132 + } 133 + 134 + @['/post/new'] 135 + fn (mut app App) new(mut ctx Context) veb.Result { 136 + user := app.whoami(mut ctx) or { 137 + ctx.error('not logged in') 138 + return ctx.redirect('/login') 139 + } 140 + ctx.title = '${app.config.instance.name} - new post' 141 + replying := false 142 + replying_to := 0 143 + replying_to_user := User{} 144 + return $veb.html('templates/new_post.html') 145 + }
+1 -1
src/templates/index.html
··· 28 </div> 29 </div> 30 31 - @include 'partial/footer.html'
··· 28 </div> 29 </div> 30 31 + @include 'partial/footer.html'
+67
src/templates/new_post.html
···
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + 5 + @if replying 6 + <h1>reply to @{replying_to_user.get_name()} with...</h1> 7 + <p>(replying to <a href="/post/${replying_to}">this</a>)</p> 8 + @else 9 + <h2>make a post...</h2> 10 + @end 11 + 12 + <div> 13 + <form action="/api/post/new_post" method="post"> 14 + @if replying 15 + <input 16 + type="number" 17 + name="replying_to" 18 + id="replying_to" 19 + required aria-required 20 + readonly aria-readonly 21 + hidden aria-hidden 22 + value="@replying_to" 23 + > 24 + <input 25 + type="text" 26 + name="title" 27 + id="title" 28 + value="reply to @{replying_to_user.get_name()}" 29 + required aria-required 30 + readonly aria-readonly 31 + hidden aria-hidden 32 + > 33 + @else 34 + <input 35 + type="text" 36 + name="title" 37 + id="title" 38 + minlength="@app.config.post.title_min_len" 39 + maxlength="@app.config.post.title_max_len" 40 + pattern="@app.config.post.title_pattern" 41 + placeholder="title" 42 + required aria-required 43 + > 44 + @end 45 + 46 + <br> 47 + <textarea 48 + name="body" 49 + id="body" 50 + minlength="@app.config.post.body_min_len" 51 + maxlength="@app.config.post.body_max_len" 52 + rows="10" 53 + cols="30" 54 + placeholder="in reply to @{replying_to_user.get_name()}..." 55 + required 56 + ></textarea> 57 + 58 + <br> 59 + 60 + <input type="submit" value="post!"> 61 + </form> 62 + </div> 63 + @else 64 + <p>uh oh, you need to be logged in to see this page</p> 65 + @end 66 + 67 + @include 'partial/footer.html'
+13 -1
src/templates/post.html
··· 4 <script src="/static/js/render_body.js"></script> 5 6 <div class="post post-full"> 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> 8 <pre id="post-@{post.id}">@post.body</pre> 9 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 10 <p><em>posted at: @post.posted_at</em></p> 11 12 @if ctx.is_logged_in() && post.author_id == user.id 13 <p><a href="/post/@{post.id}/edit">edit post</a></p>
··· 4 <script src="/static/js/render_body.js"></script> 5 6 <div class="post post-full"> 7 + <h2> 8 + <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> 9 + - 10 + @if replying_to_post.id == 0 11 + @post.title 12 + @else 13 + replied to <a href="/user/@{replying_to_user.username}">@{replying_to_user.get_name()}</a> 14 + @end 15 + </h2> 16 <pre id="post-@{post.id}">@post.body</pre> 17 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 18 <p><em>posted at: @post.posted_at</em></p> 19 + 20 + @if ctx.is_logged_in() 21 + <p><a href="/post/@{post.id}/reply">reply</a></p> 22 + @end 23 24 @if ctx.is_logged_in() && post.author_id == user.id 25 <p><a href="/post/@{post.id}/edit">edit post</a></p>