a mini social media app for small communities

add post replying!

Changed files
+176 -25
doc
src
+8 -7
doc/database_spec.md
··· 27 27 28 28 > represents a public post 29 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 | 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 | 37 38 38 39 ## `Like` 39 40
+1 -2
doc/todo.md
··· 4 4 5 5 ## in-progress 6 6 7 - - [ ] post:replies 8 - 9 7 ## planing 10 8 11 9 - [ ] post:tags ('hashtags') ··· 30 28 - [x] post:mentioning ('tagging') other users in posts 31 29 - [x] post:mentioning:who mentioned you (send notifications when a user mentions you) 32 30 - [x] post:editing 31 + - [x] post:replies 33 32 - [ ] ~~site:stylesheet (and a toggle for html-only mode)~~ 34 33 - replaced with per-user optional stylesheets 35 34 - [x] site:message of the day (admins can add a welcome message displayed on index.html)
+18 -9
src/api.v
··· 382 382 ////// post ////// 383 383 384 384 @['/api/post/new_post'; post] 385 - fn (mut app App) api_post_new_post(mut ctx Context, title string, body string) veb.Result { 385 + fn (mut app App) api_post_new_post(mut ctx Context, replying_to int, title string, body string) veb.Result { 386 386 user := app.whoami(mut ctx) or { 387 387 ctx.error('not logged in!') 388 - return ctx.redirect('/') 388 + return ctx.redirect('/login') 389 389 } 390 390 391 391 if user.muted { 392 392 ctx.error('you are muted!') 393 - return ctx.redirect('/me') 393 + return ctx.redirect('/post/new') 394 394 } 395 395 396 396 // validate title 397 397 if !app.validators.post_title.validate(title) { 398 398 ctx.error('invalid title') 399 - return ctx.redirect('/me') 399 + return ctx.redirect('/post/new') 400 400 } 401 401 402 402 // validate body 403 403 if !app.validators.post_body.validate(body) { 404 404 ctx.error('invalid body') 405 - return ctx.redirect('/me') 405 + return ctx.redirect('/post/new') 406 406 } 407 407 408 - post := Post{ 408 + mut post := Post{ 409 409 author_id: user.id 410 410 title: title 411 411 body: body 412 412 } 413 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 + 414 423 sql app.db { 415 424 insert post into Post 416 425 } or { 417 426 ctx.error('failed to post!') 418 427 println('failed to post: ${post} from user ${user.id}') 419 - return ctx.redirect('/me') 428 + return ctx.redirect('/post/new') 420 429 } 421 430 422 431 // find the post's id to process mentions with 423 432 if x := app.get_post_by_author_and_timestamp(user.id, post.posted_at) { 424 433 app.process_post_mentions(x) 434 + return ctx.redirect('/post/${x.id}') 425 435 } else { 426 436 ctx.error('failed to get_post_by_timestamp_and_author for ${post}') 437 + return ctx.redirect('/me') 427 438 } 428 - 429 - return ctx.redirect('/me') 430 439 } 431 440 432 441 @['/api/post/delete'; post]
+19 -3
src/app.v
··· 154 154 } 155 155 } 156 156 157 + pub fn (app &App) get_unknown_post() Post { 158 + return Post{ 159 + title: 'unknown' 160 + } 161 + } 162 + 157 163 pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 158 164 if !ctx.is_logged_in() { 159 165 return false ··· 308 314 } 309 315 author_name := author.get_name() 310 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 311 328 mut re := regex.regex_opt('@\\(${app.config.user.username_pattern}\\)') or { 312 329 eprintln('failed to compile regex for process_post_mentions (err: ${err})') 313 330 return 314 331 } 315 332 matches := re.find_all_str(post.body) 316 - mut mentioned_users := []int{} 317 333 for mat in matches { 318 334 println('found mentioned user: ${mat}') 319 335 username := mat#[2..-1] ··· 321 337 continue 322 338 } 323 339 324 - if user.id in mentioned_users || user.id == author.id { 340 + if user.id in notified_users || user.id == author.id { 325 341 continue 326 342 } 327 - mentioned_users << user.id 343 + notified_users << user.id 328 344 329 345 app.send_notification_to( 330 346 user.id,
+3 -2
src/entity/post.v
··· 4 4 5 5 pub struct Post { 6 6 pub mut: 7 - id int @[primary; sql: serial] 8 - author_id int 7 + id int @[primary; sql: serial] 8 + author_id int 9 + replying_to ?int 9 10 10 11 title string 11 12 body string
+46
src/pages.v
··· 77 77 } 78 78 ctx.title = '${app.config.instance.name} - ${post.title}' 79 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 + 80 93 return $veb.html() 81 94 } 82 95 ··· 97 110 ctx.title = '${app.config.instance.name} - editing ${post.title}' 98 111 return $veb.html() 99 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 28 </div> 29 29 </div> 30 30 31 - @include 'partial/footer.html' 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 4 <script src="/static/js/render_body.js"></script> 5 5 6 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> 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> 8 16 <pre id="post-@{post.id}">@post.body</pre> 9 17 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 10 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 11 23 12 24 @if ctx.is_logged_in() && post.author_id == user.id 13 25 <p><a href="/post/@{post.id}/edit">edit post</a></p>