diff --git a/api/graphql/filters.js b/api/graphql/filters.js index 4d9408d5f..c92c5ff54 100644 --- a/api/graphql/filters.js +++ b/api/graphql/filters.js @@ -162,12 +162,13 @@ export const postFilter = (userId, isAdmin) => relation => { }) } -// Only can see votes from active posts that are public or are in a group that the person is a member of -export const voteFilter = userId => relation => { +// Only can see reactions from active posts that are public or are in a group that the person is a member of +export const reactionFilter = userId => relation => { return relation.query(q => { - q.join('groups_posts', 'votes.post_id', 'groups_posts.post_id') + q.join('groups_posts', 'reactions.entity_id', 'groups_posts.post_id') q.join('posts', 'posts.id', 'groups_posts.post_id') q.where('posts.active', true) + q.andWhere('reactions.entity_type', 'post') q.andWhere(q2 => { const selectIdsForMember = Group.selectIdsForMember(userId) q.whereIn('groups_posts.group_id', selectIdsForMember) @@ -175,4 +176,3 @@ export const voteFilter = userId => relation => { }) }) } - diff --git a/api/graphql/index.js b/api/graphql/index.js index ee489e25f..cc19f0b7b 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -41,6 +41,7 @@ import { deleteGroupTopic, deletePost, deleteProjectRole, + deleteReaction, deleteSavedSearch, expireInvitation, findOrCreateLinkPreviewByUrl, @@ -60,12 +61,13 @@ import { messageGroupModerators, pinPost, processStripeToken, + reactOn, + reactivateUser, regenerateAccessCode, registerDevice, registerStripeAccount, reinviteAll, rejectGroupRelationshipInvite, - reactivateUser, register, reorderPostInCollection, removeMember, @@ -347,6 +349,8 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { deleteProjectRole: (root, { id }) => deleteProjectRole(userId, id), + deleteReaction: (root, { entityId, data }) => deleteReaction(entityId, userId, data), + deleteSavedSearch: (root, { id }) => deleteSavedSearch(id), expireInvitation: (root, {invitationId}) => @@ -385,6 +389,8 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { processStripeToken: (root, { postId, token, amount }) => processStripeToken(userId, postId, token, amount), + reactOn: (root, { entityId, data }) => reactOn(userId, entityId, data), + reactivateMe: (root) => reactivateUser({ userId }), regenerateAccessCode: (root, { groupId }) => diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index ae176d93b..45c6a13e5 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -10,7 +10,7 @@ import { messageFilter, personFilter, postFilter, - voteFilter + reactionFilter } from './filters' import { LOCATION_DISPLAY_PRECISION } from '../../lib/constants' import InvitationService from '../services/InvitationService' @@ -134,7 +134,7 @@ export default function makeModels (userId, isAdmin, apiClient) { {comments: {querySet: true}}, {skills: {querySet: true}}, {skillsToLearn: {querySet: true}}, - {votes: {querySet: true}} + {reactions: {querySet: true}} ], filter: nonAdminFilter(apiFilter(personFilter(userId))), isDefaultTypeForTable: true, @@ -172,7 +172,8 @@ export default function makeModels (userId, isAdmin, apiClient) { getters: { commenters: (p, { first }) => p.getCommenters(first, userId), commentersTotal: p => p.getCommentersTotal(userId), - myVote: p => userId ? p.userVote(userId).then(v => !!v) : false, + myReactions: p => userId ? p.postReactions(userId).fetch() : [], + myVote: p => userId ? p.userVote(userId).then(v => !!v) : false, // Remove once Mobile has been updated myEventResponse: p => userId && p.isEvent() ? p.userEventInvitation(userId) .then(eventInvitation => eventInvitation ? eventInvitation.get('response') : '') @@ -188,6 +189,7 @@ export default function makeModels (userId, isAdmin, apiClient) { { eventInvitations: { querySet: true } }, 'linkPreview', 'postMemberships', + 'postReactions', { media: { alias: 'attachments', @@ -610,15 +612,19 @@ export default function makeModels (userId, isAdmin, apiClient) { ], relations: [ 'post', - {user: {alias: 'creator'}}, - {childComments: { querySet: true }}, - {media: { - alias: 'attachments', - arguments: ({ type }) => [type] - }} + { user: { alias: 'creator' } }, + { childComments: { querySet: true } }, + { + media: { + alias: 'attachments', + arguments: ({ type }) => [type] + } + } ], getters: { - parentComment: (c) => c.parentComment().fetch() + parentComment: (c) => c.parentComment().fetch(), + myReactions: c => userId ? c.commentReactions(userId).fetch() : [], + commentReactions: c => c.commentReactions().fetch() }, filter: nonAdminFilter(commentFilter(userId)), isDefaultTypeForTable: true @@ -678,16 +684,33 @@ export default function makeModels (userId, isAdmin, apiClient) { filter: messageFilter(userId) }, - Vote: { - model: Vote, + Reaction: { + model: Reaction, + getters: { + createdAt: r => r.get('date_reacted'), + emojiBase: r => r.get('emoji_base'), + emojiFull: r => r.get('emoji_full'), + emojiLabel: r => r.get('emoji_label'), + entityId: r => r.get('entity_id'), + entityType: r => r.get('entity_type') + }, + isDefaultTypeForTable: true, + relations: [ + 'post', + 'user' + ], + filter: nonAdminFilter(reactionFilter('reactions', userId)) + }, + Vote: { // TO BE REMOVED ONCE MOBILE IS UPDATED + model: Reaction, getters: { - createdAt: v => v.get('date_voted') + createdAt: v => v.get('date_reacted') }, relations: [ 'post', - {user: {alias: 'voter'}} + { user: { alias: 'voter' } } ], - filter: nonAdminFilter(voteFilter('votes', userId)) + filter: nonAdminFilter(reactionFilter('reactions', userId)) }, GroupTopic: { diff --git a/api/graphql/mutations/index.js b/api/graphql/mutations/index.js index f6cd0307c..0b7224493 100644 --- a/api/graphql/mutations/index.js +++ b/api/graphql/mutations/index.js @@ -303,6 +303,32 @@ export function messageGroupModerators (userId, groupId) { return Group.messageModerators(userId, groupId) } +export function reactOn (userId, entityId, data) { + const lookUp = { + post: Post, + comment: Comment + } + const { entityType } = data + if (!['post', 'comment'].includes(entityType)) { + throw new Error('entityType invalid: you need to say its a post or a comment') + } + return lookUp[entityType].find(entityId) + .then(entity => entity.reaction(userId, data)) +} + +export function deleteReaction (entityId, userId, data) { + const lookUp = { + post: Post, + comment: Comment + } + const { entityType } = data + if (!['post', 'comment'].includes(entityType)) { + throw new Error('entityType invalid: you need to say its a post or a comment') + } + return lookUp[entityType].find(entityId) + .then(entity => entity.deleteReaction(userId, data)) +} + export async function removePost (userId, postId, groupIdOrSlug) { const group = await Group.find(groupIdOrSlug) return Promise.join( diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index a30052075..94d283471 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -38,11 +38,10 @@ export function unfulfillPost (userId, postId) { .then(() => ({success: true})) } -export function vote (userId, postId, isUpvote) { +export function vote (userId, postId, isUpvote) { // TODO: remove after mobile brought back into sync return Post.find(postId) .then(post => post.vote(userId, isUpvote)) } - export function deletePost (userId, postId) { return Post.find(postId) .then(post => { diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 713cf9588..f5bec5064 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -437,6 +437,12 @@ type Comment { post: Post # The content of the comment text: String + # Not used + commentReactionsTotal: Int + # All reactions on a comment + commentReactions: [Reaction] + # The reactions of the logged-in user to this + myReactions: [Reaction] } # Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set @@ -1432,6 +1438,7 @@ type Person { offset: Int, # Determines sort order, either 'ASC' or 'DESC' order: String, + # Only load posts whose content matches this search string search: String, # Sort posts by 'created', 'start_time', 'updated', 'votes' @@ -1447,6 +1454,9 @@ type Person { # Project posts that this person is a member of projects: PostQuerySet + # This person's reactions + reactions(first: Int, offset: Int, order: String): ReactionQuerySet + # The set of Skills this person has added to their profile skills(first: Int, cursor: ID): SkillQuerySet @@ -1517,8 +1527,13 @@ type Post { locationObject: Location # Logged in user's attendance response to an event post: 'yes', 'maybe' or 'no' myEventResponse: String - # Whether the logged in user has upvoted the post or not + # The reactions of the logged-in user to this + myReactions: [Reaction] + # Whether the logged-in user has upvoted the post or not myVote: Boolean + # Number of people that have one or more reactions to a post + peopleReactedTotal: Int + postReactionsTotal: Int # Number of total "members" of the post. Used for Projects only right now postMembershipsTotal: Int # Link to a project management tool or service. Only used for Projects right now @@ -1567,6 +1582,9 @@ type Post { # The post's "memberships" in the groups it has been posted in postMemberships: [PostMembership] + # reactions to this post + postReactions: [Reaction] + # The topics that have been added to this post topics: [Topic] } @@ -1607,6 +1625,38 @@ type Question { text: String } +# Emoji reactions to posts and comments +type Reaction { + # when the reaction happened + createdAt: Date + # Just the basic emoji, no modifiers. UTF-16 (JS default) encoding for an emojis code-points (like '\uD83D\uDE00') + emojiBase: String + # The full emoji, with any modifiers (eg: for skin). UTF-16 (JS default) encoding for an emojis code-points (like '\uD83D\uDE00') + emojiFull: String + # The EN locale string for an emoji, like :thumbs_up: + emojiLabel: String + # id for the entity associated with the reactions + entityId: ID + # What type of entity a reaction is associated with. Currently only 'post' or 'comment' + entityType: String + # id for the reaction + id: ID + # id for the user who reacted + userId: ID + + # The post associated with a reaction + post: Post + # The user associated with a reaction + user: Person +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type ReactionQuerySet { + total: Int + hasMore: Boolean + items: [Reaction] +} + # A saved set of search/filter parameters for the map that can be reloaded later by a user type SavedSearch { id: ID @@ -1840,6 +1890,8 @@ type Mutation { deletePost(id: ID): GenericResult # Remove a person as a member from a project Post deleteProjectRole(id: ID): GenericResult + # Delete a reaction you created + deleteReaction(entityId: ID, data: ReactionInput): Post # Delete a SavedSearch you created deleteSavedSearch(id: ID): ID # Set an invitation to join a group as expired. Only a group moderator can call this. @@ -1880,6 +1932,8 @@ type Mutation { pinPost(postId: ID, groupId: ID): GenericResult # NOT USED RIGHT NOW processStripeToken(postId: ID, token: String, amount: Int): GenericResult + # react to a post or comment + reactOn(entityId: ID, data: ReactionInput): Post # For a deactivated user to reactivate their account. reactivateMe(id: ID): GenericResult # Regenerate the invite URL used to invite people to a Group. Only a group moderator can call this. @@ -2461,6 +2515,18 @@ input QuestionAnswerInput { questionId: Int } +# Inputs for emoji reactions to posts and comments +input ReactionInput { + # when the reaction happened + createdAt: Date + # The full emoji, with any modifiers (eg: for skin). UTF-16 (JS default) encoding for an emojis code-points (like '\uD83D\uDE00') + emojiFull: String + # id for the entity associated with the reactions + entityId: ID + # What type of entity a reaction is associated with. Currently only 'post' or 'comment' + entityType: String +} + # A SavedSearch on the Map input SavedSearchInput { # The geographic bounding box diff --git a/api/models/Comment.js b/api/models/Comment.js index 5ba1fc39a..9c492ea2c 100644 --- a/api/models/Comment.js +++ b/api/models/Comment.js @@ -1,8 +1,12 @@ +import data from '@emoji-mart/data' +import { init, getEmojiDataFromNative } from 'emoji-mart' import { TextHelpers } from 'hylo-shared' import { notifyAboutMessage, sendDigests } from './comment/notifications' import EnsureLoad from './mixins/EnsureLoad' import * as RichText from '../services/RichText' +init({ data }) + module.exports = bookshelf.Model.extend(Object.assign({ tableName: 'comments', requireFetch: false, @@ -33,6 +37,12 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.relations.post.relations.groups.first() }, + commentReactions: function (userId) { + return userId + ? this.hasMany(Reaction, 'entity_id').where({ 'reactions.entity_type': 'comment', 'reactions.user_id': userId }) + : this.hasMany(Reaction, 'entity_id').where('reactions.entity_type', 'comment') + }, + tags: function () { return this.belongsToMany(Tag).through(CommentTag) }, @@ -45,6 +55,31 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.belongsTo(Comment).where('comments.active', true) }, + deleteReaction: function (userId, data) { + return this.commentReactions(userId).fetch() + .then(userReactionsModels => bookshelf.transaction(async trx => { + const userReactions = userReactionsModels.models + const userReaction = userReactions.filter(reaction => reaction.attributes?.emoji_full === data.emojiFull)[0] + + return userReaction.destroy({ transacting: trx }) + })) + }, + + reaction: async function (userId, data) { + const { emojiFull } = data + const emojiObject = await getEmojiDataFromNative(emojiFull) + + return new Reaction({ + entity_id: this.id, + user_id: userId, + emoji_base: emojiFull, + emoji_full: emojiFull, + entity_type: 'comment', + emoji_label: emojiObject.shortcodes + }).save() + .then(() => this) + }, + childComments: function () { return this.hasMany(Comment, 'comment_id').query({where: {'comments.active': true}}) }, diff --git a/api/models/FlaggedItem.js b/api/models/FlaggedItem.js index 3c204d3d9..9f6db2b59 100644 --- a/api/models/FlaggedItem.js +++ b/api/models/FlaggedItem.js @@ -41,7 +41,7 @@ module.exports = bookshelf.Model.extend({ return Frontend.Route.post(this.get('object_id'), group, isPublic) case FlaggedItem.Type.COMMENT: const comment = await this.getObject() - return Frontend.Route.comment(comment, group, isPublic) + return Frontend.Route.comment({ comment, group }) case FlaggedItem.Type.MEMBER: return Frontend.Route.profile(this.get('object_id')) default: diff --git a/api/models/Notification.js b/api/models/Notification.js index c4629a674..1a0d42113 100644 --- a/api/models/Notification.js +++ b/api/models/Notification.js @@ -437,7 +437,7 @@ module.exports = bookshelf.Model.extend({ post_label: postLabel, post_title: title, comment_url: Frontend.Route.tokenLogin(reader, token, - Frontend.Route.post(post, group) + '?ctt=comment_email&cti=' + reader.id + `#comment-${comment.id}`), + Frontend.Route.comment({ comment, groupSlug: group.get('slug'), post })), unfollow_url: Frontend.Route.tokenLogin(reader, token, Frontend.Route.unfollow(post, group)), tracking_pixel_url: Analytics.pixelUrl('Comment', {userId: reader.id}) diff --git a/api/models/Post.js b/api/models/Post.js index 9bf124fa2..f56400dfd 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -1,4 +1,6 @@ /* globals _ */ +import data from '@emoji-mart/data' +import { init, getEmojiDataFromNative } from 'emoji-mart' import { difference, filter, isNull, omitBy, uniqBy, isEmpty, intersection, isUndefined, pick } from 'lodash/fp' import { flatten, sortBy } from 'lodash' import { postRoom, pushToSockets } from '../services/Websockets' @@ -10,6 +12,8 @@ import ProjectMixin from './project/mixin' import EventMixin from './event/mixin' import * as RichText from '../services/RichText' +init({ data }) + export const POSTS_USERS_ATTR_UPDATE_WHITELIST = [ 'project_role_id', 'following', @@ -73,8 +77,12 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.get('num_comments') }, + peopleReactedTotal: function () { + return this.get('num_people_reacts') + }, + votesTotal: function () { - return this.get('num_votes') + return this.get('num_people_reacts') }, // Relations @@ -159,7 +167,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ // should only be one of these per post selectedTags: function () { return this.belongsToMany(Tag).through(PostTag).withPivot('selected') - .query({where: {selected: true}}) + .query({ where: { selected: true } }) }, tags: function () { @@ -170,18 +178,24 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.belongsTo(User) }, + postReactions: function (userId) { + return userId + ? this.hasMany(Reaction, 'entity_id').where({ 'reactions.entity_type': 'post', 'reactions.user_id': userId }) + : this.hasMany(Reaction, 'entity_id').where('reactions.entity_type', 'post') + }, + userVote: function (userId) { - return this.votes().query({where: {user_id: userId}}).fetchOne() + return this.votes().query({ where: { user_id: userId, entity_type: 'post' } }).fetchOne() }, votes: function () { - return this.hasMany(Vote) + return this.hasMany(Reaction, 'entity_id').where('reactions.entity_type', 'post') }, // TODO: this is confusing and we are not using, remove for now? children: function () { return this.hasMany(Post, 'parent_post_id') - .query({where: {active: true}}) + .query({ where: { active: true } }) }, parent: function () { @@ -224,8 +238,8 @@ module.exports = bookshelf.Model.extend(Object.assign({ return Object.assign({}, refineOne( this, - [ 'created_at', 'description', 'id', 'name', 'num_votes', 'type', 'updated_at' ], - { 'description': 'details', 'name': 'title', 'num_votes': 'votesTotal' } + ['created_at', 'description', 'id', 'name', 'num_people_reacts', 'type', 'updated_at', 'num_votes'], + { description: 'details', name: 'title', num_people_reacts: 'peopleReactedTotal', num_votes: 'votesTotal' } ), { // Shouldn't have commenters immediately after creation @@ -357,7 +371,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ reader_id: eventInvitation.get('user_id'), post_id: this.id, actor_id: eventInvitation.get('inviter_id'), - reason: `eventInvitation` + reason: 'eventInvitation' })) let members = await Promise.all(groups.map(async group => { @@ -396,21 +410,76 @@ module.exports = bookshelf.Model.extend(Object.assign({ fulfill, unfulfill, - + // TODO: Need to remove this once mobile has been updated vote: function (userId, isUpvote) { - return this.votes().query({where: {user_id: userId}}).fetchOne() - .then(vote => bookshelf.transaction(trx => { - var inc = delta => () => - this.save({num_votes: this.get('num_votes') + delta}, {transacting: trx}) - - return (vote && !isUpvote - ? vote.destroy({transacting: trx}).then(inc(-1)) - : isUpvote && new Vote({ - post_id: this.id, - user_id: userId - }).save().then(inc(1))) - })) - .then(() => this) + return this.postReactions().query({ where: { user_id: userId, emoji_full: '\uD83D\uDC4D' } }).fetchOne() + .then(reaction => bookshelf.transaction(trx => { + const inc = delta => async () => { + const reactionsSummary = await this.get('reactions_summary') + this.save({ num_people_reacts: this.get('num_people_reacts') + delta, reactions_summary: { ...reactionsSummary, '\uD83D\uDC4D': reactionsSummary['\uD83D\uDC4D'] + delta } }) + } + + return (reaction && !isUpvote + ? reaction.destroy({ transacting: trx }).then(inc(-1)) + : isUpvote && new Reaction({ + entity_id: this.id, + user_id: userId, + emoji_base: '\uD83D\uDC4D', + emoji_full: '\uD83D\uDC4D', + entity_type: 'post', + emoji_label: ':thumbs up:' + }).save().then(inc(1))) + })) + .then(() => this) + }, + + deleteReaction: function (userId, data) { + return this.postReactions(userId).fetch() + .then(userReactionsModels => bookshelf.transaction(async trx => { + const userReactions = userReactionsModels.models + const isLastReaction = userReactions.length === 1 + const userReaction = userReactions.filter(reaction => reaction.attributes?.emoji_full === data.emojiFull)[0] + const { emojiFull } = data + + const cleanUp = () => { + const reactionsSummary = this.get('reactions_summary') + const reactionCount = reactionsSummary[emojiFull] || 0 + if (isLastReaction) { + return this.save({ num_people_reacts: this.get('num_people_reacts') - 1, reactions_summary: { ...reactionsSummary, [emojiFull]: reactionCount - 1 } }, { transacting: trx }) + } else { + const reactionsSummary = this.get('reactions_summary') + return this.save({ reactions_summary: { ...reactionsSummary, [emojiFull]: reactionCount - 1 } }, { transacting: trx }) + } + } + + return userReaction.destroy({ transacting: trx }) + .then(cleanUp) + })) + }, + + reaction: function (userId, data) { + return this.postReactions(userId).fetch() + .then(userReactions => bookshelf.transaction(async trx => { + + const delta = userReactions?.models?.length > 0 ? 0 : 1 + const reactionsSummary = this.get('reactions_summary') || {} + const { emojiFull } = data + const emojiObject = await getEmojiDataFromNative(emojiFull) + const reactionCount = reactionsSummary[emojiFull] || 0 + const inc = () => { + return this.save({ num_people_reacts: this.get('num_people_reacts') + delta, reactions_summary: { ...reactionsSummary, [emojiFull]: reactionCount + delta } }, { transacting: trx }) + } + + return new Reaction({ + entity_id: this.id, + user_id: userId, + emoji_base: emojiFull, + emoji_full: emojiFull, + entity_type: 'post', + emoji_label: emojiObject.shortcodes + }).save().then(inc()) + })) + .then(() => this) }, removeFromGroup: function (idOrSlug) { @@ -506,7 +575,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ updated_at: new Date(), active: true, num_comments: 0, - num_votes: 0 + num_people_reacts: 0 }), create: function (attrs, opts) { diff --git a/api/models/Reaction.js b/api/models/Reaction.js new file mode 100644 index 000000000..f1e412dee --- /dev/null +++ b/api/models/Reaction.js @@ -0,0 +1,27 @@ +module.exports = bookshelf.Model.extend({ + tableName: 'reactions', + requireFetch: false, + + post: function () { + return this.belongsTo(Post, 'entity_id').where('reactions.entity_type', 'post') + }, + comment: function () { + return this.belongsTo(Comment, 'entity_id').where('reactions.entity_type', 'comment') + }, + user: function () { + return this.belongsTo(User, 'user_id') + } + +}, { + + /** + * @param userId User ID to check which posts they reacted to + * @param postIds List of Post ID's to check against + * @returns a list of Reactions. + */ + forUserInPosts: function (userId, postIds) { + return bookshelf.knex('reactions').where({ + user_id: userId + }).whereIn('entity_id', postIds) + } +}) diff --git a/api/models/User.js b/api/models/User.js index bc6bfdb43..bb905dce4 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -188,8 +188,8 @@ module.exports = bookshelf.Model.extend(merge({ return this.belongsTo(StripeAccount) }, - votes: function () { - return this.hasMany(Vote) + reactions: function () { + return this.hasMany(Reaction) }, postUsers: function () { @@ -298,7 +298,7 @@ module.exports = bookshelf.Model.extend(merge({ DELETE FROM user_external_data WHERE user_id = ${this.id}; DELETE FROM user_post_relevance WHERE user_id = ${this.id}; DELETE FROM posts_tags WHERE post_id in (select id from posts WHERE user_id = ${this.id}); - DELETE FROM votes WHERE user_id = ${this.id}; + DELETE FROM reactions WHERE user_id = ${this.id}; UPDATE users SET active = false, diff --git a/api/models/Vote.js b/api/models/Vote.js deleted file mode 100644 index 1dbb5e115..000000000 --- a/api/models/Vote.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = bookshelf.Model.extend({ - tableName: 'votes', - requireFetch: false, - - post: function() { - return this.belongsTo(Post, 'post_id'); - }, - - user: function() { - return this.belongsTo(User, "user_id") - } - -}, { - - /** - * @param userId User ID to check which posts they voted on - * @param postIds List of Post ID's to check against - * @returns a list of Vote's. - */ - forUserInPosts: function(userId, postIds) { - return bookshelf.knex("votes").where({ - user_id: userId - }).whereIn("post_id", postIds); - } -}); diff --git a/api/models/comment/notifications.js b/api/models/comment/notifications.js index d1b636174..2513fd4d6 100644 --- a/api/models/comment/notifications.js +++ b/api/models/comment/notifications.js @@ -119,7 +119,7 @@ export const sendDigests = async () => { count: commentData.length, post_title: TextHelpers.truncateText(post.get('name'), 140), post_creator_avatar_url: post.relations.user.get('avatar_url'), - thread_url: Frontend.Route.post(post), + thread_url: Frontend.Route.comment({ comment: commentData[0], post }), comments: commentData, subject_prefix: some(hasMention, commentData) ? 'You were mentioned in' diff --git a/api/services/Frontend.js b/api/services/Frontend.js index ce1982f0b..6dd568166 100644 --- a/api/services/Frontend.js +++ b/api/services/Frontend.js @@ -67,15 +67,12 @@ module.exports = { root: () => url('/app'), - comment: function (comment, group) { - // TODO: update to use comment specific url when implemented in frontend - let groupSlug = getSlug(group) + comment: function ({ comment, groupSlug, post }) { let groupUrl = isEmpty(groupSlug) ? '/all' : `/groups/${groupSlug}` - const postId = comment.relations.post.id - - return url(`${groupUrl}/post/${postId}`) + const postId = comment?.relations?.post?.id || post.id + return url(`${groupUrl}/post/${postId}/comments/${comment.id}`) }, group: function (group) { diff --git a/api/services/Search/util.js b/api/services/Search/util.js index 8d9fda5cb..b902a2f71 100644 --- a/api/services/Search/util.js +++ b/api/services/Search/util.js @@ -25,7 +25,7 @@ export const filterAndSortPosts = curry((opts, q) => { let { topics = [] } = opts const sortColumns = { - votes: 'posts.num_votes', + votes: 'posts.num_people_reacts', // Need to remove once Mobile has been ported to reactions updated: 'posts.updated_at', created: 'posts.created_at', start_time: 'posts.start_time', diff --git a/api/services/UserManagement.js b/api/services/UserManagement.js index 5e5472c99..b56cf474b 100644 --- a/api/services/UserManagement.js +++ b/api/services/UserManagement.js @@ -39,6 +39,7 @@ const generateMergeQueries = function (userId, duplicateUserId, knex) { ['group_invites', 'invited_by_id'], ['group_invites', 'used_by_id'], ['user_external_data', 'user_id'], + ['reactions', 'user_id'] ].forEach(args => { var table = args[0] var userCol = args[1] @@ -52,7 +53,6 @@ const generateMergeQueries = function (userId, duplicateUserId, knex) { ['contributions', 'user_id', 'post_id'], ['follows', 'user_id', 'post_id'], ['linked_account', 'user_id', 'provider_user_id'], - ['votes', 'user_id', 'post_id'], ['tag_follows', 'user_id', 'tag_id'], ['groups_tags', 'user_id', 'tag_id'], ['thanks', 'thanked_by_id', 'comment_id'], @@ -112,7 +112,7 @@ const generateRemoveQueries = function (userId, knex) { ['user_external_data', 'user_id'], ['user_post_relevance', 'user_id'], ['users', 'id'], - ['votes', 'user_id'] + ['reactions', 'user_id'] ].forEach(args => { var table = args[0] var userCol = args[1] diff --git a/lib/graphql-bookshelf-bridge/util/applyPagination.js b/lib/graphql-bookshelf-bridge/util/applyPagination.js index 6fec3d547..98b3d6262 100644 --- a/lib/graphql-bookshelf-bridge/util/applyPagination.js +++ b/lib/graphql-bookshelf-bridge/util/applyPagination.js @@ -16,7 +16,7 @@ export default function applyPagination (query, tableName, opts) { } // skip special sorts - if (!['join', 'votes', 'updated', 'created'].includes(sortBy)) { + if (!['join', 'votes', 'updated', 'created'].includes(sortBy)) { // Need to change this to reactions once Mobile has been ported query = query.orderBy(snakeCase(sortBy), order) } diff --git a/lib/util/normalize.js b/lib/util/normalize.js index 87803bde8..f64f4de10 100644 --- a/lib/util/normalize.js +++ b/lib/util/normalize.js @@ -28,7 +28,7 @@ export const normalizePost = (post, buckets, final) => { if (!post) return const { groups, people } = buckets convertList(groups, post, 'group') - convertList(people, post, 'voter') + convertList(people, post, 'voter') // TODO: not really sure what this voter stuff is doing convertList(people, post, 'follower') convertItem(people, post, 'user') if (post.comments) { diff --git a/migrations/20220913092444_convert_votes_to_reactions.js b/migrations/20220913092444_convert_votes_to_reactions.js new file mode 100644 index 000000000..01119e644 --- /dev/null +++ b/migrations/20220913092444_convert_votes_to_reactions.js @@ -0,0 +1,66 @@ + +exports.up = async function (knex) { + await knex.raw('alter table votes drop constraint if exists fk_vote_post_14') + await knex.raw('alter table votes drop constraint if exists uq_vote_1') + await knex.schema.renameTable('votes', 'reactions') + + await knex.schema.table('reactions', table => { + table.text('emoji_base') + table.text('emoji_full') + table.text('emoji_label') + table.text('entity_type') + table.renameColumn('post_id', 'entity_id') + table.renameColumn('date_voted', 'date_reacted') + table.index('emoji_base', 'idx_reactions_emoji_full') + table.index('entity_type', 'idx_reactions_entity_type') + table.index('entity_id', 'idx_reactions_entity_id') + }) + + await knex.raw('DROP INDEX ix_vote_post_14') + + // migrate prior data + // 'U+1F44D' but encoded in UTF-16 (because javascript) => '\uD83D\uDC4D', thumbs up emoji, :thumbs up: + await knex('reactions').update({ + emoji_base: '\uD83D\uDC4D', + emoji_full: '\uD83D\uDC4D', + entity_type: 'post', + emoji_label: ':thumbs up:' + }) + + await knex.schema.table('posts', table => { + table.jsonb('reactions_summary') + table.renameColumn('num_votes', 'num_people_reacts') + }) + + const existingCounts = await knex.raw('select id, num_people_reacts from posts') + + return Promise.all( + existingCounts.rows.map(({id, num_people_reacts}) => knex.raw( + `update posts set reactions_summary = '{"\uD83D\uDC4D": ${num_people_reacts}}' where id = ${id}` + )) + ) +} + +exports.down = async function (knex) { + await knex.schema.table('reactions', table => { + table.dropColumn('geo_shape') + table.dropColumn('emoji_base') + table.dropColumn('emoji_full') + table.dropColumn('emoji_label') + table.dropColumn('entity_type') + table.renameColumn('entity_id', 'post_id') + + table.dropIndex('idx_reactions_emoji_full') + table.dropIndex('idx_reactions_entity_type') + table.index('post_id', 'ix_vote_post_14') + table.dropIndex('idx_reactions_entity_id') + }) + + await knex.schema.table('posts', table => { + table.dropColumn('reactions_summary') + table.renameColumn('num_people_reacts', 'num_votes') + table.renameColumn('date_reacted', 'date_voted') + }) + + return await knex.schema.renameTable('reactions', 'votes') +} diff --git a/migrations/20221030120533_add-general-topic.js b/migrations/20221030120533_add-general-topic.js new file mode 100644 index 000000000..44642c518 --- /dev/null +++ b/migrations/20221030120533_add-general-topic.js @@ -0,0 +1,8 @@ + +exports.up = async function (knex) { + await knex.raw(`insert into tags (name) values ('general') ON CONFLICT DO NOTHING`) +} + +exports.down = function (knex) { + +} diff --git a/migrations/schema.sql b/migrations/schema.sql index 18dcb55ca..d01963e2d 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -1641,7 +1641,7 @@ CREATE TABLE public.posts ( type character varying(255), created_at timestamp with time zone, user_id bigint, - num_votes integer, + num_people_reacts integer, num_comments integer, active boolean, deactivated_by_id bigint, @@ -2412,17 +2412,20 @@ CREATE SEQUENCE public.vote_seq -- --- Name: votes; Type: TABLE; Schema: public; Owner: - +-- Name: reactions; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.votes ( +CREATE TABLE public.reactions ( id bigint DEFAULT nextval('public.vote_seq'::regclass) NOT NULL, user_id bigint, - post_id bigint, - date_voted timestamp with time zone + entity_id bigint, + date_reacted timestamp with time zone, + emoji_base text, + emoji_full text, + emoji_label text, + entity_type text ); - -- -- Name: widgets; Type: TABLE; Schema: public; Owner: - -- @@ -3325,10 +3328,8 @@ ALTER TABLE ONLY public.user_post_relevance -- Name: votes pk_vote; Type: CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.votes +ALTER TABLE ONLY public.reactions ADD CONSTRAINT pk_vote PRIMARY KEY (id); - - -- -- Name: groups_posts post_community_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3576,15 +3577,6 @@ ALTER TABLE ONLY public.thanks ALTER TABLE ONLY public.group_invites ADD CONSTRAINT uq_no_multiple_tokens UNIQUE (token); - --- --- Name: votes uq_vote_1; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.votes - ADD CONSTRAINT uq_vote_1 UNIQUE (user_id, post_id); - - -- -- Name: user_affiliations user_affiliations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3655,6 +3647,26 @@ ALTER TABLE ONLY public.widgets CREATE INDEX communities_tags_community_id_visibility_index ON public.groups_tags USING btree (community_id, visibility); +-- +-- Name: idx_reactions_emoji_full; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reactions_emoji_full ON public.reactions USING btree (emoji_base); + + +-- +-- Name: idx_reactions_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reactions_entity_id ON public.reactions USING btree (entity_id); + + +-- +-- Name: idx_reactions_entity_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reactions_entity_type ON public.reactions USING btree (entity_type); + -- -- Name: fk_community_created_by_1; Type: INDEX; Schema: public; Owner: - @@ -3863,14 +3875,14 @@ CREATE INDEX ix_thank_you_user_2 ON public.thanks USING btree (user_id); -- Name: ix_vote_post_14; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX ix_vote_post_14 ON public.votes USING btree (post_id); +CREATE INDEX ix_vote_post_14 ON public.reactions USING btree (entity_id); -- -- Name: ix_vote_user_13; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX ix_vote_user_13 ON public.votes USING btree (user_id); +CREATE INDEX ix_vote_user_13 ON public.reactions USING btree (user_id); -- @@ -4444,15 +4456,15 @@ ALTER TABLE ONLY public.communities_users -- Name: votes fk_vote_post_14; Type: FK CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.votes - ADD CONSTRAINT fk_vote_post_14 FOREIGN KEY (post_id) REFERENCES public.posts(id) DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE ONLY public.reactions + ADD CONSTRAINT fk_vote_post_14 FOREIGN KEY (entity_id) REFERENCES public.posts(id) DEFERRABLE INITIALLY DEFERRED; -- -- Name: votes fk_vote_user_13; Type: FK CONSTRAINT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.votes +ALTER TABLE ONLY public.reactions ADD CONSTRAINT fk_vote_user_13 FOREIGN KEY (user_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; diff --git a/package.json b/package.json index 6779d772a..7ce9fddc2 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@babel/core": "^7.17.9", "@babel/preset-env": "^7.16.11", "@babel/register": "^7.17.7", + "@emoji-mart/data": "^1.0.6", "@envelop/core": "^2.6.0", "@graphql-yoga/node": "^2.13.13", "@mapbox/mapbox-sdk": "^0.13.3", @@ -87,6 +88,7 @@ "dataloader": "^1.2.0", "dotenv": "^0.4.0", "ejs": "^2.5.5", + "emoji-mart": "^5.2.2", "ent": "^2.2.0", "file-type": "^6.1.0", "gaze": "^1.1.3", @@ -222,6 +224,7 @@ "ProjectContributions", "PushNotification", "Queue", + "Reaction", "RedisClient", "RequestValidation", "RichText", @@ -236,8 +239,7 @@ "UserExternalData", "UserSession", "UserVerificationCode", - "Validation", - "Vote" + "Validation" ] }, "nodemonConfig": { diff --git a/test/unit/models/FlaggedItem.test.js b/test/unit/models/FlaggedItem.test.js index bf941e805..76b9a7d98 100644 --- a/test/unit/models/FlaggedItem.test.js +++ b/test/unit/models/FlaggedItem.test.js @@ -183,7 +183,8 @@ describe('FlaggedItem', () => { link: 'www.hylo.com/post/1' }) const link = await flaggedItem.getContentLink(group) - expect(link).to.equal(Frontend.Route.post(commentParent.id, group)) + + expect(link).to.equal(Frontend.Route.comment({ post: commentParent, group, comment })) }) it('throws an error when object_type is bad', () => { diff --git a/yarn.lock b/yarn.lock index 91eb3381a..be2bdb0a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1056,6 +1056,11 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@emoji-mart/data@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.0.6.tgz#68f71be5e023653a3f9f73f4beadfd50848e2131" + integrity sha512-8wu3ec/kLCB0Y3K+pOKyY6Ob+xtQu3XhZvntdrpOTUQZ/PO6FW5PpFw7RE1kQ/up1fsVSJBl5mZ8Gs4SPwTYeg== + "@envelop/core@^2.5.0", "@envelop/core@^2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@envelop/core/-/core-2.6.0.tgz#1b7a346a37040c217f0f9b60c3358efc6c3b1b94" @@ -4888,6 +4893,11 @@ electron-to-chromium@^1.4.251: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.276.tgz#17837b19dafcc43aba885c4689358b298c19b520" integrity sha512-EpuHPqu8YhonqLBXHoU6hDJCD98FCe6KDoet3/gY1qsQ6usjJoHqBH2YIVs8FXaAtHwVL8Uqa/fsYao/vq9VWQ== +emoji-mart@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.2.2.tgz#1c093ffc19554dd6edfcfeec9aca43ff38bcc16e" + integrity sha512-BvcrX+Ps9MxSVEjnvxupclU3MBD6WVC4WZOY26csfC6oFdaWpFhdrzeVNVBmCLPOmzY1SE0aAsqZJRNVbZ1yhQ== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"