Skip to content
This repository has been archived by the owner on Oct 1, 2019. It is now read-only.

Commit

Permalink
Add an ability for users to subscribe to posts (#437)
Browse files Browse the repository at this point in the history
  • Loading branch information
voidxnull committed Jul 31, 2016
1 parent 1412dac commit 8f7e75e
Show file tree
Hide file tree
Showing 15 changed files with 323 additions and 47 deletions.
13 changes: 13 additions & 0 deletions migrations/20160724204940_post_subscriptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export async function up(knex) {
await knex.schema.createTable('post_subscriptions', (table) => {
table.uuid('post_id')
.references('id').inTable('posts').onDelete('cascade').onUpdate('cascade');
table.uuid('user_id')
.references('id').inTable('users').onDelete('cascade').onUpdate('cascade');
table.index(['user_id', 'post_id']);
});
}

export async function down(knex) {
await knex.schema.dropTable('post_subscriptions');
}
3 changes: 2 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ app.use(async function reactMiddleware(ctx) {
'followed_geotags',
'liked_hashtags',
'liked_geotags',
'liked_schools'
'liked_schools',
'post_subscriptions'
]
});

Expand Down
17 changes: 17 additions & 0 deletions src/actions/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const SUBMIT_RESET_PASSWORD = 'SUBMIT_RESET_PASSWORD';
export const SUBMIT_NEW_PASSWORD = 'SUBMIT_NEW_PASSWORD';

export const SUBSCRIBE_TO_POST = 'SUBSCRIBE_TO_POST';
export const UNSUBSCRIBE_FROM_POST = 'UNSUBSCRIBE_FROM_POST';

export function addUser(user) {
return {
type: ADD_USER,
Expand Down Expand Up @@ -101,3 +104,17 @@ export function submitNewPassword() {
type: SUBMIT_NEW_PASSWORD
};
}

export function subscribeToPost(post_id) {
return {
type: SUBSCRIBE_TO_POST,
post_id
};
}

export function unsubscribeFromPost(post_id) {
return {
type: UNSUBSCRIBE_FROM_POST,
post_id
};
}
10 changes: 10 additions & 0 deletions src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,4 +589,14 @@ export default class ApiClient
return err.response.body;
}
}

async subscribeToPost(postId) {
const response = await this.post(`/api/v1/post/${postId}/subscribe`);
return await response.json();
}

async unsubscribeFromPost(postId) {
const response = await this.post(`/api/v1/post/${postId}/unsubscribe`);
return await response.json();
}
}
65 changes: 65 additions & 0 deletions src/api/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,9 @@ export default class ApiController {
await obj.attachGeotags(geotags);
}

// Add the author to the list of subscribers by default.
obj.subscribers().attach(ctx.session.user);

await obj.fetch({ require: true, withRelated: POST_RELATIONS });
obj.relations.schools = obj.relations.schools.map(row => ({ id: row.id, name: row.attributes.name, url_name: row.attributes.url_name }));

Expand Down Expand Up @@ -1562,6 +1565,68 @@ export default class ApiController {
ctx.body = { success: true };
};

/**
* Subscribes the current user to the specified post.
* If subscribed, the current user recieves notifications about new comments on the post.
*/
subscribeToPost = async (ctx) => {
if (!ctx.session || !ctx.session.user) {
ctx.status = 403;
ctx.body = { error: 'You are not authorized' };
return;
}

if (!('id' in ctx.params)) {
ctx.status = 400;
ctx.body = { error: '"id" parameter is not given' };
return;
}

const Post = this.bookshelf.model('Post');

try {
const post = await Post.where({ id: ctx.params.id }).fetch({ require: true });

await post.subscribers().attach(ctx.session.user);

ctx.status = 200;
ctx.body = { success: true };
} catch (e) {
ctx.status = 500;
ctx.body = { error: e.message };
return;
}
};

unsubscribeFromPost = async (ctx) => {
if (!ctx.session || !ctx.session.user) {
ctx.status = 403;
ctx.body = { error: 'You are not authorized' };
return;
}

if (!('id' in ctx.params)) {
ctx.status = 400;
ctx.body = { error: '"id" parameter is not given' };
return;
}

const Post = this.bookshelf.model('Post');

try {
const post = await Post.where({ id: ctx.params.id }).fetch({ require: true });

await post.subscribers().detach(ctx.session.user);

ctx.status = 200;
ctx.body = { success: true };
} catch (e) {
ctx.status = 500;
ctx.body = { error: e.message };
return;
}
};

getUser = async (ctx) => {
const User = this.bookshelf.model('User');
const u = await User
Expand Down
6 changes: 6 additions & 0 deletions src/api/db/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export function initBookshelfFromKnex(knex) {
followed_geotags() {
return this.belongsToMany(Geotag, 'followed_geotags_users', 'user_id', 'geotag_id');
},
post_subscriptions() {
return this.belongsToMany(Post, 'post_subscriptions');
},
virtuals: {
gravatarHash() {
return md5(this.get('email'));
Expand Down Expand Up @@ -186,6 +189,9 @@ export function initBookshelfFromKnex(knex) {
post_comments() {
return this.hasMany(Comment);
},
subscribers() {
return this.belongsToMany(User, 'post_subscriptions');
},

// Hashtag methods
async attachHashtags(names) {
Expand Down
2 changes: 2 additions & 0 deletions src/api/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export function initApi(bookshelf, sphinx) {
api.post('/post/:id/fav', controller.favPost);
api.post('/post/:id/unfav', controller.unfavPost);
api.get('/post/:id/related-posts', controller.getRelatedPosts);
api.post('/post/:id/subscribe', controller.subscribeToPost);
api.post('/post/:id/unsubscribe', controller.unsubscribeFromPost);

api.get('/post/:id/comments', controller.getPostComments);
api.post('/post/:id/comments', controller.postComment);
Expand Down
105 changes: 75 additions & 30 deletions src/components/post/footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import React, { PropTypes } from 'react';
import { Link } from 'react-router';
import { isEmpty } from 'lodash';
import Time from '../time';
Expand All @@ -26,41 +26,86 @@ import Toolbar from './toolbar';
import User from '../user';
import { URL_NAMES, getUrl } from '../../utils/urlGenerator';

const PostFooter = ({ author, current_user, post, triggers }) => {
const post_url = getUrl(URL_NAMES.POST, { uuid: post.id });
const hasTags = !isEmpty(post.geotags) || !isEmpty(post.hashtags) || !isEmpty(post.schools);

return (
<div>
<div className="card__meta">
<div className="card__owner">
<User avatar={{ size: 39 }} user={author} />
</div>
<div className="card__timestamp">
<Link to={post_url}>
<Time timestamp={post.created_at} />
</Link>
</div>
</div>
class PostFooter extends React.Component {
static propTypes = {
author: PropTypes.shape({}), // FIXME
current_user: PropTypes.shape({}), // FIXME
post: PropTypes.shape({}), // FIXME
triggers: PropTypes.shape({
subscribeToPost: PropTypes.function,
unsubscribeFromPost: PropTypes.function
})
};

handleToggleSubscription = async (e) => {
if (e.target.checked) {
await this.props.triggers.subscribeToPost(this.props.post.id);
} else {
await this.props.triggers.unsubscribeFromPost(this.props.post.id);
}
};

{hasTags &&
<footer className="card__footer">
<TagLine geotags={post.geotags} hashtags={post.hashtags} schools={post.schools} />
</footer>
}
render() {
const {
author,
current_user,
post,
triggers
} = this.props;

<footer className="card__footer card__footer-colored">
<div className="card__toolbars">
<Toolbar current_user={current_user} post={post} triggers={triggers} />
const post_url = getUrl(URL_NAMES.POST, { uuid: post.id });
const hasTags = !isEmpty(post.geotags) || !isEmpty(post.hashtags) || !isEmpty(post.schools);
const subscribed = current_user && current_user.post_subscriptions.indexOf(post.id) != -1;

<div className="card__toolbar card__toolbar-right">
<EditPostButton current_user={current_user} post={post} />
return (
<div>
<div className="card__meta">
<div className="card__owner">
<User avatar={{ size: 39 }} user={author} />
</div>
<div className="card__timestamp">
<Link to={post_url}>
<Time timestamp={post.created_at} />
</Link>
</div>
</div>
</footer>
</div>
);
};


{hasTags &&
<footer className="card__footer">
<TagLine geotags={post.geotags} hashtags={post.hashtags} schools={post.schools} />
</footer>
}

<footer className="card__footer card__footer-colored">
<div className="card__toolbars">
<Toolbar current_user={current_user} post={post} triggers={triggers} />

<div className="card__toolbar card__toolbar-right">
{current_user &&
<label
className="card__toolbar_item"
htmlFor="subscribe_to_post"
title="Recieve email notifications about new comments"
>
<span className="checkbox__label-left">Subscribe</span>
<input
checked={subscribed}
id="subscribe_to_post"
name="subscribe_to_post"
type="checkbox"
onClick={this.handleToggleSubscription}
/>
</label>
}
<EditPostButton current_user={current_user} post={post} />
</div>
</div>
</footer>
</div>
);
}
}

export default PostFooter;
2 changes: 1 addition & 1 deletion src/email-templates/new_comment.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@
<p class="page__content_text" style="margin: 20px 0 0 0;font-size: 16px;line-height: 1.4;">Commented your post: <a id="post-link" href="<%= post.url %>" class="page__blue" style="text-decoration: underline;color: #40b7e9;"><%= post.title %></a></p>
<div class="space" style="height: 20px;">&nbsp;</div>
<div class="space" style="height: 20px;">&nbsp;</div>
<!-- <p class="page__content_text" style="margin: 20px 0 0 0;font-size: 16px;line-height: 1.4;"><a href="#" style="text-decoration: underline;color: inherit;">Click here</a> to mute this thread (stop receiving email notifications for this post only).</p> -->
<p class="page__content_text" style="margin: 20px 0 0 0;font-size: 16px;line-height: 1.4;"><a href="<%= post.url %>/mute" style="text-decoration: underline;color: inherit;">Click here</a> to mute this thread (stop receiving email notifications for this post only).</p>
<p class="page__content_text" style="margin: 5px 0 0 0;font-size: 16px;line-height: 1.4;"><a href="http://www.libertysoil.com/settings/email" style="text-decoration: underline;color: inherit;">Change email frequency</a> in your account settings.</p>
</td>
</tr>
Expand Down
20 changes: 20 additions & 0 deletions src/store/current-user.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const initialState = i.Map({
liked_schools: i.Map({}),
liked_geotags: i.Map({}),
suggested_users: i.List([]),
post_subscriptions: i.List([]),
recent_tags: i.Map({
hashtags: i.List([]),
schools: i.List([]),
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function reducer(state = initialState, action) {
.set('liked_hashtags', i.Map({}))
.set('liked_schools', i.Map({}))
.set('liked_geotags', i.Map({}))
.set('post_subscriptions', i.List([]))
.set('suggested_users', i.List([]))
.set('recent_tags', i.fromJS({ hashtags: [], schools: [], geotags: [] }));
});
Expand All @@ -72,6 +74,7 @@ export default function reducer(state = initialState, action) {
const likedHashtags = _.keyBy(action.user.liked_hashtags, 'name');
const likedSchools = _.keyBy(action.user.liked_schools, 'url_name');
const likedGeotags = _.keyBy(action.user.liked_geotags, 'url_name');
const postSubscriptions = _.map(action.user.post_subscriptions, 'id');

state = state.withMutations(state => {
state.set('followed_hashtags', i.fromJS(followedTags));
Expand All @@ -80,6 +83,7 @@ export default function reducer(state = initialState, action) {
state.set('liked_hashtags', i.fromJS(likedHashtags));
state.set('liked_schools', i.fromJS(likedSchools));
state.set('liked_geotags', i.fromJS(likedGeotags));
state.set('post_subscriptions', i.fromJS(postSubscriptions));
});
}

Expand Down Expand Up @@ -195,6 +199,22 @@ export default function reducer(state = initialState, action) {

break;
}

case a.users.SUBSCRIBE_TO_POST: {
state = state.updateIn(['post_subscriptions'], val => {
return val.push(action.post_id);
});

break;
}

case a.users.UNSUBSCRIBE_FROM_POST: {
state = state.updateIn(['post_subscriptions'], val => {
return val.delete(val.indexOf(action.post_id));
});

break;
}
}

return state;
Expand Down
1 change: 1 addition & 0 deletions src/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const initialState = i.Map({
liked_hashtags: i.Map({}),
liked_schools: i.Map({}),
liked_geotags: i.Map({}),
post_subscriptions: i.List([]),
suggested_users: i.List([]),
recent_tags: i.Map({
hashtags: i.List([]),
Expand Down

0 comments on commit 8f7e75e

Please sign in to comment.