- Show timezone next to post datetimes
- Take read status in consideration when sorting posts
This commit is contained in:
Sonny Bakker 2020-10-31 14:31:27 +01:00
parent ee9b36d8ae
commit 116b6d1308
19 changed files with 166 additions and 57 deletions

View file

@ -1,9 +1,11 @@
from django import forms from django import forms
from newsreader.accounts.models import User from newsreader.accounts.models import User
from newsreader.core.forms import CheckboxInput
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ("first_name", "last_name") fields = ("first_name", "last_name", "auto_mark_read")
widgets = {"auto_mark_read": CheckboxInput}

View file

@ -0,0 +1,20 @@
# Generated by Django 3.0.7 on 2020-10-27 21:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("accounts", "0012_remove_user_task")]
operations = [
migrations.AddField(
model_name="user",
name="auto_mark_read",
field=models.BooleanField(
default=True,
help_text="Wether posts should be marked as read after x amount of seconds of reading",
verbose_name="Auto read marking",
),
)
]

View file

@ -45,6 +45,15 @@ class User(AbstractUser):
twitter_oauth_token = models.CharField(max_length=255, blank=True, null=True) twitter_oauth_token = models.CharField(max_length=255, blank=True, null=True)
twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True) twitter_oauth_token_secret = models.CharField(max_length=255, blank=True, null=True)
# settings
auto_mark_read = models.BooleanField(
_("Auto read marking"),
default=True,
help_text=_(
"Wether posts should be marked as read after x amount of seconds of reading"
),
)
username = None username = None
objects = UserManager() objects = UserManager()

View file

@ -1,8 +1,14 @@
from .dev import * # isort:skip from .base import * # isort:skip
SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$" SECRET_KEY = "=q(ztyo)b6noom#a164g&s9vcj1aawa^g#ing_ir99=_zl4g&$"
INSTALLED_APPS += ["debug_toolbar", "django_extensions"]
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
@ -24,6 +30,16 @@ CACHES = {
}, },
} }
# Third party settings
# Axes
AXES_FAILURE_LIMIT = 50
AXES_COOLOFF_TIME = None
# Celery # Celery
# https://docs.celeryproject.org/en/latest/userguide/configuration.html # https://docs.celeryproject.org/en/latest/userguide/configuration.html
CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//" CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672//"
try:
from .local import * # noqa
except ImportError:
pass

View file

@ -23,12 +23,9 @@ class App extends React.Component {
feedUrl={this.props.feedUrl} feedUrl={this.props.feedUrl}
subredditUrl={this.props.subredditUrl} subredditUrl={this.props.subredditUrl}
timelineUrl={this.props.timelineUrl} timelineUrl={this.props.timelineUrl}
timezone={this.props.timezone}
/> />
{this.props.error && (
<Messages messages={[{ type: 'error', text: this.props.error.message }]} />
)}
{!isEqual(this.props.post, {}) && ( {!isEqual(this.props.post, {}) && (
<PostModal <PostModal
post={this.props.post} post={this.props.post}
@ -38,8 +35,14 @@ class App extends React.Component {
subredditUrl={this.props.subredditUrl} subredditUrl={this.props.subredditUrl}
timelineUrl={this.props.timelineUrl} timelineUrl={this.props.timelineUrl}
categoriesUrl={this.props.categoriesUrl} categoriesUrl={this.props.categoriesUrl}
timezone={this.props.timezone}
autoMarking={this.props.autoMarking}
/> />
)} )}
{this.props.error && (
<Messages messages={[{ type: 'error', text: this.props.error.message }]} />
)}
</> </>
); );
} }

View file

@ -9,6 +9,7 @@ export const RECEIVE_POST = 'RECEIVE_POST';
export const REQUEST_POSTS = 'REQUEST_POSTS'; export const REQUEST_POSTS = 'REQUEST_POSTS';
export const MARK_POST_READ = 'MARK_POST_READ'; export const MARK_POST_READ = 'MARK_POST_READ';
export const MARKING_POST = 'MARKING_POST';
export const requestPosts = () => ({ type: REQUEST_POSTS }); export const requestPosts = () => ({ type: REQUEST_POSTS });
@ -30,10 +31,14 @@ export const postRead = (post, section) => ({
section, section,
}); });
export const markingPostRead = () => ({ type: MARKING_POST });
export const markPostRead = (post, token) => { export const markPostRead = (post, token) => {
return (dispatch, getState) => { return (dispatch, getState) => {
const { selected } = getState(); const { selected } = getState();
dispatch(markingPostRead());
const url = `/api/posts/${post.id}/`; const url = `/api/posts/${post.id}/`;
const options = { const options = {
method: 'PATCH', method: 'PATCH',

View file

@ -21,7 +21,7 @@ class PostModal extends React.Component {
const markPostRead = this.props.markPostRead; const markPostRead = this.props.markPostRead;
const token = Cookies.get('csrftoken'); const token = Cookies.get('csrftoken');
if (!post.read) { if (this.props.autoMarking && !post.read) {
this.readTimer = setTimeout(markPostRead, 3000, post, token); this.readTimer = setTimeout(markPostRead, 3000, post, token);
} }
@ -48,31 +48,47 @@ class PostModal extends React.Component {
render() { render() {
const post = this.props.post; const post = this.props.post;
const token = Cookies.get('csrftoken');
const publicationDate = formatDatetime(post.publicationDate); const publicationDate = formatDatetime(post.publicationDate);
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title'; const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
let ruleUrl = ''; const readButtonDisabled = post.read || this.props.isMarkingPost;
if (this.props.rule.type === SUBREDDIT) { let ruleUrl = '';
ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`; switch (this.props.rule.type) {
} else if (this.props.rule.type === TWITTER_TIMELINE) { case SUBREDDIT:
ruleUrl = `${this.props.timelineUrl}/${this.props.rule.id}/`; ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`;
} else { break;
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`; case TWITTER_TIMELINE:
ruleUrl = `${this.props.timelineUrl}/${this.props.rule.id}/`;
break;
default:
ruleUrl = `${this.props.feedUrl}/${this.props.rule.id}/`;
break;
} }
return ( return (
<div className="modal post-modal"> <div className="modal post-modal">
<div className="post"> <div className="post">
<span <div className="post__actions">
className="button post__close-button" <button
onClick={() => this.props.unSelectPost()} className={`button read-button ${readButtonDisabled && 'button--disabled'}`}
> onClick={() => !readButtonDisabled && this.props.markPostRead(post, token)}
Close <i className="gg-close"></i> >
</span> Mark as read
</button>
<button
className="button post__close-button"
onClick={() => this.props.unSelectPost()}
>
Close <i className="gg-close"></i>
</button>
</div>
<div className="post__header"> <div className="post__header">
<h2 className={titleClassName}>{`${post.title} `}</h2> <h2 className={titleClassName}>{`${post.title} `}</h2>
<div className="post__meta-info"> <div className="post__meta-info">
<span className="post__date">{publicationDate}</span> <span className="post__date">
{publicationDate} {this.props.timezone}
</span>
{post.author && <span className="post__author">{post.author}</span>} {post.author && <span className="post__author">{post.author}</span>}
{this.props.category && ( {this.props.category && (
<span className="badge post__category" title={this.props.category.name}> <span className="badge post__category" title={this.props.category.name}>
@ -114,4 +130,6 @@ const mapDispatchToProps = dispatch => ({
markPostRead: (post, token) => dispatch(markPostRead(post, token)), markPostRead: (post, token) => dispatch(markPostRead(post, token)),
}); });
export default connect(null, mapDispatchToProps)(PostModal); const mapStateToProps = state => ({ isMarkingPost: state.posts.isMarking });
export default connect(mapStateToProps, mapDispatchToProps)(PostModal);

View file

@ -41,7 +41,7 @@ class PostItem extends React.Component {
<div className="posts-info"> <div className="posts-info">
<span className="posts-info__date" title={publicationDate}> <span className="posts-info__date" title={publicationDate}>
{publicationDate} {post.author && `By ${post.author}`} {publicationDate} {this.props.timezone} {post.author && `By ${post.author}`}
</span> </span>
{this.props.selected.type == CATEGORY_TYPE && ( {this.props.selected.type == CATEGORY_TYPE && (
<span className="badge"> <span className="badge">

View file

@ -46,6 +46,7 @@ class PostList extends React.Component {
feedUrl={this.props.feedUrl} feedUrl={this.props.feedUrl}
subredditUrl={this.props.subredditUrl} subredditUrl={this.props.subredditUrl}
timelineUrl={this.props.timelineUrl} timelineUrl={this.props.timelineUrl}
timezone={this.props.timezone}
/> />
); );
}); });

View file

@ -4,6 +4,19 @@ const isEmpty = (object = {}) => {
return Object.keys(object).length === 0; return Object.keys(object).length === 0;
}; };
const sortOrdering = (firstPost, secondPost) => {
const dateOrdering =
new Date(firstPost.publicationDate) < new Date(secondPost.publicationDate);
if (firstPost.read && !secondPost.read) {
return 1;
} else if (secondPost.read && !firstPost.read) {
return -1;
}
return dateOrdering;
};
export const filterPostsByRule = (rule = {}, posts = []) => { export const filterPostsByRule = (rule = {}, posts = []) => {
const filteredPosts = posts.filter(post => { const filteredPosts = posts.filter(post => {
return post.rule === rule.id; return post.rule === rule.id;
@ -11,9 +24,7 @@ export const filterPostsByRule = (rule = {}, posts = []) => {
const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } })); const filteredData = filteredPosts.map(post => ({ ...post, rule: { ...rule } }));
return filteredData.sort((firstPost, secondPost) => { return filteredData.sort(sortOrdering);
return new Date(secondPost.publicationDate) - new Date(firstPost.publicationDate);
});
}; };
export const filterPostsByCategory = (category = {}, rules = [], posts = []) => { export const filterPostsByCategory = (category = {}, rules = [], posts = []) => {
@ -29,9 +40,7 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) =>
return filteredPosts.map(post => ({ ...post, rule: { ...rule } })); return filteredPosts.map(post => ({ ...post, rule: { ...rule } }));
}); });
const sortedPosts = [...filteredData.flat()].sort((firstPost, secondPost) => { const sortedPosts = [...filteredData.flat()].sort(sortOrdering);
return new Date(secondPost.publicationDate) - new Date(firstPost.publicationDate);
});
return sortedPosts; return sortedPosts;
}; };

View file

@ -11,18 +11,18 @@ const page = document.getElementById('homepage--page');
if (page) { if (page) {
const store = configureStore(); const store = configureStore();
let feedUrl = document.getElementById('feedUrl').textContent; const settings = JSON.parse(document.getElementById('homepageSettings').textContent);
let subredditUrl = document.getElementById('subredditUrl').textContent; const { feedUrl, subredditUrl, timelineUrl, categoriesUrl } = settings;
let timelineUrl = document.getElementById('timelineUrl').textContent;
let categoriesUrl = document.getElementById('categoriesUrl').textContent;
ReactDOM.render( ReactDOM.render(
<Provider store={store}> <Provider store={store}>
<App <App
feedUrl={feedUrl.substring(1, feedUrl.length - 4)} feedUrl={feedUrl.substring(1, feedUrl.length - 3)}
subredditUrl={subredditUrl.substring(1, subredditUrl.length - 4)} subredditUrl={subredditUrl.substring(1, subredditUrl.length - 3)}
timelineUrl={timelineUrl.substring(1, timelineUrl.length - 4)} timelineUrl={timelineUrl.substring(1, timelineUrl.length - 3)}
categoriesUrl={categoriesUrl.substring(1, categoriesUrl.length - 4)} categoriesUrl={categoriesUrl.substring(1, categoriesUrl.length - 3)}
timezone={settings.timezone}
autoMarking={settings.autoMarking}
/> />
</Provider>, </Provider>,
page page

View file

@ -5,6 +5,8 @@ import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
import { import {
SELECT_POST, SELECT_POST,
MARKING_POST,
MARK_POST_READ,
RECEIVE_POST, RECEIVE_POST,
RECEIVE_POSTS, RECEIVE_POSTS,
REQUEST_POSTS, REQUEST_POSTS,
@ -13,7 +15,7 @@ import { SELECT_CATEGORY } from '../actions/categories.js';
import { SELECT_RULE } from '../actions/rules.js'; import { SELECT_RULE } from '../actions/rules.js';
import { MARK_SECTION_READ } from '../actions/selected.js'; import { MARK_SECTION_READ } from '../actions/selected.js';
const defaultState = { items: {}, isFetching: false }; const defaultState = { items: {}, isFetching: false, isMarking: false };
export const posts = (state = { ...defaultState }, action) => { export const posts = (state = { ...defaultState }, action) => {
switch (action.type) { switch (action.type) {
@ -62,6 +64,10 @@ export const posts = (state = { ...defaultState }, action) => {
...updatedPosts, ...updatedPosts,
}, },
}; };
case MARKING_POST:
return { ...state, isMarking: true };
case MARK_POST_READ:
return { ...state, isMarking: false };
default: default:
return state; return state;
} }

View file

@ -14,12 +14,18 @@ describe('post actions', () => {
fetchMock.restore(); fetchMock.restore();
}); });
it('should create an action request posts', () => { it('should create an action to request posts', () => {
const expectedAction = { type: actions.REQUEST_POSTS }; const expectedAction = { type: actions.REQUEST_POSTS };
expect(actions.requestPosts()).toEqual(expectedAction); expect(actions.requestPosts()).toEqual(expectedAction);
}); });
it('should create an action to mark a post read', () => {
const expectedAction = { type: actions.MARKING_POST };
expect(actions.markingPostRead()).toEqual(expectedAction);
});
it('should create an action receive a post', () => { it('should create an action receive a post', () => {
const post = { const post = {
id: 2067, id: 2067,
@ -147,6 +153,7 @@ describe('post actions', () => {
}); });
const expectedActions = [ const expectedActions = [
{ type: actions.MARKING_POST },
{ {
type: actions.RECEIVE_POST, type: actions.RECEIVE_POST,
post: { ...post, read: true }, post: { ...post, read: true },
@ -362,6 +369,7 @@ describe('post actions', () => {
}); });
const expectedActions = [ const expectedActions = [
{ type: actions.MARKING_POST },
{ type: actions.RECEIVE_POST, post: {} }, { type: actions.RECEIVE_POST, post: {} },
{ type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) }, { type: errorActions.RECEIVE_API_ERROR, error: TypeError(errorMessage) },
]; ];

View file

@ -12,7 +12,7 @@ describe('post actions', () => {
it('should return state after requesting posts', () => { it('should return state after requesting posts', () => {
const action = { type: actions.REQUEST_POSTS }; const action = { type: actions.REQUEST_POSTS };
const expectedState = { ...defaultState, isFetching: true }; const expectedState = { ...defaultState, isFetching: true, isMarking: false };
expect(reducer(undefined, action)).toEqual(expectedState); expect(reducer(undefined, action)).toEqual(expectedState);
}); });
@ -40,6 +40,7 @@ describe('post actions', () => {
const expectedState = { const expectedState = {
...defaultState, ...defaultState,
isFetching: false, isFetching: false,
isMarking: false,
items: { [post.id]: post }, items: { [post.id]: post },
}; };
@ -85,6 +86,7 @@ describe('post actions', () => {
const expectedState = { const expectedState = {
...defaultState, ...defaultState,
isFetching: false, isFetching: false,
isMarking: false,
items: expectedPosts, items: expectedPosts,
}; };

View file

@ -6,10 +6,7 @@
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{{ feed_url|json_script:"feedUrl" }} {{ homepageSettings|json_script:"homepageSettings" }}
{{ subreddit_url|json_script:"subredditUrl" }}
{{ twitter_timeline_url|json_script:"timelineUrl" }}
{{ categories_url|json_script:"categoriesUrl" }}
{{ block.super }} {{ block.super }}
{% endblock scripts %} {% endblock scripts %}

View file

@ -1,3 +1,4 @@
from django.conf import settings
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView
@ -16,14 +17,18 @@ class NewsView(TemplateView):
return { return {
**context, **context,
"feed_url": reverse_lazy("news:collection:feed-update", args=(0,)), "homepageSettings": {
"subreddit_url": reverse_lazy( "feedUrl": reverse_lazy("news:collection:feed-update", args=(0,)),
"news:collection:subreddit-update", args=(0,) "subredditUrl": reverse_lazy(
), "news:collection:subreddit-update", args=(0,)
"twitter_timeline_url": reverse_lazy( ),
"news:collection:twitter-timeline-update", args=(0,) "timelineUrl": reverse_lazy(
), "news:collection:twitter-timeline-update", args=(0,)
"categories_url": reverse_lazy("news:core:category-update", args=(0,)), ),
"categoriesUrl": reverse_lazy("news:core:category-update", args=(0,)),
"timezone": settings.TIME_ZONE,
"autoMarking": self.request.user.auto_mark_read,
},
} }

View file

@ -15,6 +15,15 @@
cursor: initial; cursor: initial;
&__actions {
display: flex;
justify-content: flex-end;
width: 100%;
padding: 20px 50px 0;
gap: 20px;
}
&__header { &__header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -81,9 +90,6 @@
} }
&__close-button { &__close-button {
position: relative;
margin: 1% 2% 0 0;
align-self: flex-end;
background-color: $blue; background-color: $blue;
color: $white; color: $white;

View file

@ -20,4 +20,8 @@
padding: 2px 10px 5px 10px; padding: 2px 10px 5px 10px;
} }
} }
.read-button {
margin: 20px 0 0 0;
}
} }

View file

@ -1,8 +1,6 @@
.read-button { .read-button {
@extend .button; @extend .button;
margin: 20px 0 0 0;
color: $white; color: $white;
background-color: $green; background-color: $green;