+5
config.maple
+5
config.maple
+3
-2
doc/todo.md
+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
+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
+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
+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
+9
src/entity/notification.v
+6
src/main.v
+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
+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
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
+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
+6
-3
src/static/style.css
+30
src/templates/inbox.html
+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
+2
src/templates/partial/header.html
+2
-1
src/templates/post.html
+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'