Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notifications #1536

Merged
merged 107 commits into from
May 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
9f70c0d
Initialize notifications app
rafalp Mar 30, 2023
e8712d1
Add migration for thread subscriptions
rafalp Mar 30, 2023
dee6e14
Add threads subscriptions migration
rafalp Mar 30, 2023
89d1f2d
Move targets enum to separate file
rafalp Mar 30, 2023
f3f6d1c
Add notifications settings
rafalp Apr 2, 2023
c7f401d
Fix translation strings in notifications form
rafalp Apr 2, 2023
645c5e2
Fix tests
rafalp Apr 2, 2023
bbd0451
Add notifications fields to user model
rafalp Apr 6, 2023
39923dd
New notification preferences for users
rafalp Apr 6, 2023
559b355
Add API for thread watching
rafalp Apr 8, 2023
e79ab98
Add notifications API
rafalp Apr 9, 2023
04cbcae
Add message and url factories for notifications
rafalp Apr 9, 2023
8a61464
Add Python views for notifications
rafalp Apr 9, 2023
213dc02
Return serialized notifications from API
rafalp Apr 10, 2023
c65a3aa
Move notification url creation to model
rafalp Apr 10, 2023
f54b386
Improv serializer, add management commad for deleting old notifications
rafalp Apr 10, 2023
22fd020
Fix serializer, start UI work
rafalp Apr 10, 2023
ed47de5
Quick sketching of notifications dropdown
rafalp Apr 10, 2023
12427b9
Add dropdown component
rafalp Apr 11, 2023
96d10ee
Select notification with relations for url buildding
rafalp Apr 11, 2023
01bbbfc
More work on notifications
rafalp Apr 12, 2023
5aa717c
Iterate on notifications look
rafalp Apr 15, 2023
c6dcabc
Notifications list
rafalp Apr 15, 2023
dbb060f
Notifications page
rafalp Apr 15, 2023
ef40b1c
Add mark all as read and extra tweaks to counter sync
rafalp Apr 15, 2023
61d9855
Test self-healing unread notifications counter
rafalp Apr 16, 2023
99ace78
Add notification limits for API and healing
rafalp Apr 16, 2023
50f22b0
Notifications list, heal notifications count in the UI, mark all noti…
rafalp Apr 16, 2023
c0d12e4
WIP notifications dropdown
rafalp Apr 16, 2023
8fa38ef
Notifications dropdown
rafalp Apr 16, 2023
83e9ebd
Small style tweaks to notifications dropdown
rafalp Apr 16, 2023
d569ee7
Notifications overlay
rafalp Apr 16, 2023
bbef209
More work on new navbar
rafalp Apr 24, 2023
5328d59
Small styles tweak
rafalp Apr 24, 2023
732618f
Add placeholder nav items
rafalp Apr 26, 2023
983dbeb
Navbar: WIP new search
rafalp Apr 29, 2023
4e58e39
Non-js navbar version
rafalp Apr 30, 2023
93f5bcf
Fix tests, format code with black, remove some unused code
rafalp Apr 30, 2023
87ec6b5
Notifications and search dropdowns and overlays
rafalp Apr 30, 2023
fbfebd9
Add categories map to frontend context
rafalp Apr 30, 2023
75b9037
Quick experiment with site nav dropdown
rafalp Apr 30, 2023
e206a9a
Site nav
rafalp May 2, 2023
665912c
Navbar v2
rafalp May 4, 2023
246e676
DB fix, better unread counter healing add ruff
rafalp May 4, 2023
119eb12
Add util for bulk watching threads retrieval
rafalp May 4, 2023
7b0f57b
Update read post api to handle watching and notifications
rafalp May 4, 2023
e0e36dd
Fix showstopers, expose watching info for thread
rafalp May 4, 2023
7c02ec0
Replace subscription button with watch button
rafalp May 4, 2023
9a876d3
Watch button tweaks
rafalp May 4, 2023
ed1d74f
WIP watching of started/replied threads
rafalp May 4, 2023
a48194d
Add tests for auto-watch started/replied utils
rafalp May 4, 2023
c346083
Use new watch thread options when posting
rafalp May 5, 2023
c1afd61
Naive notifications on thread reply
rafalp May 5, 2023
1c0665f
WIP Notify about new reply
rafalp May 5, 2023
c56e94e
Finalize REPLIED notification message and url
rafalp May 5, 2023
35b51eb
Notify user about new reply in watched thread
rafalp May 5, 2023
fd6f9b8
Fix import error, add notification tests
rafalp May 5, 2023
817599a
Comment tweak
rafalp May 6, 2023
4f5c767
WIP: Watch new private thread
rafalp May 6, 2023
5f1d868
WIP notify user about private thread
rafalp May 6, 2023
0849c14
Move code around, remove some subscriptions code
rafalp May 6, 2023
c8108d2
Watch private thread you are added to
rafalp May 6, 2023
dd4a3c1
Add tests for participants watch thread they are added to
rafalp May 6, 2023
73b3c4e
Add tests for new private thread notifications
rafalp May 6, 2023
aef856f
Extra tests, make participants run in post save
rafalp May 6, 2023
b014d3b
Make mentions test non-random
rafalp May 6, 2023
2f84786
Fix tests
rafalp May 6, 2023
e1cccb0
Make select relations for notification url explicit
rafalp May 6, 2023
e73d6d0
WIP make tests pass with celery disabled
rafalp May 6, 2023
bc74098
Mock out notifications in more tests
rafalp May 6, 2023
3b502ea
Mock out celery calls in more tests
rafalp May 6, 2023
db90761
Mock out all celery calls in threads tests
rafalp May 6, 2023
b8018b4
Fix ruff errors
rafalp May 6, 2023
39eb035
Add link for disabling e-mail notifications
rafalp May 7, 2023
94ec107
Move code around
rafalp May 7, 2023
0069f2e
Format code with black
rafalp May 7, 2023
8a5e348
Backend for watched threads list, redirect from subscribed list
rafalp May 9, 2023
c4f761f
Fix migration
rafalp May 9, 2023
827b5ef
Remove subscription utils
rafalp May 9, 2023
abd6fc0
Add watched threads list
rafalp May 9, 2023
e8fcedd
Tweak fake categories generator
rafalp May 9, 2023
7fd200b
Replace notifications with send_emails
rafalp May 13, 2023
6114cb4
Fix category faker tests
rafalp May 13, 2023
db1c41d
Make thread reverse in watched threads index
rafalp May 13, 2023
ba27146
Add migration
rafalp May 13, 2023
ad1bd90
Format code with black
rafalp May 13, 2023
011a584
Remove notification's extra actors
rafalp May 14, 2023
8e32b3d
Update notifications on threads bulk merge
rafalp May 14, 2023
aea3f58
Merge threads handle notifications and watched threads
rafalp May 14, 2023
78c68c4
Change notificatio option labels
rafalp May 14, 2023
2859d21
Keep send emails preference for merged watched threads
rafalp May 14, 2023
d4b1688
Tweak code comment
rafalp May 14, 2023
a62d5cf
User name change and data anonymize
rafalp May 14, 2023
856daba
Handle user content deletion
rafalp May 14, 2023
3807f65
Add logic for user account deletion
rafalp May 14, 2023
800a8ed
Include user notifications in data exports
rafalp May 14, 2023
7d9edd8
Add signals for thread notifications/watched syncing
rafalp May 14, 2023
6c7a64e
Fix thread.set_title, add management commands for notifications and w…
rafalp May 14, 2023
cbc4004
Add index, update migrations
rafalp May 14, 2023
ad7b72a
Add some additional indexes for notifications
rafalp May 14, 2023
c56bb38
Update navbar logo settings and handling
rafalp May 14, 2023
085a6d0
Spellcheck copy with chatgpt
rafalp May 15, 2023
029e5a1
Commit changes
rafalp May 15, 2023
6e95006
Add notify_user util
rafalp May 15, 2023
362f90c
Format code with black
rafalp May 15, 2023
60ae680
Commit dev docs
rafalp May 15, 2023
b9226f0
Update docs and combine message and register notifications registries
rafalp May 15, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ jobs:
coveralls
- name: Linters
run: |
black --check .
black --check .
ruff misago/notifications misago/apiv2
12 changes: 12 additions & 0 deletions dev-docs/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Misago Developer Reference

This directory contains reference documents for Misago developers.

> **Note:** This documentation is a temporary solution. In future I aim to generate Misago's dev documentation from it's codebase.


## Notifications

Misago's notifications feature is implemented in the `misago.notifications` package.

- [Notifications guide](./notifications.md)
187 changes: 187 additions & 0 deletions dev-docs/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Notifications guide

Misago's notifications feature is implemented in the `misago.notifications` package.


## Notification model

In Misago notifications are represented as instances of `misago.notifications.models.Notification` Django model.

This model has following fields:

- `id`: an `int` with primary key.
- `user`: an foreign key to `misago.users.models.User`.
- `verb`: a `str` with this notification's type. Max 32 characters.
- `is_read`: a `bool` marking this notification as read or unread. Defaults to `False` (unread).
- `actor`: an foreign key to `misago.users.models.User`, nullable.
- `actor_name`: a `str` with actor username, nullable. Max 255 characters.
- `category`: an foreign key to `misago.categories.models.Category`, nullable.
- `thread`: an foreign key to `misago.threads.models.Thread`, nullable.
- `thread_title`: a `str` with thread title, nullable. Max 255 characters.
- `post`: an foreign key to `misago.threads.models.Post`, nullable.
- `created_at`: `datetime` of notification's creation.


## Notifying users

To notify user about an event, use `notify_user` function from `misago.notifications.users`:

```python
def notify_user(
user: "User",
verb: str,
actor: Optional["User"] = None,
category: Optional[Category] = None,
thread: Optional[Thread] = None,
post: Optional[Post] = None,
) -> Notification:
...
```

This function performs two tasks:

- It saves new `misago.notifications.models.Notification` instance to database.
- It increases `user.unread_notifications` attribute in database.

It takes two required arguments:

- `user`: an instance of `misago.users.models.User` representing user to notify.
- `verb`: a `str` with notification's "verb", eg. `REPLIED`, `FOLLOWED`.

It optionally takes following arguments:

- `actor`: an instance of `misago.users.models.User` representing user who caused the event.
- `category`: an instance of `misago.categories.models.Categry` representing category in which the event has occurred.
- `thread`: an instance of `misago.threads.models.Thread` representing thread in which the event has occurred.
- `post`: an instance of `misago.threads.models.Post` representing post in which the event has occurred.

Example `notify_user` call executed by Misago to notify an user watching a thread about new reply looks like this:

```python
notify_user(
watched_thread.user,
"REPLIED",
actor=reply.poster,
category=reply.category,
thread=reply.thread,
post=reply,
)
```


## Adding custom notification

Misago supports adding custom notifications for new events.


### Notification verb

Each notification type is a "verb", a `str` representing the action (or event) that caused this notification.

Standard verbs used by Misago are defined on `misago.notifications.verbs.NotificationVerb` enumerable.

There's also special `TEST` verb thats not defined on `NotificationVerb` but is still supported for testing purposes.

Custom verbs can be any Python string not longer than 32 characters. To reduce the risk of your custom verb conflicting with other plugin or future Misago release I recommend prefixing it with name of your plugin, eg. `USER_VOTES_VOTED`.

Once you've decided on verb for your notification, you will be able to implement message and redirect url for it.


### Notification registry

Misago uses factory functions to retrieve notifications messages and redirect urls.

Those factory functions are registered in special registry, importable as `registry` from `misago.notifications.registry` module.


### Notification message

Factory function for notification's message is called with single argument, `Notification` instance, and returns **escaped** `str` with HTML for notification message:

```python
def get_verb_notification_message(notification: Notification) -> str:
return html.escape(f"Test notification #{notification.id}")
```

> **Note:** For performance reasons foreign key fields on `Notification` model are not populated when notifications list is retrieved.
>
> Use `notification.actor_name` field to retrieve actor's name and `notification.thread_title` field to retrieve thread's title.

To register custom function as message factory for verb, use `registry`'s `message` method:

```python
import html

from misago.notifications.registry import Notification, registry


# `message` works as decorator
@registry.message("CUSTOM")
def get_custom_notification_message(notification: Notification) -> str:
return html.escape(f"Custom notification in {notification.thread_title}")


# And as setter
def get_custom_notification_message(notification: Notification) -> str:
return html.escape(f"Custom notification in {notification.thread_title}")


registry.message("CUSTOM", get_custom_notification_message)
```


### Notification redirect url

Factory function for notification's redirect url is called with two arguments, Django's `HttpRequest` instance and the `Notification` instance, and retuurns `str` with URL to which user should be redirected to see notification's source:

```python
def get_verb_notification_redirect_url(request: HttpRequest, notification: Notification) -> str:
return reverse(
"my-plugin:some-url",
kwargs={
"id": notification.thread.id,
"slug": notification.thread.slug,
},
)
```

> **Note:** Foreign key fields on `Notification` model are populated with related objects when notification redirect url is retrieved.


To register custom function as redirect url factory for verb, use `registry`'s `redirect` method:

```python
from django.http import HttpRequest
from django.urls import reverse
from misago.notifications.registry import Notification, registry


# `redirect` works as decorator
@registry.redirect("CUSTOM")
def get_custom_notification_redirect_url(
request: HttpRequest, notification: Notification
) -> str:
return reverse(
"my-plugin:some-url",
kwargs={
"id": notification.thread.id,
"slug": notification.thread.slug,
},
)


# And as setter
def get_custom_notification_redirect_url(
request: HttpRequest, notification: Notification
) -> str:
return reverse(
"my-plugin:some-url",
kwargs={
"id": notification.thread.id,
"slug": notification.thread.slug,
},
)


registry.redirect("CUSTOM", get_custom_notification_redirect_url)
```
3 changes: 3 additions & 0 deletions devproject/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@
"misago.themes",
"misago.markup",
"misago.legal",
"misago.notifications",
"misago.categories",
"misago.threads",
"misago.readtracker",
Expand All @@ -204,6 +205,7 @@
"misago.faker",
"misago.menus",
"misago.plugins",
"misago.apiv2",
]

INTERNAL_IPS = ["127.0.0.1"]
Expand Down Expand Up @@ -305,6 +307,7 @@
"misago.markup.context_processors.preload_api_url",
"misago.threads.context_processors.preload_threads_urls",
"misago.users.context_processors.preload_user_json",
"misago.categories.context_processors.preload_categories_json",
"misago.socialauth.context_processors.preload_socialauth_json",
# Note: keep frontend_context processor last for previous processors
# to be able to expose data UI app via request.frontend_context
Expand Down
9 changes: 6 additions & 3 deletions devproject/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
# Use in-memory cache
CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}

# Disable Celery backend
CELERY_BROKER_URL = None

# Disable Debug Toolbar
DEBUG_TOOLBAR_CONFIG = {}
INTERNAL_IPS = []

# Disable account validation via Stop Forum Spam
MISAGO_NEW_REGISTRATIONS_VALIDATORS = ("misago.users.validators.validate_gmail_email",)

# Store mails in memory
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

# Disable account validation via Stop Forum Spam
MISAGO_NEW_REGISTRATIONS_VALIDATORS = ("misago.users.validators.validate_gmail_email",)

# Use MD5 password hashing to speed up test suite
PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",)

Expand Down
116 changes: 116 additions & 0 deletions frontend/src/components/Api/ApiFetch.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from "react"

export default class ApiFetch extends React.Component {
constructor(props) {
super(props)

this.state = {
data: null,
loading: false,
error: null,
}

this.controller = new AbortController()
this.signal = this.controller.signal
}

componentDidMount() {
if (this.props.url && !this.props.disabled) {
this.request(this.props.url)
}
}

componentDidUpdate(prevProps) {
const url = this.props.url
const urlChanged = url && url !== prevProps.url
const disabledChanged = this.props.disabled != prevProps.disabled

if (urlChanged || disabledChanged) {
if (!this.props.disabled) {
if (this.hasCache(url)) {
this.getCache(url)
} else {
this.controller.abort()

this.controller = new AbortController()
this.signal = this.controller.signal
this.request(url)
}
} else {
this.controller.abort()
}
}
}

componentWillUnmount() {
this.controller.abort()
}

hasCache = (url) => {
return this.props.cache && this.props.cache[url]
}

getCache = async (url) => {
const data = this.props.cache[url]
this.setState({ loading: false, error: null, data })
if (this.props.onData) {
await this.props.onData(data)
}
}

setCache = (url, data) => {
if (this.props.cache) {
this.props.cache[url] = data
}
}

request = (url) => {
this.setState({ loading: true })

fetch(url, {
method: "GET",
credentials: "include",
signal: this.signal,
}).then(
async (response) => {
if (url === this.props.url) {
if (response.status == 200) {
const data = await response.json()
this.setState({ loading: false, error: null, data })
this.setCache(url, data)
if (this.props.onData) {
await this.props.onData(data)
}
} else {
const error = { status: response.status }
if (response.headers.get("Content-Type") === "application/json") {
error.data = await response.json()
}
this.setState({ loading: false, error })
}
}
},
(rejection) => {
if (url === this.props.url) {
this.setState({ loading: false, error: { status: 0, rejection } })
}
}
)
}

refetch = () => {
this.request(this.props.url)
}

update = (mutation) => {
this.setState((state) => {
return { data: mutation(state.data) }
})
}

render() {
return this.props.children(
Object.assign({ refetch: this.refetch, update: this.update }, this.state)
)
}
}
Loading