diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fc3148a810..d18d91fa9a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,6 +14,7 @@ - hermes - chronos - mobile +- analytics **Run database migrations (delete if no migration was added)** YES diff --git a/analytics/models/channel.js b/analytics/models/channel.js index 6dc14f7696..d01033b51d 100644 --- a/analytics/models/channel.js +++ b/analytics/models/channel.js @@ -1,6 +1,6 @@ // @flow import type { DBChannel } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; export const getChannelById = (channelId: string): Promise => { return db diff --git a/analytics/models/community.js b/analytics/models/community.js index 7ec4629231..8e635e411d 100644 --- a/analytics/models/community.js +++ b/analytics/models/community.js @@ -1,6 +1,6 @@ // @flow import type { DBCommunity } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; export const getCommunityById = (communityId: string): Promise => { return db diff --git a/analytics/models/db.js b/analytics/models/db.js deleted file mode 100644 index 6b2cbf9109..0000000000 --- a/analytics/models/db.js +++ /dev/null @@ -1,31 +0,0 @@ -// @flow -/** - * Database setup is done here - */ -const IS_PROD = !process.env.FORCE_DEV && process.env.NODE_ENV === 'production'; - -const DEFAULT_CONFIG = { - db: 'spectrum', - max: 20, // Maximum number of connections, default is 1000 - buffer: 1, // Minimum number of connections open at any given moment, default is 50 - timeoutGb: 60 * 1000, // How long should an unused connection stick around, default is an hour, this is a minute -}; - -const PRODUCTION_CONFIG = { - password: process.env.AWS_RETHINKDB_PASSWORD, - host: process.env.AWS_RETHINKDB_URL, - port: process.env.AWS_RETHINKDB_PORT, -}; - -const config = IS_PROD - ? { - ...DEFAULT_CONFIG, - ...PRODUCTION_CONFIG, - } - : { - ...DEFAULT_CONFIG, - }; - -var r = require('rethinkdbdash')(config); - -module.exports = { db: r }; diff --git a/analytics/models/message.js b/analytics/models/message.js index cad951c251..ce1b77d6cc 100644 --- a/analytics/models/message.js +++ b/analytics/models/message.js @@ -1,6 +1,6 @@ // @flow import type { DBMessage } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; export const getMessageById = (messageId: string): Promise => { return db diff --git a/analytics/models/notification.js b/analytics/models/notification.js index 7c700bb890..a3ded7904a 100644 --- a/analytics/models/notification.js +++ b/analytics/models/notification.js @@ -1,6 +1,6 @@ // @flow import type { DBNotification } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; export const getNotificationById = ( notificationId: string diff --git a/analytics/models/reaction.js b/analytics/models/reaction.js index 897b3f6182..2cb72bed53 100644 --- a/analytics/models/reaction.js +++ b/analytics/models/reaction.js @@ -1,6 +1,6 @@ // @flow import type { DBReaction, DBThreadReaction } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; export const getReactionById = (reactionId: string): Promise => { return db diff --git a/analytics/models/thread.js b/analytics/models/thread.js index 883a331354..38cc1cb06b 100644 --- a/analytics/models/thread.js +++ b/analytics/models/thread.js @@ -1,6 +1,6 @@ // @flow import type { DBThread } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; export const getThreadById = (threadId: string): Promise => { return db diff --git a/analytics/models/usersChannels.js b/analytics/models/usersChannels.js index 8e15607257..b232f51fec 100644 --- a/analytics/models/usersChannels.js +++ b/analytics/models/usersChannels.js @@ -1,6 +1,6 @@ // @flow import type { DBUsersChannels } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; const defaultResult = { isMember: false, diff --git a/analytics/models/usersCommunities.js b/analytics/models/usersCommunities.js index d1eb061953..466e6aaa81 100644 --- a/analytics/models/usersCommunities.js +++ b/analytics/models/usersCommunities.js @@ -1,6 +1,6 @@ // @flow import type { DBUsersCommunities } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; const defaultResult = { isMember: false, diff --git a/analytics/models/usersThreads.js b/analytics/models/usersThreads.js index 709e4473fc..73510a195d 100644 --- a/analytics/models/usersThreads.js +++ b/analytics/models/usersThreads.js @@ -1,6 +1,6 @@ // @flow import type { DBUsersThreads } from 'shared/types'; -import { db } from './db'; +import { db } from 'shared/db'; export const getThreadNotificationStatusForUser = ( threadId: string, diff --git a/api/apollo-server.js b/api/apollo-server.js index 7991d2a132..ea1daf51bc 100644 --- a/api/apollo-server.js +++ b/api/apollo-server.js @@ -61,20 +61,6 @@ const server = new ProtectedApolloServer({ req.login(data, err => (err ? rej(err) : res())) ), user: currentUser, - getImageSignatureExpiration: () => { - /* - Expire images sent to the client at midnight each day (UTC). - Expiration needs to be consistent across all images in order - to preserve client-side caching abilities and to prevent checksum - mismatches during SSR - */ - const date = new Date(); - date.setHours(24); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); - return date.getTime(); - }, }; }, subscriptions: { @@ -98,28 +84,12 @@ const server = new ProtectedApolloServer({ return { user: user || null, loaders: createLoaders({ cache: false }), - getImageSignatureExpiration: () => { - const date = new Date(); - date.setHours(24); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); - return date.getTime(); - }, }; }) .catch(err => { console.error(err); return { loaders: createLoaders({ cache: false }), - getImageSignatureExpiration: () => { - const date = new Date(); - date.setHours(24); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); - return date.getTime(); - }, }; }), }, diff --git a/api/index.js b/api/index.js index ee138b1d90..cd6672a45f 100644 --- a/api/index.js +++ b/api/index.js @@ -26,7 +26,6 @@ import type { Loader } from './loaders/types'; export type GraphQLContext = { user: DBUser, updateCookieUserData: (data: DBUser) => Promise, - getImageSignatureExpiration: () => number, loaders: { [key: string]: Loader, }, diff --git a/api/loaders/directMessageThread.js b/api/loaders/directMessageThread.js index a0b936d483..1657fca0e0 100644 --- a/api/loaders/directMessageThread.js +++ b/api/loaders/directMessageThread.js @@ -1,7 +1,7 @@ // @flow import { getDirectMessageThreads } from '../models/directMessageThread'; import { getMembersInDirectMessageThreads } from '../models/usersDirectMessageThreads'; -import { getLastMessages } from '../models/message'; +import { getLastMessageOfThreads } from '../models/message'; import createLoader from './create-loader'; import type { Loader } from './types'; @@ -15,8 +15,8 @@ export const __createDirectMessageParticipantsLoader = createLoader( ); export const __createDirectMessageSnippetLoader = createLoader( - threads => getLastMessages(threads), - 'group' + threads => getLastMessageOfThreads(threads), + 'threadId' ); export default () => { diff --git a/api/migrations/20181126094455-users-channels-roles.js b/api/migrations/20181126094455-users-channels-roles.js new file mode 100644 index 0000000000..0d7275d1a7 --- /dev/null +++ b/api/migrations/20181126094455-users-channels-roles.js @@ -0,0 +1,59 @@ +const branch = (r, field, fallback) => { + return r.branch(r.row(`is${field}`).eq(true), field.toLowerCase(), fallback); +}; + +exports.up = function(r, conn) { + return Promise.all([ + r + .table('usersChannels') + .indexCreate('channelIdAndRole', [ + r.row('channelId'), + branch( + r, + 'Pending', + branch( + r, + 'Blocked', + branch( + r, + 'Owner', + branch(r, 'Moderator', branch(r, 'Member', r.literal())) + ) + ) + ), + ]) + .run(conn), + r + .table('usersChannels') + .indexCreate('userIdAndRole', [ + r.row('userId'), + branch( + r, + 'Pending', + branch( + r, + 'Blocked', + branch( + r, + 'Owner', + branch(r, 'Moderator', branch(r, 'Member', r.literal())) + ) + ) + ), + ]) + .run(conn), + ]); +}; + +exports.down = function(r, conn) { + return Promise.all([ + r + .table('usersChannels') + .indexDrop('channelIdAndRole') + .run(conn), + r + .table('usersChannels') + .indexDrop('userIdAndRole') + .run(conn), + ]); +}; diff --git a/api/migrations/20181127090014-communities-member-count-index.js b/api/migrations/20181127090014-communities-member-count-index.js new file mode 100644 index 0000000000..eecdfa1163 --- /dev/null +++ b/api/migrations/20181127090014-communities-member-count-index.js @@ -0,0 +1,13 @@ +exports.up = function(r, conn) { + return r + .table('communities') + .indexCreate('memberCount') + .run(conn); +}; + +exports.down = function(r, conn) { + return r + .table('communities') + .indexDrop('memberCount') + .run(conn); +}; diff --git a/api/models/channel.js b/api/models/channel.js index 238e1347ad..fa9de18852 100644 --- a/api/models/channel.js +++ b/api/models/channel.js @@ -99,22 +99,28 @@ const getChannelsByUser = (userId: string): Promise> => { ); }; -// prettier-ignore -const getChannelBySlug = (channelSlug: string, communitySlug: string): Promise => { +const getChannelBySlug = async ( + channelSlug: string, + communitySlug: string +): Promise => { + const [communityId] = await db + .table('communities') + .getAll(communitySlug, { index: 'slug' })('id') + .run(); + + if (!communityId) return null; + return db .table('channels') + .getAll(communityId, { index: 'communityId' }) .filter(channel => channel('slug') .eq(channelSlug) .and(db.not(channel.hasFields('deletedAt'))) ) - .eqJoin('communityId', db.table('communities')) - .filter({ right: { slug: communitySlug } }) .run() - .then(result => { - if (result && result[0]) { - return result[0].left; - } + .then(res => { + if (Array.isArray(res) && res.length > 0) return res[0]; return null; }); }; diff --git a/api/models/message.js b/api/models/message.js index 9baa823345..12af8881cd 100644 --- a/api/models/message.js +++ b/api/models/message.js @@ -90,23 +90,25 @@ export const getMessages = ( return getForwardMessages(threadId, { first, after }); }; -export const getLastMessage = (threadId: string): Promise => { +export const getLastMessage = (threadId: string): Promise => { return db .table('messages') - .getAll(threadId, { index: 'threadId' }) + .between([threadId, db.minval], [threadId, db.maxval], { + index: 'threadIdAndTimestamp', + leftBound: 'open', + rightBound: 'closed', + }) + .orderBy({ index: db.desc('threadIdAndTimestamp') }) .filter(db.row.hasFields('deletedAt').not()) - .max('timestamp') - .run(); + .limit(1) + .run() + .then(res => (Array.isArray(res) && res.length > 0 ? res[0] : null)); }; -export const getLastMessages = (threadIds: Array): Promise => { - return db - .table('messages') - .getAll(...threadIds, { index: 'threadId' }) - .filter(db.row.hasFields('deletedAt').not()) - .group('threadId') - .max(row => row('timestamp')) - .run(); +export const getLastMessageOfThreads = ( + threadIds: Array +): Promise> => { + return Promise.all(threadIds.map(id => getLastMessage(id))); }; // prettier-ignore diff --git a/api/models/search.js b/api/models/search.js index 21a7af64b7..c92871acf4 100644 --- a/api/models/search.js +++ b/api/models/search.js @@ -89,8 +89,7 @@ export const getPrivateCommunityIdsForUsersThreads = ( export const getUsersJoinedChannels = (userId: string): Promise> => { return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ isMember: true }) + .getAll([userId, "member"], [userId, "moderator"], [userId, "owner"], { index: 'userIdAndRole' }) .eqJoin('channelId', db.table('channels')) .filter(row => row('right').hasFields('deletedAt').not()) .zip() @@ -114,8 +113,7 @@ export const getUsersJoinedCommunities = (userId: string): Promise export const getUsersJoinedPrivateChannelIds = (userId: string): Promise> => { return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ isMember: true }) + .getAll([userId, "member"], [userId, "moderator"], [userId, "owner"], { index: 'userIdAndRole' }) .eqJoin('channelId', db.table('channels')) .filter(row => row('right')('isPrivate').eq(true).and(row('right').hasFields('deletedAt').not())) .without({ left: ['id'] }) diff --git a/api/models/thread.js b/api/models/thread.js index 51c5e7068f..f2d7ac1852 100644 --- a/api/models/thread.js +++ b/api/models/thread.js @@ -164,8 +164,14 @@ export const getViewableThreadsByUser = async ( // get a list of the channelIds the current user is allowed to see threads const getCurrentUsersChannelIds = db .table('usersChannels') - .getAll(currentUser, { index: 'userId' }) - .filter({ isBlocked: false, isMember: true }) + .getAll( + [currentUser, 'member'], + [currentUser, 'moderator'], + [currentUser, 'owner'], + { + index: 'userIdAndRole', + } + ) .map(userChannel => userChannel('channelId')) .run(); @@ -273,8 +279,14 @@ export const getViewableParticipantThreadsByUser = async ( // get a list of the channelIds the current user is allowed to see threads for const getCurrentUsersChannelIds = db .table('usersChannels') - .getAll(currentUser, { index: 'userId' }) - .filter({ isBlocked: false, isMember: true }) + .getAll( + [currentUser, 'member'], + [currentUser, 'moderator'], + [currentUser, 'owner'], + { + index: 'userIdAndRole', + } + ) .map(userChannel => userChannel('channelId')) .run(); diff --git a/api/models/usersChannels.js b/api/models/usersChannels.js index 99e11891f8..e6bfea7921 100644 --- a/api/models/usersChannels.js +++ b/api/models/usersChannels.js @@ -63,15 +63,14 @@ const createMemberInChannel = (channelId: string, userId: string, token: boolean return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ channelId }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) .run() .then(result => { if (result && result.length > 0) { return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ channelId, isBlocked: false }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) + .filter({ isBlocked: false }) .update( { createdAt: new Date(), @@ -112,8 +111,7 @@ const createMemberInChannel = (channelId: string, userId: string, token: boolean const removeMemberInChannel = (channelId: string, userId: string): Promise => { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ userId }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) .update( { isModerator: false, @@ -160,8 +158,7 @@ const unblockMemberInChannel = (channelId: string, userId: string): Promise { if (data && data.length > 0) { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ userId }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) .update( { isPending: true, @@ -256,8 +251,7 @@ const createOrUpdatePendingUserInChannel = (channelId: string, userId: string): const removePendingUsersInChannel = (channelId: string): Promise => { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isPending: true }) + .getAll([channelId, 'pending'], { index: 'channelIdAndRole' }) .update({ isPending: false, receiveNotifications: false, @@ -288,11 +282,12 @@ const blockUserInChannel = async (channelId: string, userId: string): Promise => { const currentCount = await db.table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isMember: true }) + .getAll( + [channelId, 'member'], + [channelId, 'moderator'], + [channelId, 'owner'], + { + index: 'channelIdAndRole', + } + ) .count() .default(1) .run() const pendingCount = await db.table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isPending: true }) + .getAll([channelId, "pending"], { index: 'channelIdAndRole' }) .count() .default(0) .run() @@ -356,8 +355,7 @@ const approvePendingUsersInChannel = async (channelId: string): Promise => { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ userId }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) .update({ isModerator: false, }) @@ -466,8 +463,7 @@ const toggleUserChannelNotifications = async (userId: string, channelId: string, const permissions = await db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ userId }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) .run(); // permissions exist, this user is trying to toggle notifications for a channel where they @@ -475,8 +471,7 @@ const toggleUserChannelNotifications = async (userId: string, channelId: string, if (permissions && permissions.length > 0) { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ userId }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) .update({ receiveNotifications: value }) .run(); } @@ -539,10 +534,9 @@ const getMembersInChannel = (channelId: string, options: Options): Promise userChannel('userId')) .run() @@ -554,8 +548,7 @@ const getPendingUsersInChannel = (channelId: string): Promise> => return ( db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isPending: true }) + .getAll([channelId, "pending"], { index: 'channelIdAndRole' }) // return an array of the userIds to be loaded by gql .map(userChannel => userChannel('userId')) .run() @@ -565,9 +558,10 @@ const getPendingUsersInChannel = (channelId: string): Promise> => const getPendingUsersInChannels = (channelIds: Array) => { return db .table('usersChannels') - .getAll(...channelIds, { index: 'channelId' }) + .getAll(...channelIds.map(id => [id, 'pending']), { + index: 'channelIdAndRole', + }) .group('channelId') - .filter({ isPending: true }) .run(); }; @@ -576,8 +570,9 @@ const getBlockedUsersInChannel = (channelId: string): Promise> => return ( db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isBlocked: true }) + .getAll([channelId, 'blocked'], { + index: 'channelIdAndRole', + }) // return an array of the userIds to be loaded by gql .map(userChannel => userChannel('userId')) .run() @@ -588,8 +583,9 @@ const getModeratorsInChannel = (channelId: string): Promise> => { return ( db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isModerator: true }) + .getAll([channelId, 'moderator'], { + index: 'channelIdAndRole', + }) // return an array of the userIds to be loaded by gql .map(userChannel => userChannel('userId')) .run() @@ -600,8 +596,9 @@ const getOwnersInChannel = (channelId: string): Promise> => { return ( db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isOwner: true }) + .getAll([channelId, 'owner'], { + index: 'channelIdAndRole', + }) // return an array of the userIds to be loaded by gql .map(userChannel => userChannel('userId')) .run() @@ -666,16 +663,18 @@ const getUsersPermissionsInChannels = (input: Array): Promis const getUserUsersChannels = (userId: string) => { return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ isMember: true }) + .getAll([userId, 'member'], [userId, 'owner'], [userId, 'moderator'], { + index: 'userIdAndRole', + }) .run(); }; const getUserChannelIds = (userId: string) => { return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ isMember: true }) + .getAll([userId, 'member'], [userId, 'owner'], [userId, 'moderator'], { + index: 'userIdAndRole', + }) .map(rec => rec('channelId')) .run(); }; diff --git a/api/package.json b/api/package.json index be2573f9bf..e87c060d12 100644 --- a/api/package.json +++ b/api/package.json @@ -10,8 +10,9 @@ "apollo-upload-client": "^8.1.0", "aws-sdk": "2.200.0", "axios": "^0.16.2", + "b2a": "^1.0.10", "babel-plugin-replace-dynamic-import-runtime": "^1.0.2", - "babel-plugin-styled-components": "^1.8.0", + "babel-plugin-styled-components": "^1.9.0", "babel-plugin-transform-flow-strip-types": "^6.22.0", "babel-plugin-transform-object-rest-spread": "^6.23.0", "babel-preset-env": "^1.7.0", diff --git a/api/queries/community/coverPhoto.js b/api/queries/community/coverPhoto.js index 15a4dbf16a..10297b7b7a 100644 --- a/api/queries/community/coverPhoto.js +++ b/api/queries/community/coverPhoto.js @@ -1,12 +1,9 @@ // @flow import type { GraphQLContext } from '../../'; import type { DBCommunity } from 'shared/types'; -import { signImageUrl } from 'shared/imgix'; +import { signCommunity } from 'shared/imgix'; -export default ({ coverPhoto }: DBCommunity, _: any, ctx: GraphQLContext) => { - return signImageUrl(coverPhoto, { - w: 1280, - h: 384, - expires: ctx.getImageSignatureExpiration(), - }); +export default (community: DBCommunity, _: any, ctx: GraphQLContext) => { + const { coverPhoto } = signCommunity(community); + return coverPhoto; }; diff --git a/api/queries/community/profilePhoto.js b/api/queries/community/profilePhoto.js index 90492e51f5..dac9b86c5b 100644 --- a/api/queries/community/profilePhoto.js +++ b/api/queries/community/profilePhoto.js @@ -1,12 +1,9 @@ // @flow import type { GraphQLContext } from '../../'; -import type { DBUser } from 'shared/types'; -import { signImageUrl } from 'shared/imgix'; +import type { DBCommunity } from 'shared/types'; +import { signCommunity } from 'shared/imgix'; -export default ({ profilePhoto }: DBUser, _: any, ctx: GraphQLContext) => { - return signImageUrl(profilePhoto, { - w: 256, - h: 256, - expires: ctx.getImageSignatureExpiration(), - }); +export default (community: DBCommunity, _: any, ctx: GraphQLContext) => { + const { profilePhoto } = signCommunity(community); + return profilePhoto; }; diff --git a/api/queries/directMessageThread/participants.js b/api/queries/directMessageThread/participants.js index a5994f5937..c9deaac9de 100644 --- a/api/queries/directMessageThread/participants.js +++ b/api/queries/directMessageThread/participants.js @@ -1,10 +1,10 @@ // @flow import type { GraphQLContext } from '../../'; import { canViewDMThread } from '../../utils/permissions'; -import { signImageUrl } from 'shared/imgix'; +import { signUser } from 'shared/imgix'; export default async ({ id }: { id: string }, _: any, ctx: GraphQLContext) => { - const { loaders, user, getImageSignatureExpiration } = ctx; + const { loaders, user } = ctx; if (!user || !user.id) return null; const canViewThread = await canViewDMThread(user.id, id, loaders); @@ -14,19 +14,7 @@ export default async ({ id }: { id: string }, _: any, ctx: GraphQLContext) => { return loaders.directMessageParticipants.load(id).then(results => { if (!results || results.length === 0) return null; return results.reduction.map(user => { - return { - ...user, - coverPhoto: signImageUrl(user.coverPhoto, { - w: 1280, - h: 384, - expires: getImageSignatureExpiration(), - }), - profilePhoto: signImageUrl(user.profilePhoto, { - w: 256, - h: 256, - expires: getImageSignatureExpiration(), - }), - }; + return signUser(user); }); }); }; diff --git a/api/queries/directMessageThread/snippet.js b/api/queries/directMessageThread/snippet.js index 7652049857..d0c907539d 100644 --- a/api/queries/directMessageThread/snippet.js +++ b/api/queries/directMessageThread/snippet.js @@ -14,9 +14,8 @@ export default async ( const canViewThread = await canViewDMThread(user.id, id, loaders); if (!canViewThread) return null; - return loaders.directMessageSnippet.load(id).then(results => { - if (!results) return 'No messages yet...'; - const message = results.reduction; + return loaders.directMessageSnippet.load(id).then(message => { + if (!message) return 'No messages yet...'; if (message.messageType === 'media') return '📷 Photo'; return message.messageType === 'draftjs' ? toPlainText(toState(JSON.parse(message.content.body))) diff --git a/api/queries/message/content.js b/api/queries/message/content.js index 79fe5ee82e..ed005f64a4 100644 --- a/api/queries/message/content.js +++ b/api/queries/message/content.js @@ -1,8 +1,9 @@ // @flow import type { GraphQLContext } from '../../'; import type { DBMessage } from 'shared/types'; -import body from './content/body'; +import { signMessage } from 'shared/imgix'; -export default (message: DBMessage, _: any, ctx: GraphQLContext) => ({ - body: body(message, ctx.getImageSignatureExpiration()), -}); +export default (message: DBMessage, _: any, ctx: GraphQLContext) => { + const signedMessage = signMessage(message); + return signedMessage.content; +}; diff --git a/api/queries/message/content/body.js b/api/queries/message/content/body.js deleted file mode 100644 index 567aefb76b..0000000000 --- a/api/queries/message/content/body.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow -import type { DBMessage } from 'shared/types'; -import { signImageUrl } from 'shared/imgix'; - -export default (message: DBMessage, imageSignatureExpiration: number) => { - const { content, messageType } = message; - if (messageType !== 'media') return content.body; - return signImageUrl(content.body, { expires: imageSignatureExpiration }); -}; diff --git a/api/queries/thread/content.js b/api/queries/thread/content.js index 16d83f39a3..67804d3b78 100644 --- a/api/queries/thread/content.js +++ b/api/queries/thread/content.js @@ -1,11 +1,12 @@ // @flow import type { GraphQLContext } from '../../'; import type { DBThread } from 'shared/types'; -import body from './content/body'; +import { signThread } from 'shared/imgix'; export default (thread: DBThread, _: any, ctx: GraphQLContext) => { + const signedThread = signThread(thread); return { - title: thread.content.title, - body: body(thread, ctx.getImageSignatureExpiration()), + ...signedThread.content, + body: signedThread.content.body, }; }; diff --git a/api/queries/thread/content/body.js b/api/queries/thread/content/body.js deleted file mode 100644 index 0e38a04ec7..0000000000 --- a/api/queries/thread/content/body.js +++ /dev/null @@ -1,44 +0,0 @@ -// @flow -import type { DBThread } from 'shared/types'; -import { signImageUrl } from 'shared/imgix'; - -export default (thread: DBThread, imageSignatureExpiration: number) => { - const { content } = thread; - - if (!content.body) { - return JSON.stringify({ - blocks: [ - { - key: 'foo', - text: '', - type: 'unstyled', - depth: 0, - inlineStyleRanges: [], - entityRanges: [], - data: {}, - }, - ], - entityMap: {}, - }); - } - - // Replace the local image srcs with the remote image src - const body = JSON.parse(content.body); - - const imageKeys = Object.keys(body.entityMap).filter( - key => body.entityMap[key].type.toLowerCase() === 'image' - ); - - imageKeys.forEach((key, index) => { - if (!body.entityMap[key]) return; - - const { src } = body.entityMap[key].data; - - // transform the body inline with signed image urls - body.entityMap[key].data.src = signImageUrl(src, { - expires: imageSignatureExpiration, - }); - }); - - return JSON.stringify(body); -}; diff --git a/api/queries/thread/index.js b/api/queries/thread/index.js index c428879f23..0a5e8426b3 100644 --- a/api/queries/thread/index.js +++ b/api/queries/thread/index.js @@ -15,6 +15,7 @@ import creator from './creator'; import currentUserLastSeen from './currentUserLastSeen'; import content from './content'; import reactions from './reactions'; +import metaImage from './metaImage'; import type { DBThread } from 'shared/types'; @@ -37,6 +38,7 @@ module.exports = { currentUserLastSeen, content, reactions, + metaImage, messageCount: ({ messageCount }: DBThread) => messageCount || 0, }, }; diff --git a/api/queries/thread/metaImage.js b/api/queries/thread/metaImage.js new file mode 100644 index 0000000000..9c337ab9c2 --- /dev/null +++ b/api/queries/thread/metaImage.js @@ -0,0 +1,24 @@ +// @flow +import type { GraphQLContext } from '../../'; +import type { DBThread } from 'shared/types'; +import generateImageFromText from '../../utils/generate-thread-meta-image-from-text'; +import { signImageUrl } from 'shared/imgix'; + +export default async (thread: DBThread, _: any, ctx: GraphQLContext) => { + const { loaders } = ctx; + const { watercooler, communityId, content } = thread; + + const community = await loaders.community.load(communityId); + if (!community) return null; + + const imageUrl = generateImageFromText({ + title: watercooler + ? `Chat with the ${community.name} community` + : content.title, + footer: `spectrum.chat/${community.slug}`, + }); + + if (!imageUrl) return null; + + return signImageUrl(imageUrl); +}; diff --git a/api/queries/user/coverPhoto.js b/api/queries/user/coverPhoto.js index 5d1415adfe..e383f22bfd 100644 --- a/api/queries/user/coverPhoto.js +++ b/api/queries/user/coverPhoto.js @@ -1,12 +1,9 @@ // @flow import type { GraphQLContext } from '../../'; import type { DBUser } from 'shared/types'; -import { signImageUrl } from 'shared/imgix'; +import { signUser } from 'shared/imgix'; -export default ({ coverPhoto }: DBUser, _: any, ctx: GraphQLContext) => { - return signImageUrl(coverPhoto, { - w: 1280, - h: 384, - expires: ctx.getImageSignatureExpiration(), - }); +export default (user: DBUser, _: any, ctx: GraphQLContext) => { + const { coverPhoto } = signUser(user); + return coverPhoto; }; diff --git a/api/queries/user/profilePhoto.js b/api/queries/user/profilePhoto.js index 90492e51f5..c978deabd6 100644 --- a/api/queries/user/profilePhoto.js +++ b/api/queries/user/profilePhoto.js @@ -1,12 +1,9 @@ // @flow import type { GraphQLContext } from '../../'; import type { DBUser } from 'shared/types'; -import { signImageUrl } from 'shared/imgix'; +import { signUser } from 'shared/imgix'; -export default ({ profilePhoto }: DBUser, _: any, ctx: GraphQLContext) => { - return signImageUrl(profilePhoto, { - w: 256, - h: 256, - expires: ctx.getImageSignatureExpiration(), - }); +export default (user: DBUser, _: any, ctx: GraphQLContext) => { + const { profilePhoto } = signUser(user); + return profilePhoto; }; diff --git a/api/test/channel/queries/__snapshots__/memberConnection.test.js.snap b/api/test/channel/queries/__snapshots__/memberConnection.test.js.snap index caf42504d1..5064fa79c5 100644 --- a/api/test/channel/queries/__snapshots__/memberConnection.test.js.snap +++ b/api/test/channel/queries/__snapshots__/memberConnection.test.js.snap @@ -8,11 +8,11 @@ Object { "memberConnection": Object { "edges": Array [ Object { - "cursor": "OC0x", + "cursor": "My0x", "node": Object { "contextPermissions": null, - "id": "8", - "name": "Channel moderator", + "id": "3", + "name": "Bryn Jackson", }, }, Object { @@ -40,11 +40,11 @@ Object { }, }, Object { - "cursor": "My01", + "cursor": "OC01", "node": Object { "contextPermissions": null, - "id": "3", - "name": "Bryn Jackson", + "id": "8", + "name": "Channel moderator", }, }, ], diff --git a/api/test/utils.js b/api/test/utils.js index d33020108d..3da122f2b6 100644 --- a/api/test/utils.js +++ b/api/test/utils.js @@ -18,20 +18,6 @@ export const request = (query: mixed, { context, variables }: Options = {}) => { undefined, { loaders: createLoaders(), - getImageSignatureExpiration: () => { - /* - Expire images sent to the client at midnight each day (UTC). - Expiration needs to be consistent across all images in order - to preserve client-side caching abilities and to prevent checksum - mismatches during SSR - */ - const date = new Date(); - date.setHours(24); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); - return date.getTime(); - }, ...context, }, variables diff --git a/api/types/Thread.js b/api/types/Thread.js index 646ed831b1..f071f21e82 100644 --- a/api/types/Thread.js +++ b/api/types/Thread.js @@ -67,6 +67,7 @@ const Thread = /* GraphQL */ ` watercooler: Boolean currentUserLastSeen: Date @cost(complexity: 1) reactions: ThreadReactions @cost(complexity: 1) + metaImage: String attachments: [Attachment] @deprecated(reason: "Attachments no longer used for link previews") diff --git a/src/helpers/generate-image-from-text.js b/api/utils/generate-thread-meta-image-from-text.js similarity index 98% rename from src/helpers/generate-image-from-text.js rename to api/utils/generate-thread-meta-image-from-text.js index 94a4c29005..9f89f93bf1 100644 --- a/src/helpers/generate-image-from-text.js +++ b/api/utils/generate-thread-meta-image-from-text.js @@ -64,10 +64,10 @@ const generateImageFromText = ({ w: WIDTH, bm: 'normal', // Blend the title normally, don't change opacity or color or anything, just overlay it by: 170, // Magic numbers that get the position right - bx: 180, + bx: 170, markalign: 'left,bottom', // Show the footer on the left side markpad: 24, // We overwrite the X pos, so the padding only applies on the y-axis - markx: 100, + markx: 140, blend64: btoa(titleUrl).replace(/=/g, ''), mark64: btoa(footerUrl).replace(/=/g, ''), }; diff --git a/api/yarn.lock b/api/yarn.lock index f4e7a5d481..353d76c3c9 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -314,6 +314,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" +"@babel/plugin-syntax-jsx@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.0.0.tgz#034d5e2b4e14ccaea2e4c137af7e4afb39375ffd" + integrity sha512-PdmL2AoPsCLWxhIr3kG2+F9v4WH06Q3z+NoGVpQgnUNGcagXHq5sB3OXxkSahKq9TLdNMN/AJzFYSOo8UKDMHg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-object-rest-spread@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.0.0.tgz#37d8fbcaf216bd658ea1aebbeb8b75e88ebc549b" @@ -1603,6 +1610,11 @@ axios@^0.16.2: follow-redirects "^1.2.3" is-buffer "^1.1.5" +b2a@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/b2a/-/b2a-1.0.10.tgz#979271967ae2dd6d0bafea827ab5d02bb0362c01" + integrity sha512-qRdfj/Abk64Wp1QKE5t4dG+ioF87/er959LgzyBg0DGBNNQMcOzljC5lpFeYeFFUcUdtUhwofDRRo/0YM8Il1Q== + babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -1829,7 +1841,7 @@ babel-plugin-replace-dynamic-import-runtime@^1.0.2: babel-template "^6.24.1" babel-types "^6.24.1" -babel-plugin-styled-components@^1.1.4, babel-plugin-styled-components@^1.8.0: +babel-plugin-styled-components@^1.1.4: version "1.8.0" resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.8.0.tgz#9dd054c8e86825203449a852a5746f29f2dab857" integrity sha512-PcrdbXFO/9Plo9JURIj8G0Dsz+Ct8r+NvjoLh6qPt8Y/3EIAj1gHGW1ocPY1IkQbXZLBEZZSRBAxJem1KFdBXg== @@ -1837,6 +1849,15 @@ babel-plugin-styled-components@^1.1.4, babel-plugin-styled-components@^1.8.0: "@babel/helper-annotate-as-pure" "^7.0.0" lodash "^4.17.10" +babel-plugin-styled-components@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.9.0.tgz#7b814bbe55900d9e0f6ec2c66741a1992c1c4a3d" + integrity sha512-DRurNjnndoIAiW0+vYgQyGmnCtKyCEGP9Y19Z9NrSwMEMGBWl2S7Q7F70RyGDae+KKeighhOPI1WttIb3r0xaA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.0.0" + lodash "^4.17.10" + babel-plugin-syntax-async-functions@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" diff --git a/athena/models/channel.js b/athena/models/channel.js index 218e984262..b13f164ef5 100644 --- a/athena/models/channel.js +++ b/athena/models/channel.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBChannel } from 'shared/types'; export const getChannelById = (id: string): Promise => { diff --git a/athena/models/channelSettings.js b/athena/models/channelSettings.js index 04adc17693..66e2e7c089 100644 --- a/athena/models/channelSettings.js +++ b/athena/models/channelSettings.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getChannelSettings = (id: string) => { return db diff --git a/athena/models/community.js b/athena/models/community.js index 10cfbeec27..ecaf98d934 100644 --- a/athena/models/community.js +++ b/athena/models/community.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBCommunity } from 'shared/types'; export const getCommunityById = (id: string): Promise => { diff --git a/athena/models/communitySettings.js b/athena/models/communitySettings.js index 353e4a8250..05d368c1ab 100644 --- a/athena/models/communitySettings.js +++ b/athena/models/communitySettings.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import axios from 'axios'; const querystring = require('querystring'); import { decryptString } from 'shared/encryption'; diff --git a/athena/models/db.js b/athena/models/db.js deleted file mode 100644 index 271e97a5a8..0000000000 --- a/athena/models/db.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Database setup is done here - */ -const IS_PROD = !process.env.FORCE_DEV && process.env.NODE_ENV === 'production'; - -const DEFAULT_CONFIG = { - db: 'spectrum', - max: 20, // Maximum number of connections, default is 1000 - buffer: 1, // Minimum number of connections open at any given moment, default is 50 - timeoutGb: 60 * 1000, // How long should an unused connection stick around, default is an hour, this is a minute -}; - -const PRODUCTION_CONFIG = { - password: process.env.AWS_RETHINKDB_PASSWORD, - host: process.env.AWS_RETHINKDB_URL, - port: process.env.AWS_RETHINKDB_PORT, -}; - -const config = IS_PROD - ? { - ...DEFAULT_CONFIG, - ...PRODUCTION_CONFIG, - } - : { - ...DEFAULT_CONFIG, - }; - -var r = require('rethinkdbdash')(config); - -module.exports = { db: r }; diff --git a/athena/models/directMessageThread.js b/athena/models/directMessageThread.js index ac3209b98b..5f646c527b 100644 --- a/athena/models/directMessageThread.js +++ b/athena/models/directMessageThread.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBDirectMessageThread } from 'shared/types'; export const getDirectMessageThreadById = ( diff --git a/athena/models/message.js b/athena/models/message.js index cdfa3e8316..ab04f84c2d 100644 --- a/athena/models/message.js +++ b/athena/models/message.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBMessage } from 'shared/types'; export const getMessageById = (id: string): Promise => { diff --git a/athena/models/notification.js b/athena/models/notification.js index 436b1e68c3..548714f5f6 100644 --- a/athena/models/notification.js +++ b/athena/models/notification.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { NotificationEventType, DBNotification } from 'shared/types'; import { TIME_BUFFER } from '../queues/constants'; import { NEW_DOCUMENTS } from 'api/models/utils'; diff --git a/athena/models/thread.js b/athena/models/thread.js index ed831bac15..baacdfcfb5 100644 --- a/athena/models/thread.js +++ b/athena/models/thread.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBThread } from 'shared/types'; export const getThreadById = (id: string): Promise => { diff --git a/athena/models/usersChannels.js b/athena/models/usersChannels.js index b9cf9b8c95..52a16c9778 100644 --- a/athena/models/usersChannels.js +++ b/athena/models/usersChannels.js @@ -1,13 +1,18 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getMembersInChannelWithNotifications = ( channelId: string ): Promise> => { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isMember: true, receiveNotifications: true }) + .getAll( + [channelId, 'member'], + [channelId, 'moderator'], + [channelId, 'owner'], + { index: 'channelIdAndRole' } + ) + .filter({ receiveNotifications: true }) .group('userId') .run() .then(users => users.map(u => u.group)); @@ -19,8 +24,7 @@ export const getUserPermissionsInChannel = ( ): Promise => { return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ channelId }) + .getAll([userId, channelId], { index: 'userIdAndChannelId' }) .group('userId') .run() .then(groups => { @@ -43,8 +47,7 @@ export const getOwnersInChannel = ( ): Promise> => { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isOwner: true }) + .getAll([channelId, 'owner'], { index: 'channelIdAndRole' }) .map(user => user('userId')) .run(); }; @@ -54,8 +57,7 @@ export const getModeratorsInChannel = ( ): Promise> => { return db .table('usersChannels') - .getAll(channelId, { index: 'channelId' }) - .filter({ isModerator: true }) + .getAll([channelId, 'moderator'], { index: 'channelIdAndRole' }) .map(user => user('userId')) .run(); }; diff --git a/athena/models/usersCommunities.js b/athena/models/usersCommunities.js index b828d4c3a2..f0b7acc306 100644 --- a/athena/models/usersCommunities.js +++ b/athena/models/usersCommunities.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getMembersInCommunity = ( communityId: string diff --git a/athena/models/usersDirectMessageThreads.js b/athena/models/usersDirectMessageThreads.js index b1314210b3..30ff77574e 100644 --- a/athena/models/usersDirectMessageThreads.js +++ b/athena/models/usersDirectMessageThreads.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getDirectMessageThreadMembers = ( id: string diff --git a/athena/models/usersSettings.js b/athena/models/usersSettings.js index f864381a4d..168aec5087 100644 --- a/athena/models/usersSettings.js +++ b/athena/models/usersSettings.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBUsersSettings } from 'shared/types'; export const getUsersSettings = (userId: string): Promise => { diff --git a/athena/models/usersThreads.js b/athena/models/usersThreads.js index 690b09a69e..c9c50f62d4 100644 --- a/athena/models/usersThreads.js +++ b/athena/models/usersThreads.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBUsersThreads } from 'shared/types'; export const getThreadNotificationUsers = ( diff --git a/athena/models/web-push-subscription.js b/athena/models/web-push-subscription.js index 6263248800..c717b7fe47 100644 --- a/athena/models/web-push-subscription.js +++ b/athena/models/web-push-subscription.js @@ -1,6 +1,6 @@ // @flow const debug = require('debug')('api:models:webPushSubscription'); -const { db } = require('./db'); +const { db } = require('shared/db'); import type { WebPushSubscription } from 'api/mutations/user'; export const storeSubscription = ( diff --git a/athena/queues/community-invite.js b/athena/queues/community-invite.js index 21962ce6b3..e4428207b0 100644 --- a/athena/queues/community-invite.js +++ b/athena/queues/community-invite.js @@ -14,6 +14,7 @@ import type { CommunityInviteNotificationJobData, Job, } from 'shared/bull/types'; +import { signCommunity, signUser } from 'shared/imgix'; const addToSendCommunityInviteEmailQueue = ( recipient, @@ -31,8 +32,8 @@ const addToSendCommunityInviteEmailQueue = ( { to: recipient.email, recipient, - sender, - community, + sender: signUser(sender), + community: signCommunity(community), communitySettings, customMessage, }, diff --git a/athena/queues/create-thread-notification-email.js b/athena/queues/create-thread-notification-email.js index 4b497e4215..799646fe96 100644 --- a/athena/queues/create-thread-notification-email.js +++ b/athena/queues/create-thread-notification-email.js @@ -9,6 +9,7 @@ import { getChannelById } from '../models/channel'; import { toPlainText, toState } from 'shared/draft-utils'; import { sendThreadCreatedNotificationEmailQueue } from 'shared/bull/queues'; import type { DBThread, DBUser } from 'shared/types'; +import { signCommunity, signUser, signThread } from 'shared/imgix'; const createThreadNotificationEmail = async ( thread: DBThread, @@ -33,19 +34,24 @@ const createThreadNotificationEmail = async ( // user is either online or has this notif type turned off if (!shouldSendEmail) return; + const signedThread = signThread(thread); + // at this point the email is safe to send, construct data for Hermes const rawBody = thread.type === 'DRAFTJS' - ? thread.content.body - ? toPlainText(toState(JSON.parse(thread.content.body))) + ? signedThread.content.body + ? toPlainText(toState(JSON.parse(signedThread.content.body))) : '' - : thread.content.body || ''; + : signedThread.content.body || ''; // if the body is long, truncate it at 280 characters for the email preview const body = rawBody && truncate(rawBody.trim(), 280); const primaryActionLabel = 'View conversation'; + const signedCommunity = signCommunity(community); + const signedCreator = signUser(creator); + return sendThreadCreatedNotificationEmailQueue.add({ // $FlowIssue recipient, @@ -53,8 +59,8 @@ const createThreadNotificationEmail = async ( thread: { ...thread, // $FlowIssue - creator, - community, + creator: signedCreator, + community: signedCommunity, channel, content: { title: thread.content.title, diff --git a/athena/queues/direct-message-notification.js b/athena/queues/direct-message-notification.js index 09048c65f3..5ba30158ed 100644 --- a/athena/queues/direct-message-notification.js +++ b/athena/queues/direct-message-notification.js @@ -19,6 +19,7 @@ import sentencify from '../utils/sentencify'; import { toPlainText, toState } from 'shared/draft-utils'; import { sendNewDirectMessageEmailQueue } from 'shared/bull/queues'; import type { Job, DirectMessageNotificationJobData } from 'shared/bull/types'; +import { signUser, signMessage } from 'shared/imgix'; export default async (job: Job) => { const { message: incomingMessage, userId: currentUserId } = job.data; @@ -91,6 +92,9 @@ export default async (job: Job) => { ? markUsersNotificationsAsNew : storeUsersNotifications; + const signedMessage = signMessage(message); + const signedUser = signUser(user); + const addToQueue = recipient => { return sendNewDirectMessageEmailQueue.add({ recipient, @@ -106,14 +110,14 @@ export default async (job: Job) => { path: `messages/${thread.id}`, id: thread.id, }, - user, + user: signedUser, message: { - ...message, + ...signedMessage, content: { body: - message.messageType === 'draftjs' - ? toPlainText(toState(JSON.parse(message.content.body))) - : message.content.body, + signedMessage.messageType === 'draftjs' + ? toPlainText(toState(JSON.parse(signedMessage.content.body))) + : signedMessage.content.body, }, }, }); diff --git a/athena/queues/mention-notification.js b/athena/queues/mention-notification.js index 8784c50ada..7776999e3c 100644 --- a/athena/queues/mention-notification.js +++ b/athena/queues/mention-notification.js @@ -20,6 +20,7 @@ import { sendNewMentionMessageEmailQueue, } from 'shared/bull/queues'; import type { Job, MentionNotificationJobData } from 'shared/bull/types'; +import { signUser, signThread, signMessage, signCommunity } from 'shared/imgix'; export default async ({ data }: Job) => { debug('mention job created'); @@ -135,13 +136,15 @@ export default async ({ data }: Job) => { getChannelById(thread.channelId), ]); + const signedThread = signThread(thread); + // compose preview text for the email const rawThreadBody = - thread.type === 'DRAFTJS' - ? thread.content.body - ? toPlainText(toState(JSON.parse(thread.content.body))) + signedThread.type === 'DRAFTJS' + ? signedThread.content.body + ? toPlainText(toState(JSON.parse(signedThread.content.body))) : '' - : thread.content.body || ''; + : signedThread.content.body || ''; const threadBody = rawThreadBody && rawThreadBody.length > 10 @@ -149,8 +152,10 @@ export default async ({ data }: Job) => { : rawThreadBody.trim(); const primaryActionLabel = 'View conversation'; - const rawMessageBody = message - ? toPlainText(toState(JSON.parse(message.content.body))) + const signedMessage = message ? signMessage(message) : null; + + const rawMessageBody = signedMessage + ? toPlainText(toState(JSON.parse(signedMessage.content.body))) : ''; // if the message was super long, truncate it @@ -162,15 +167,18 @@ export default async ({ data }: Job) => { ? sendNewMentionThreadEmailQueue : sendNewMentionMessageEmailQueue; + const signedSender = signUser(sender); + const signedCommunity = signCommunity(community); + return Promise.all([ queue.add({ recipient, - sender, + sender: signedSender, primaryActionLabel, thread: { ...thread, - creator: sender, - community, + creator: signedSender, + community: signedCommunity, channel, content: { title: thread.content.title, @@ -179,7 +187,7 @@ export default async ({ data }: Job) => { }, message: { ...message, - sender, + sender: signedSender, content: { body: messageBody, }, diff --git a/athena/queues/moderationEvents/message.js b/athena/queues/moderationEvents/message.js index 2f785fe7a9..a091c91e79 100644 --- a/athena/queues/moderationEvents/message.js +++ b/athena/queues/moderationEvents/message.js @@ -25,7 +25,13 @@ export default async (job: Job) => { getSpectrumScore(text, message.id, message.senderId), getPerspectiveScore(text), ]).catch(err => - console.error('Error getting message moderation scores from providers', err) + console.error('Error getting message moderation scores from providers', { + error: err.message, + data: { + text, + threadId: message.id, + }, + }) ); const spectrumScore = scores && scores[0]; diff --git a/athena/queues/new-message-in-thread/format-data.js b/athena/queues/new-message-in-thread/format-data.js index 28f1ff6b58..977ee634b8 100644 --- a/athena/queues/new-message-in-thread/format-data.js +++ b/athena/queues/new-message-in-thread/format-data.js @@ -32,7 +32,7 @@ export default async ( break; } case 'media': { - body = '📷 Photo'; + body = message.content.body; break; } default: { diff --git a/athena/queues/new-message-in-thread/group-replies.js b/athena/queues/new-message-in-thread/group-replies.js index abceed9f88..dd61a7d1f3 100644 --- a/athena/queues/new-message-in-thread/group-replies.js +++ b/athena/queues/new-message-in-thread/group-replies.js @@ -1,4 +1,5 @@ import { getMessageById } from '../../models/message'; +import { signImageUrl } from 'shared/imgix'; export default async replies => { let newReplies = []; @@ -21,9 +22,9 @@ export default async replies => { const reply = replies.filter(r => r.id === message.id)[0]; const body = message.messageType === 'media' - ? `

` + )}' />

` : `

${reply.content.body}

`; const newGroup = { diff --git a/athena/queues/private-community-request-sent.js b/athena/queues/private-community-request-sent.js index 1b4e5eade0..4b651c06da 100644 --- a/athena/queues/private-community-request-sent.js +++ b/athena/queues/private-community-request-sent.js @@ -15,6 +15,7 @@ import { fetchPayload } from '../utils/payloads'; import isEmail from 'validator/lib/isEmail'; import { sendPrivateCommunityRequestEmailQueue } from 'shared/bull/queues'; import type { Job, PrivateCommunityRequestJobData } from 'shared/bull/types'; +import { signCommunity, signUser } from 'shared/imgix'; export default async (job: Job) => { const { userId, communityId } = job.data; @@ -72,10 +73,11 @@ export default async (job: Job) => { const community = await getCommunityById(communityId); const usersEmailPromises = filteredRecipients.map(recipient => sendPrivateCommunityRequestEmailQueue.add({ - user: userPayload, // $FlowFixMe - recipient, - community, + user: signUser(userPayload), + // $FlowFixMe + recipient: signUser(recipient), + community: signCommunity(community), }) ); diff --git a/athena/queues/thread-notification.js b/athena/queues/thread-notification.js index d1d8294f05..193a2f6200 100644 --- a/athena/queues/thread-notification.js +++ b/athena/queues/thread-notification.js @@ -18,6 +18,7 @@ import { handleSlackChannelResponse } from '../utils/slack'; import { decryptString } from 'shared/encryption'; import { trackQueue } from 'shared/bull/queues'; import { events } from 'shared/analytics'; +import { signThread, signUser } from 'shared/imgix'; export default async (job: Job) => { const { thread: incomingThread } = job.data; @@ -64,6 +65,10 @@ export default async (job: Job) => { return r.username && mentions.indexOf(r.username) < 0; }); + const signedRecipientsWithoutMentions = recipientsWithoutMentions.map(r => { + return signUser(r); + }); + let slackNotificationPromise; if ( // process.env.NODE_ENV === 'production' && @@ -85,6 +90,8 @@ export default async (job: Job) => { getChannelById(incomingThread.channelId), ]); + const signedAuthor = signUser(author); + const decryptedToken = decryptString( communitySlackSettings.slackSettings.token ); @@ -104,7 +111,7 @@ export default async (job: Job) => { }:`, author_name: `${author.name} (@${author.username})`, author_link: `https://spectrum.chat/users/${author.username}`, - author_icon: author.profilePhoto, + author_icon: signedAuthor.profilePhoto, pretext: `New conversation published in ${community.name} #${ channel.name }:`, @@ -145,8 +152,13 @@ export default async (job: Job) => { }); } + const signedThread = signThread(incomingThread); + return Promise.all([ - createThreadNotificationEmail(incomingThread, recipientsWithoutMentions), // handle emails separately + createThreadNotificationEmail( + signedThread, + signedRecipientsWithoutMentions + ), // handle emails separately slackNotificationPromise, ]).catch(err => { debug('❌ Error in job:\n'); diff --git a/chronos/models/channel.js b/chronos/models/channel.js index 668f39a216..abd2d485c9 100644 --- a/chronos/models/channel.js +++ b/chronos/models/channel.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getChannelById = (id: string): Promise => { return db diff --git a/chronos/models/community.js b/chronos/models/community.js index 28b639e35d..c6a4a9c560 100644 --- a/chronos/models/community.js +++ b/chronos/models/community.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBCommunity } from 'shared/types'; export const getCommunityById = (id: string): Promise => { @@ -21,7 +21,7 @@ export const getCommunities = ( export const getTopCommunities = (amount: number): Array => { return db .table('communities') - .orderBy('memberCount') + .orderBy({ index: db.desc('memberCount') }) .filter(community => community.hasFields('deletedAt').not()) .limit(amount) .run(); @@ -33,7 +33,7 @@ export const getCommunitiesWithMinimumMembers = ( ) => { return db .table('communities') - .filter(row => row('memberCount').ge(min)) + .between(min, db.maxval, { index: 'memberCount' }) .filter(community => community.hasFields('deletedAt').not()) .map(row => row('id')) .run(); diff --git a/chronos/models/coreMetrics.js b/chronos/models/coreMetrics.js index 2aa5c9b343..4bd74eb5a8 100644 --- a/chronos/models/coreMetrics.js +++ b/chronos/models/coreMetrics.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import { getCoreMetricsActiveThreads } from './thread'; import { getCommunitiesWithMinimumMembers, getCommunities } from './community'; diff --git a/chronos/models/db.js b/chronos/models/db.js deleted file mode 100644 index 271e97a5a8..0000000000 --- a/chronos/models/db.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Database setup is done here - */ -const IS_PROD = !process.env.FORCE_DEV && process.env.NODE_ENV === 'production'; - -const DEFAULT_CONFIG = { - db: 'spectrum', - max: 20, // Maximum number of connections, default is 1000 - buffer: 1, // Minimum number of connections open at any given moment, default is 50 - timeoutGb: 60 * 1000, // How long should an unused connection stick around, default is an hour, this is a minute -}; - -const PRODUCTION_CONFIG = { - password: process.env.AWS_RETHINKDB_PASSWORD, - host: process.env.AWS_RETHINKDB_URL, - port: process.env.AWS_RETHINKDB_PORT, -}; - -const config = IS_PROD - ? { - ...DEFAULT_CONFIG, - ...PRODUCTION_CONFIG, - } - : { - ...DEFAULT_CONFIG, - }; - -var r = require('rethinkdbdash')(config); - -module.exports = { db: r }; diff --git a/chronos/models/message.js b/chronos/models/message.js index e435e577e0..5f710b3a03 100644 --- a/chronos/models/message.js +++ b/chronos/models/message.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getTotalMessageCount = (threadId: string): Promise => { return db @@ -31,15 +31,10 @@ export const getNewMessageCount = ( return db .table('messages') - .getAll(threadId, { index: 'threadId' }) + .between([threadId, db.now().sub(range)], [threadId, db.now()], { + index: 'threadIdAndTimestamp', + }) .filter(db.row.hasFields('deletedAt').not()) - .filter( - db.row('timestamp').during( - // only count messages sent in the past week - db.now().sub(range), - db.now() - ) - ) .count() .run(); }; diff --git a/chronos/models/reputationEvent.js b/chronos/models/reputationEvent.js index 41db89970a..b9a1c68ae8 100644 --- a/chronos/models/reputationEvent.js +++ b/chronos/models/reputationEvent.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getReputationChangeInTimeframe = ( userId: string, diff --git a/chronos/models/thread.js b/chronos/models/thread.js index d788041cd4..39f6dab334 100644 --- a/chronos/models/thread.js +++ b/chronos/models/thread.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getActiveThreadsInTimeframe = (timeframe: string) => { let range; diff --git a/chronos/models/usersChannels.js b/chronos/models/usersChannels.js index 2f829ba7f3..fa3e59a6a6 100644 --- a/chronos/models/usersChannels.js +++ b/chronos/models/usersChannels.js @@ -1,13 +1,14 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getUsersChannelsEligibleForWeeklyDigest = ( userId: string ): Promise> => { return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter({ isMember: true }) + .getAll([userId, 'member'], [userId, 'moderator'], [userId, 'owner'], { + index: 'userIdAndRole', + }) .map(row => row('channelId')) .run(); }; diff --git a/chronos/models/usersCommunities.js b/chronos/models/usersCommunities.js index 7f14ef2b81..5bc2c12897 100644 --- a/chronos/models/usersCommunities.js +++ b/chronos/models/usersCommunities.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); const debug = require('debug')('hermes:queue:send-weekly-digest-email'); export const getUsersCommunityIds = ( diff --git a/chronos/models/usersSettings.js b/chronos/models/usersSettings.js index 734cc94eea..0fd5398271 100644 --- a/chronos/models/usersSettings.js +++ b/chronos/models/usersSettings.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); // prettier-ignore export const getUsersForDigest = (timeframe: string): Promise> => { diff --git a/cypress/integration/channel/view/membership_spec.js b/cypress/integration/channel/view/membership_spec.js index 7f9a2f954d..bd48c2664a 100644 --- a/cypress/integration/channel/view/membership_spec.js +++ b/cypress/integration/channel/view/membership_spec.js @@ -28,11 +28,11 @@ const { userId: memberInPrivateChannelId } = data.usersChannels.find( const QUIET_USER_ID = constants.QUIET_USER_ID; const leave = () => { - cy.get('[data-cy="channel-join-button"]') + cy.get('[data-cy="channel-leave-button"]') .should('be.visible') - .contains('Joined'); + .contains('Leave channel'); - cy.get('[data-cy="channel-join-button"]').click(); + cy.get('[data-cy="channel-leave-button"]').click(); cy.get('[data-cy="channel-join-button"]').contains(`Join `); }; @@ -44,7 +44,7 @@ const join = () => { cy.get('[data-cy="channel-join-button"]').click(); - cy.get('[data-cy="channel-join-button"]').contains(`Joined`); + cy.get('[data-cy="channel-leave-button"]').contains(`Leave channel`); }; describe('logged out channel membership', () => { diff --git a/cypress/integration/community/view/profile_spec.js b/cypress/integration/community/view/profile_spec.js index 435afdd43c..46a474e61f 100644 --- a/cypress/integration/community/view/profile_spec.js +++ b/cypress/integration/community/view/profile_spec.js @@ -163,12 +163,18 @@ describe('public community signed in without permission', () => { .contains(`Join ${publicCommunity.name}`) .click(); - cy.get('[data-cy="join-community-button"]') - .contains(`Member`) + cy.get('[data-cy="leave-community-button"]') + .contains(`Leave community`) + .click(); + + // triggered the leave modal + cy.get('[data-cy="delete-button"]') + .contains(`Leave Community`) + .should('be.visible') .click(); cy.get('[data-cy="join-community-button"]') - .contains(`Join ${publicCommunity.name}`) + .scrollIntoView() .should('be.visible'); }); }); diff --git a/desktop/package.json b/desktop/package.json index 2956551e23..b192720cc7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -15,7 +15,7 @@ "electron-is-dev": "^1.0.1", "electron-log": "^2.2.17", "electron-updater": "^4.0.4", - "electron-window-state": "^5.0.2" + "electron-window-state": "^5.0.3" }, "devDependencies": { "electron": "^3.0.10", diff --git a/desktop/yarn.lock b/desktop/yarn.lock index af6c78ea65..1a4f9b5f8c 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -615,11 +615,6 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= -deep-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" - integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= - deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -817,12 +812,11 @@ electron-updater@^4.0.4: semver "^5.6.0" source-map-support "^0.5.9" -electron-window-state@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/electron-window-state/-/electron-window-state-5.0.2.tgz#dfc4f7dd0ca2d7116d1e246acf1683b0bdfd45c2" - integrity sha512-fcSS+ZxfY8K14Ig7XI0/PHZ54wBr1LEPEgMTRlFn799xDQJ9UsP8Ti+NNb7JhvRaJBsL7MWvtY6vWBk4BpVfMw== +electron-window-state@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/electron-window-state/-/electron-window-state-5.0.3.tgz#4f36d09e3f953d87aff103bf010f460056050aa8" + integrity sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg== dependencies: - deep-equal "^1.0.1" jsonfile "^4.0.0" mkdirp "^0.5.1" diff --git a/hermes/models/db.js b/hermes/models/db.js deleted file mode 100644 index 00d0ab5514..0000000000 --- a/hermes/models/db.js +++ /dev/null @@ -1,28 +0,0 @@ -// @flow -const IS_PROD = !process.env.FORCE_DEV && process.env.NODE_ENV === 'production'; - -const DEFAULT_CONFIG = { - db: 'spectrum', - max: 20, // Maximum number of connections, default is 1000 - buffer: 1, // Minimum number of connections open at any given moment, default is 50 - timeoutGb: 60 * 1000, // How long should an unused connection stick around, default is an hour, this is a minute -}; - -const PRODUCTION_CONFIG = { - password: process.env.AWS_RETHINKDB_PASSWORD, - host: process.env.AWS_RETHINKDB_URL, - port: process.env.AWS_RETHINKDB_PORT, -}; - -const config = IS_PROD - ? { - ...DEFAULT_CONFIG, - ...PRODUCTION_CONFIG, - } - : { - ...DEFAULT_CONFIG, - }; - -const r = require('rethinkdbdash')(config); - -module.exports = { db: r }; diff --git a/hermes/models/usersSettings.js b/hermes/models/usersSettings.js index 0b490e8cb4..691cdaaffe 100644 --- a/hermes/models/usersSettings.js +++ b/hermes/models/usersSettings.js @@ -1,5 +1,5 @@ // @flow -import { db } from './db'; +import { db } from 'shared/db'; export const deactivateUserEmailNotifications = async (email: string) => { const userId = await db diff --git a/hyperion/renderer/index.js b/hyperion/renderer/index.js index 08ade0bf2d..f4a66e77f1 100644 --- a/hyperion/renderer/index.js +++ b/hyperion/renderer/index.js @@ -50,15 +50,6 @@ const renderer = (req: express$Request, res: express$Response) => { context: { user: req.user || null, loaders: createLoaders(), - getImageSignatureExpiration: () => { - // see api/apollo-server.js - const date = new Date(); - date.setHours(24); - date.setMinutes(0); - date.setSeconds(0); - date.setMilliseconds(0); - return date.getTime(); - }, }, }); diff --git a/mercury/models/db.js b/mercury/models/db.js deleted file mode 100644 index 812b1e58cf..0000000000 --- a/mercury/models/db.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Database setup is done here - */ -const fs = require('fs'); -const path = require('path'); -const IS_PROD = !process.env.FORCE_DEV && process.env.NODE_ENV === 'production'; - -const DEFAULT_CONFIG = { - db: 'spectrum', - max: 20, // Maximum number of connections, default is 1000 - buffer: 1, // Minimum number of connections open at any given moment, default is 50 - timeoutGb: 60 * 1000, // How long should an unused connection stick around, default is an hour, this is a minute -}; - -const PRODUCTION_CONFIG = { - password: process.env.AWS_RETHINKDB_PASSWORD, - host: process.env.AWS_RETHINKDB_URL, - port: process.env.AWS_RETHINKDB_PORT, -}; - -const config = IS_PROD - ? { - ...DEFAULT_CONFIG, - ...PRODUCTION_CONFIG, - } - : { - ...DEFAULT_CONFIG, - }; - -var r = require('rethinkdbdash')(config); - -module.exports = { db: r }; diff --git a/mercury/models/message.js b/mercury/models/message.js index 9cee9dae72..ae36a5494f 100644 --- a/mercury/models/message.js +++ b/mercury/models/message.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getMessage = (id: string): Promise => { return db diff --git a/mercury/models/reaction.js b/mercury/models/reaction.js index 00f71faae3..723abd7d87 100644 --- a/mercury/models/reaction.js +++ b/mercury/models/reaction.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const getAllReactionsInThread = ( messageIds: Array diff --git a/mercury/models/reputationEvent.js b/mercury/models/reputationEvent.js index 7f234e918b..26dd07eec2 100644 --- a/mercury/models/reputationEvent.js +++ b/mercury/models/reputationEvent.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); export const saveReputationEvent = ({ userId, diff --git a/mercury/models/thread.js b/mercury/models/thread.js index f21d22b4d5..83ff68a448 100644 --- a/mercury/models/thread.js +++ b/mercury/models/thread.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import type { DBThread } from 'shared/types'; export const getThread = (id: string): Promise => { @@ -32,18 +32,12 @@ export const getParticipantCountByTime = ( ): Promise => { return db .table('messages') - .getAll(threadId, { index: 'threadId' }) - .filter( - db.row - .hasFields('deletedAt') - .not() - .and( - db - .row('timestamp') - .ge(db.now().sub(timeRanges[range].start)) - .and(db.row('timestamp').le(db.now().sub(timeRanges[range].end))) - ) + .between( + [threadId, db.now().sub(timeRanges[range].start)], + [threadId, db.now().sub(timeRanges[range].end)], + { index: 'threadIdAndTimestamp' } ) + .filter(db.row.hasFields('deletedAt').not()) .map(rec => rec('senderId')) .distinct() .count() diff --git a/mercury/models/usersCommunities.js b/mercury/models/usersCommunities.js index aa7e23c6a1..6fa013ef79 100644 --- a/mercury/models/usersCommunities.js +++ b/mercury/models/usersCommunities.js @@ -1,5 +1,5 @@ // @flow -const { db } = require('./db'); +const { db } = require('shared/db'); import { saveReputationEvent } from './reputationEvent'; export const updateReputation = ( diff --git a/package.json b/package.json index c5fd8ab8f6..0379b3cb57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Spectrum", - "version": "2.4.77", + "version": "2.4.78", "license": "BSD-3-Clause", "devDependencies": { "@babel/preset-flow": "^7.0.0", @@ -156,7 +156,7 @@ "react-image": "^1.5.1", "react-infinite-scroller-with-scroll-element": "2.0.2", "react-loadable": "^5.5.0", - "react-modal": "3.x", + "react-modal": "^3.6.1", "react-popper": "^1.0.2", "react-redux": "^5.0.2", "react-router": "^4.0.0-beta.7", @@ -198,7 +198,8 @@ "draft-js": "npm:draft-js-fork-mxstbr", "jest-environment-node": "22.4.3", "jest": "22.4.3", - "fbjs": "0.8.16" + "fbjs": "0.8.16", + "event-stream": "3.3.4" }, "scripts": { "start": "cross-env NODE_ENV=production node build-hyperion/main.js", diff --git a/shared/bull/types.js b/shared/bull/types.js index 59f4e1de51..96fd6a2fc5 100644 --- a/shared/bull/types.js +++ b/shared/bull/types.js @@ -181,12 +181,11 @@ export type SendNewDirectMessageEmailJobData = { recipient: { email: string, name: string, - username: string, + username: ?string, userId: string, }, user: { - displayName: string, - username: string, + username: ?string, id: string, name: string, }, diff --git a/shared/db/queries/user.js b/shared/db/queries/user.js index 8500ead585..891905e9e7 100644 --- a/shared/db/queries/user.js +++ b/shared/db/queries/user.js @@ -241,8 +241,7 @@ export const getEverything = (userId: string, options: PaginationOptions): Promi const { first, after } = options return db .table('usersChannels') - .getAll(userId, { index: 'userId' }) - .filter(userChannel => userChannel('isMember').eq(true)) + .getAll([userId, "member"], [userId, "owner"], [userId, "moderator"], { index: 'userIdAndRole' }) .map(userChannel => userChannel('channelId')) .run() .then( diff --git a/shared/graphql/fragments/thread/threadInfo.js b/shared/graphql/fragments/thread/threadInfo.js index e4d6efd482..e61180aeda 100644 --- a/shared/graphql/fragments/thread/threadInfo.js +++ b/shared/graphql/fragments/thread/threadInfo.js @@ -45,6 +45,7 @@ export type ThreadInfoType = { }, attachments: Array, watercooler: boolean, + metaImage: string, reactions: { count: number, hasReacted: boolean, @@ -82,6 +83,7 @@ export default gql` data } watercooler + metaImage reactions { count hasReacted diff --git a/shared/imgix/getDefaultExpires.js b/shared/imgix/getDefaultExpires.js new file mode 100644 index 0000000000..d95b03843c --- /dev/null +++ b/shared/imgix/getDefaultExpires.js @@ -0,0 +1,17 @@ +// @flow + +/* +Expire images sent to the client at midnight each day (UTC). +Expiration needs to be consistent across all images in order +to preserve client-side caching abilities and to prevent checksum +mismatches during SSR +*/ + +export const getDefaultExpires = () => { + const date = new Date(); + date.setHours(24); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date.getTime(); +}; diff --git a/shared/imgix/index.js b/shared/imgix/index.js index f61ab6a8c0..4d109add24 100644 --- a/shared/imgix/index.js +++ b/shared/imgix/index.js @@ -1,59 +1,24 @@ // @flow -require('now-env'); -import ImgixClient from 'imgix-core-js'; -import decodeUriComponent from 'decode-uri-component'; - -const IS_PROD = process.env.NODE_ENV === 'production'; -export const LEGACY_PREFIX = 'https://spectrum.imgix.net/'; -const EXPIRATION_TIME = 60 * 60 * 10; - -// prettier-ignore -const isLocalUpload = (url: string): boolean => url.startsWith('/uploads/', 0) && !IS_PROD -// prettier-ignore -export const hasLegacyPrefix = (url: string): boolean => url.startsWith(LEGACY_PREFIX, 0) -// prettier-ignore -const useProxy = (url: string): boolean => url.indexOf('spectrum.imgix.net') < 0 && url.startsWith('http', 0) - -/* - When an image is uploaded to s3, we generate a url to be stored in our db - that looks like: https://spectrum.imgix.net/users/:id/foo.png - - Because we are able to proxy our s3 bucket to imgix, we technically only - needed to store the '/users/...' path. But since legacy threads and messages - contain the full url, it must be stripped in order to generate a *new* signed - url in this utility -*/ -// prettier-ignore -export const stripLegacyPrefix = (url: string): string => url.replace(LEGACY_PREFIX, '') - -const signPrimary = (url: string, opts: Object = {}): string => { - const client = new ImgixClient({ - domains: ['spectrum.imgix.net'], - secureURLToken: process.env.IMGIX_SECURITY_KEY, - }); - return client.buildURL(url, opts); -}; - -const signProxy = (url: string, opts?: any = {}): string => { - const client = new ImgixClient({ - domains: ['spectrum-proxy.imgix.net'], - secureURLToken: process.env.IMGIX_PROXY_SECURITY_KEY, - }); - return client.buildURL(url, opts); -}; - -type Opts = { - expires: number, -}; - -export const signImageUrl = (url: string, opts: Opts) => { - if (!url) return null; - - if (isLocalUpload(url)) return url; - - const processedUrl = hasLegacyPrefix(url) ? stripLegacyPrefix(url) : url; - - // we never have to worry about escaping or unescaping proxied urls e.g. twitter images - if (useProxy(url)) return signProxy(processedUrl, opts); - return signPrimary(processedUrl, opts); +import { + signImageUrl, + stripLegacyPrefix, + hasLegacyPrefix, + LEGACY_PREFIX, +} from './sign'; +import { getDefaultExpires } from './getDefaultExpires'; +import { signCommunity } from './signCommunity'; +import { signThread } from './signThread'; +import { signUser } from './signUser'; +import { signMessage } from './signMessage'; + +export { + getDefaultExpires, + LEGACY_PREFIX, + stripLegacyPrefix, + hasLegacyPrefix, + signImageUrl, + signCommunity, + signThread, + signUser, + signMessage, }; diff --git a/shared/imgix/sign.js b/shared/imgix/sign.js new file mode 100644 index 0000000000..932bfef359 --- /dev/null +++ b/shared/imgix/sign.js @@ -0,0 +1,72 @@ +// @flow +require('now-env'); +import ImgixClient from 'imgix-core-js'; +import decodeUriComponent from 'decode-uri-component'; +import { getDefaultExpires } from './getDefaultExpires'; + +const IS_PROD = process.env.NODE_ENV === 'production'; +export const LEGACY_PREFIX = 'https://spectrum.imgix.net/'; + +// prettier-ignore +const isLocalUpload = (url: string): boolean => url.startsWith('/uploads/', 0) && !IS_PROD +// prettier-ignore +export const hasLegacyPrefix = (url: string): boolean => url.startsWith(LEGACY_PREFIX, 0) +// prettier-ignore +const useProxy = (url: string): boolean => url.indexOf('spectrum.imgix.net') < 0 && url.startsWith('http', 0) + +/* + When an image is uploaded to s3, we generate a url to be stored in our db + that looks like: https://spectrum.imgix.net/users/:id/foo.png + + Because we are able to proxy our s3 bucket to imgix, we technically only + needed to store the '/users/...' path. But since legacy threads and messages + contain the full url, it must be stripped in order to generate a *new* signed + url in this utility +*/ +// prettier-ignore +export const stripLegacyPrefix = (url: string): string => url.replace(LEGACY_PREFIX, '') + +type Opts = { + expires: ?number, +}; + +const defaultOpts = { + expires: getDefaultExpires(), +}; + +const signPrimary = (url: string, opts: Opts = defaultOpts): string => { + const client = new ImgixClient({ + domains: ['spectrum.imgix.net'], + secureURLToken: process.env.IMGIX_SECURITY_KEY, + }); + return client.buildURL(url, opts); +}; + +const signProxy = (url: string, opts?: Opts = defaultOpts): string => { + const client = new ImgixClient({ + domains: ['spectrum-proxy.imgix.net'], + secureURLToken: process.env.IMGIX_PROXY_SECURITY_KEY, + }); + return client.buildURL(url, opts); +}; + +export const signImageUrl = (url: string, opts: Opts = defaultOpts): string => { + if (!url) return ''; + if (!opts.expires) { + opts['expires'] = defaultOpts.expires; + } + + if (isLocalUpload(url)) return url; + + const processedUrl = hasLegacyPrefix(url) ? stripLegacyPrefix(url) : url; + + try { + // we never have to worry about escaping or unescaping proxied urls e.g. twitter images + if (useProxy(url)) return signProxy(processedUrl, opts); + return signPrimary(processedUrl, opts); + } catch (err) { + // if something fails, dont crash the entire frontend, just fail the images + console.error(err); + return ''; + } +}; diff --git a/shared/imgix/signCommunity.js b/shared/imgix/signCommunity.js new file mode 100644 index 0000000000..4fe7b01f04 --- /dev/null +++ b/shared/imgix/signCommunity.js @@ -0,0 +1,14 @@ +// @flow +import type { DBCommunity } from 'shared/types'; +import { signImageUrl } from 'shared/imgix'; + +// prettier-ignore +export const signCommunity = (community: DBCommunity, expires?: number): DBCommunity => { + const { profilePhoto, coverPhoto, ...rest } = community; + + return { + ...rest, + profilePhoto: signImageUrl(profilePhoto, { w: 256, h: 256, expires }), + coverPhoto: signImageUrl(coverPhoto, { w: 1280, h: 384, expires }), + }; +}; diff --git a/shared/imgix/signMessage.js b/shared/imgix/signMessage.js new file mode 100644 index 0000000000..9cd1d4a2f6 --- /dev/null +++ b/shared/imgix/signMessage.js @@ -0,0 +1,18 @@ +// @flow +import type { DBMessage } from 'shared/types'; +import { signImageUrl } from 'shared/imgix'; + +export const signMessage = ( + message: DBMessage, + expires?: number +): DBMessage => { + const { content, messageType } = message; + if (messageType !== 'media') return message; + return { + ...message, + content: { + ...content, + body: signImageUrl(message.content.body, { expires }), + }, + }; +}; diff --git a/shared/imgix/signThread.js b/shared/imgix/signThread.js new file mode 100644 index 0000000000..db29bc181b --- /dev/null +++ b/shared/imgix/signThread.js @@ -0,0 +1,51 @@ +// @flow +import type { DBThread } from 'shared/types'; +import { signImageUrl } from 'shared/imgix'; + +const signBody = (body?: string, expires?: number): string => { + if (!body) { + return JSON.stringify({ + blocks: [ + { + key: 'foo', + text: '', + type: 'unstyled', + depth: 0, + inlineStyleRanges: [], + entityRanges: [], + data: {}, + }, + ], + entityMap: {}, + }); + } + + const returnBody = JSON.parse(body); + + const imageKeys = Object.keys(returnBody.entityMap).filter( + key => returnBody.entityMap[key].type.toLowerCase() === 'image' + ); + + imageKeys.forEach((key, index) => { + if (!returnBody.entityMap[key]) return; + + const { src } = returnBody.entityMap[key].data; + + // transform the body inline with signed image urls + returnBody.entityMap[key].data.src = signImageUrl(src, { expires }); + }); + + return JSON.stringify(returnBody); +}; + +export const signThread = (thread: DBThread, expires?: number): DBThread => { + const { content, ...rest } = thread; + + return { + ...rest, + content: { + ...content, + body: signBody(content.body, expires), + }, + }; +}; diff --git a/shared/imgix/signUser.js b/shared/imgix/signUser.js new file mode 100644 index 0000000000..06e76e92ff --- /dev/null +++ b/shared/imgix/signUser.js @@ -0,0 +1,21 @@ +// @flow +import type { DBUser } from 'shared/types'; +import { signImageUrl } from 'shared/imgix'; + +export const signUser = (user: DBUser, expires?: number): DBUser => { + const { profilePhoto, coverPhoto, ...rest } = user; + + return { + ...rest, + profilePhoto: signImageUrl(profilePhoto, { + w: 256, + h: 256, + expires, + }), + coverPhoto: signImageUrl(coverPhoto, { + w: 1280, + h: 384, + expires, + }), + }; +}; diff --git a/src/components/avatar/communityAvatar.js b/src/components/avatar/communityAvatar.js index 938cfb4a7f..3aeb14b2e7 100644 --- a/src/components/avatar/communityAvatar.js +++ b/src/components/avatar/communityAvatar.js @@ -12,7 +12,7 @@ type Props = { mobilesize?: number, style?: Object, showHoverProfile?: boolean, - clickable?: boolean, + isClickable?: boolean, }; class Avatar extends React.Component { @@ -20,7 +20,7 @@ class Avatar extends React.Component { const { community, size = 32, - clickable = true, + isClickable = true, mobilesize, style, } = this.props; @@ -38,7 +38,7 @@ class Avatar extends React.Component { type={'community'} > ( {children} )} diff --git a/src/components/avatar/image.js b/src/components/avatar/image.js index 340864675c..6c59c4f561 100644 --- a/src/components/avatar/image.js +++ b/src/components/avatar/image.js @@ -8,11 +8,13 @@ type Props = { type: 'user' | 'community', size: number, mobilesize?: number, + isClickable?: boolean, }; export default class Image extends React.Component { render() { const { type, size, mobilesize } = this.props; + const { isClickable, ...rest } = this.props; const fallbackSrc = type === 'user' ? '/img/default_avatar.svg' @@ -21,7 +23,7 @@ export default class Image extends React.Component { return ( { mobilesize, style, showOnlineStatus = true, - clickable = true, + isClickable = true, onlineBorderColor = null, } = this.props; @@ -82,7 +82,7 @@ class Avatar extends React.Component { )} ( { class AvatarHandler extends React.Component { render() { - const { showHoverProfile = true, clickable } = this.props; + const { showHoverProfile = true, isClickable } = this.props; if (this.props.user) { const user = this.props.user; @@ -130,7 +130,7 @@ class AvatarHandler extends React.Component { return ( ); } diff --git a/src/components/formElements/index.js b/src/components/formElements/index.js index b12722c17c..a742d7d6af 100644 --- a/src/components/formElements/index.js +++ b/src/components/formElements/index.js @@ -126,11 +126,7 @@ export const CoverInput = (props: CoverPhotoInputProps) => { { ref: ?any; ref = null; - state = { visible: false, isMounted: false }; + state = { visible: false }; + _isMounted = false; componentDidMount() { - this.setState({ isMounted: true }); + this._isMounted = true; } componentWillUnmount() { - this.setState({ isMounted: false }); + this._isMounted = false; } handleMouseEnter = () => { const { client, id } = this.props; - client.query({ - query: getCommunityByIdQuery, - variables: { id }, - }); + if (!this._isMounted) return; + + client + .query({ + query: getCommunityByIdQuery, + variables: { id }, + }) + .then(() => { + if (!this._isMounted) return; + }); const ref = setTimeout(() => { - return this.state.isMounted && this.setState({ visible: true }); + if (this._isMounted) { + return this.setState({ visible: true }); + } }, 500); + this.ref = ref; }; @@ -76,7 +85,7 @@ class CommunityHoverProfileWrapper extends React.Component { clearTimeout(this.ref); } - if (this.state.isMounted && this.state.visible) { + if (this._isMounted && this.state.visible) { this.setState({ visible: false }); } }; diff --git a/src/components/hoverProfile/communityProfile.js b/src/components/hoverProfile/communityProfile.js index 542688fc22..5f63e0af24 100644 --- a/src/components/hoverProfile/communityProfile.js +++ b/src/components/hoverProfile/communityProfile.js @@ -49,7 +49,7 @@ class HoverProfile extends Component { src={community.profilePhoto} type={'community'} size={40} - clickable={false} + isClickable={false} /> diff --git a/src/components/hoverProfile/userContainer.js b/src/components/hoverProfile/userContainer.js index f7c7b64d56..9957989813 100644 --- a/src/components/hoverProfile/userContainer.js +++ b/src/components/hoverProfile/userContainer.js @@ -44,32 +44,40 @@ type Props = { type State = { visible: boolean, - isMounted: boolean, }; class UserHoverProfileWrapper extends React.Component { ref: ?any; ref = null; - state = { visible: false, isMounted: false }; + state = { visible: false }; + _isMounted = false; componentDidMount() { - this.setState({ isMounted: true }); + this._isMounted = true; } componentWillUnmount() { - this.setState({ isMounted: false }); + this._isMounted = false; } handleMouseEnter = () => { const { username, client } = this.props; - client.query({ - query: getUserByUsernameQuery, - variables: { username }, - }); + if (!this._isMounted) return; + + client + .query({ + query: getUserByUsernameQuery, + variables: { username }, + }) + .then(() => { + if (!this._isMounted) return; + }); const ref = setTimeout(() => { - return this.state.isMounted && this.setState({ visible: true }); + if (this._isMounted) { + return this.setState({ visible: true }); + } }, 500); this.ref = ref; }; @@ -79,7 +87,7 @@ class UserHoverProfileWrapper extends React.Component { clearTimeout(this.ref); } - if (this.state.isMounted && this.state.visible) { + if (this._isMounted && this.state.visible) { this.setState({ visible: false }); } }; diff --git a/src/components/listItems/index.js b/src/components/listItems/index.js index 724bae9300..17b8027967 100644 --- a/src/components/listItems/index.js +++ b/src/components/listItems/index.js @@ -39,7 +39,7 @@ export class CommunityListItem extends React.Component { community={community} size={32} showHoverProfile={false} - clickable={false} + isClickable={false} /> {community.name} @@ -63,7 +63,7 @@ export class CommunityListItem extends React.Component { } type CardProps = { - clickable?: boolean, + isClickable?: boolean, contents: any, meta?: string, children?: any, @@ -71,7 +71,7 @@ type CardProps = { export const ThreadListItem = (props: CardProps): React$Element => { return ( - + {props.contents.content.title} diff --git a/src/components/listItems/style.js b/src/components/listItems/style.js index d43e6ea46c..3635f5e8bc 100644 --- a/src/components/listItems/style.js +++ b/src/components/listItems/style.js @@ -28,7 +28,7 @@ export const Wrapper = styled(FlexCol)` &:hover > div > div h3, &:hover .action { - color: ${props => (props.clickable ? props.theme.brand.alt : '')}; + color: ${props => (props.isClickable ? props.theme.brand.alt : '')}; } `; diff --git a/src/components/modals/DeleteDoubleCheckModal/index.js b/src/components/modals/DeleteDoubleCheckModal/index.js index 71e858fb68..cd063e7517 100644 --- a/src/components/modals/DeleteDoubleCheckModal/index.js +++ b/src/components/modals/DeleteDoubleCheckModal/index.js @@ -15,6 +15,7 @@ import type { DeleteThreadType } from 'shared/graphql/mutations/thread/deleteThr import deleteMessage from 'shared/graphql/mutations/message/deleteMessage'; import type { DeleteMessageType } from 'shared/graphql/mutations/message/deleteMessage'; import archiveChannel from 'shared/graphql/mutations/channel/archiveChannel'; +import removeCommunityMember from 'shared/graphql/mutations/communityMember/removeCommunityMember'; import ModalContainer from '../modalContainer'; import { TextButton, Button } from '../../buttons'; @@ -55,6 +56,7 @@ type Props = { deleteThread: Function, deleteChannel: Function, archiveChannel: Function, + removeCommunityMember: Function, dispatch: Dispatch, isOpen: boolean, }; @@ -207,6 +209,23 @@ class DeleteDoubleCheckModal extends React.Component { }); }); } + case 'team-member-leaving-community': { + return this.props + .removeCommunityMember({ input: { communityId: id } }) + .then(() => { + dispatch(addToastWithTimeout('neutral', 'Left community')); + this.setState({ + isLoading: false, + }); + return this.close(); + }) + .catch(err => { + dispatch(addToastWithTimeout('error', err.message)); + this.setState({ + isLoading: false, + }); + }); + } default: { this.setState({ isLoading: false, @@ -272,6 +291,7 @@ const DeleteDoubleCheckModalWithMutations = compose( deleteThreadMutation, deleteMessage, archiveChannel, + removeCommunityMember, withRouter )(DeleteDoubleCheckModal); diff --git a/src/components/profile/community.js b/src/components/profile/community.js index daf33bd31c..470a49a997 100644 --- a/src/components/profile/community.js +++ b/src/components/profile/community.js @@ -89,7 +89,7 @@ class CommunityWithData extends React.Component { community={community} showHoverProfile={showHoverProfile} size={64} - clickable={false} + isClickable={false} style={{ boxShadow: '0 0 0 2px #fff', flex: '0 0 64px', diff --git a/src/components/segmentedControl/index.js b/src/components/segmentedControl/index.js index 5686c39a80..53e0c26bc9 100644 --- a/src/components/segmentedControl/index.js +++ b/src/components/segmentedControl/index.js @@ -12,6 +12,8 @@ export const SegmentedControl = styled(FlexRow)` min-height: 48px; @media (max-width: 768px) { + overflow-y: hidden; + overflow-x: scroll; background-color: ${theme.bg.default}; align-self: stretch; margin: 0; diff --git a/src/components/threadFeed/index.js b/src/components/threadFeed/index.js index a7eae4c5d3..223e4cf3c9 100644 --- a/src/components/threadFeed/index.js +++ b/src/components/threadFeed/index.js @@ -19,6 +19,11 @@ import type { GetCommunityType } from 'shared/graphql/queries/community/getCommu import type { Dispatch } from 'redux'; import { ErrorBoundary } from 'src/components/error'; import { withCurrentUser } from 'src/components/withCurrentUser'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; const NullState = ({ viewContext, search }) => { let hd; @@ -137,6 +142,7 @@ type Props = { community?: any, channel?: any, threads?: Array, + refetch: Function, }, community: GetCommunityType, setThreadsStatus: Function, @@ -155,6 +161,9 @@ type Props = { newActivityIndicator: ?boolean, dispatch: Dispatch, search?: boolean, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; type State = { @@ -184,8 +193,11 @@ class ThreadFeedPure extends React.Component { } }; - shouldComponentUpdate(nextProps) { + shouldComponentUpdate(nextProps: Props) { const curr = this.props; + if (curr.networkOnline !== nextProps.networkOnline) return true; + if (curr.websocketConnection !== nextProps.websocketConnection) return true; + if (curr.pageVisibility !== nextProps.pageVisibility) return true; // fetching more if (curr.data.networkStatus === 7 && nextProps.data.networkStatus === 3) return false; @@ -208,11 +220,16 @@ class ThreadFeedPure extends React.Component { this.subscribe(); } - componentDidUpdate(prevProps) { + componentDidUpdate(prev: Props) { const curr = this.props; + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } + if ( - !prevProps.data.thread && + !prev.data.thread && curr.data.threads && curr.data.threads.length === 0 ) { @@ -370,6 +387,9 @@ class ThreadFeedPure extends React.Component { const map = state => ({ newActivityIndicator: state.newActivityIndicator.hasNew, + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, }); const ThreadFeed = compose( // $FlowIssue diff --git a/src/components/toggleCommunityMembership/index.js b/src/components/toggleCommunityMembership/index.js index ad156736fc..4fcaeb3568 100644 --- a/src/components/toggleCommunityMembership/index.js +++ b/src/components/toggleCommunityMembership/index.js @@ -9,6 +9,7 @@ import { addToastWithTimeout } from '../../actions/toasts'; import type { AddCommunityMemberType } from 'shared/graphql/mutations/communityMember/addCommunityMember'; import type { RemoveCommunityMemberType } from 'shared/graphql/mutations/communityMember/removeCommunityMember'; import type { Dispatch } from 'redux'; +import { openModal } from 'src/actions/modals'; type Props = { community: { @@ -30,6 +31,32 @@ class ToggleCommunityMembership extends React.Component { init = () => { const { community } = this.props; + // warn team members before leaving a community they moderator + if (community.communityPermissions.isModerator) { + return this.props.dispatch( + openModal('DELETE_DOUBLE_CHECK_MODAL', { + id: community.id, + entity: 'team-member-leaving-community', + message: + 'You are a team member of this community. If you leave you will no longer be able to moderate this community.', + buttonLabel: 'Leave Community', + }) + ); + } + + // warn all other members before leaving + if (community.communityPermissions.isMember) { + return this.props.dispatch( + openModal('DELETE_DOUBLE_CHECK_MODAL', { + id: community.id, + entity: 'team-member-leaving-community', + buttonLabel: 'Leave Community', + message: + 'Are you sure you want to leave this community? You will no longer see conversations in your feed or get updates about new activity.', + }) + ); + } + const action = community.communityPermissions.isMember ? this.removeMember : this.addMember; diff --git a/src/hooks/useConnectionRestored.js b/src/hooks/useConnectionRestored.js new file mode 100644 index 0000000000..e3bcb00f2f --- /dev/null +++ b/src/hooks/useConnectionRestored.js @@ -0,0 +1,73 @@ +// @flow +import type { + PageVisibilityType, + WebsocketConnectionType, +} from 'src/reducers/connectionStatus'; + +type ConnectionProps = { + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, +}; + +type Props = { + prev: ConnectionProps, + curr: ConnectionProps, +}; + +const validateProps = (props: Props) => { + if (!props.prev || !props.curr) return false; + if ( + !props.prev.hasOwnProperty('networkOnline') || + !props.curr.hasOwnProperty('networkOnline') + ) { + return false; + } + + if ( + !props.prev.hasOwnProperty('websocketConnection') || + !props.curr.hasOwnProperty('websocketConnection') + ) { + return false; + } + + if ( + !props.prev.hasOwnProperty('pageVisibility') || + !props.curr.hasOwnProperty('pageVisibility') + ) { + return false; + } + + return true; +}; + +const websocketDidReconnect = (props: Props) => { + const { curr, prev } = props; + if ( + prev.websocketConnection === 'reconnecting' && + curr.websocketConnection === 'reconnected' + ) + return true; + return false; +}; + +const networkOnlineDidReconnect = (props: Props) => { + const { curr, prev } = props; + if (prev.networkOnline === false && curr.networkOnline === true) return true; + return false; +}; + +const pageBecameVisible = (props: Props) => { + const { curr, prev } = props; + if (prev.pageVisibility === 'hidden' && curr.pageVisibility === 'visible') + return true; + return false; +}; + +export const useConnectionRestored = (props: Props) => { + if (!validateProps(props)) return false; + if (websocketDidReconnect(props)) return true; + if (networkOnlineDidReconnect(props)) return true; + if (pageBecameVisible(props)) return true; + return false; +}; diff --git a/src/reducers/connectionStatus.js b/src/reducers/connectionStatus.js index c4ce643574..da1919f288 100644 --- a/src/reducers/connectionStatus.js +++ b/src/reducers/connectionStatus.js @@ -1,18 +1,33 @@ +// @flow +export type WebsocketConnectionType = + | 'connected' + | 'connecting' + | 'reconnected' + | 'reconnecting'; + +export type PageVisibilityType = 'visible' | 'hidden'; + type InitialState = { networkOnline: boolean, - websocketConnection: - | 'connected' - | 'connecting' - | 'reconnected' - | 'reconnecting', + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, +}; + +type ActionType = { + type: 'NETWORK_CONNECTION' | 'WEBSOCKET_CONNECTION' | 'PAGE_VISIBILITY', + value: any, }; const initialState: InitialState = { networkOnline: true, websocketConnection: 'connected', + pageVisibility: 'visible', }; -export default function status(state = initialState, action) { +export default function status( + state: InitialState = initialState, + action: ActionType +) { switch (action.type) { case 'NETWORK_CONNECTION': return Object.assign({}, state, { @@ -22,6 +37,10 @@ export default function status(state = initialState, action) { return Object.assign({}, state, { websocketConnection: action.value, }); + case 'PAGE_VISIBILITY': + return Object.assign({}, state, { + pageVisibility: action.value, + }); default: return state; } diff --git a/src/reducers/newUserOnboarding.js b/src/reducers/newUserOnboarding.js index a5e2359c14..8072394210 100644 --- a/src/reducers/newUserOnboarding.js +++ b/src/reducers/newUserOnboarding.js @@ -6,7 +6,7 @@ export default function newUserOnboarding(state = initialState, action) { switch (action.type) { case 'ADD_COMMUNITY_TO_NEW_USER_ONBOARDING': return Object.assign({}, state, { - community: { ...action.payload }, + community: action.payload, }); default: return state; diff --git a/src/views/channel/index.js b/src/views/channel/index.js index 3f9fb688bb..3ac22d5210 100644 --- a/src/views/channel/index.js +++ b/src/views/channel/index.js @@ -47,7 +47,13 @@ import { } from './style'; import { ExtLink, OnlineIndicator } from 'src/components/profile/style'; import { CoverPhoto } from 'src/components/profile/coverPhoto'; -import { LoginButton, ColumnHeading, MidSegment } from '../community/style'; +import { + LoginButton, + ColumnHeading, + MidSegment, + SettingsButton, + LoginOutlineButton, +} from '../community/style'; import ToggleChannelMembership from 'src/components/toggleChannelMembership'; import { track, events, transformations } from 'src/helpers/analytics'; import type { Dispatch } from 'redux'; @@ -167,13 +173,13 @@ class ChannelView extends React.Component { if (isGlobalOwner) { return ( - Settings - + ); } @@ -183,26 +189,37 @@ class ChannelView extends React.Component { ( - - {isChannelMember ? 'Joined' : `Join ${channel.name}`} - - )} + render={state => { + if (isChannelMember) { + return ( + + Leave channel + + ); + } else { + return ( + + Join {channel.name} + + ); + } + }} /> - Settings - + ); @@ -212,16 +229,27 @@ class ChannelView extends React.Component { return ( ( - - {isChannelMember ? 'Joined' : `Join ${channel.name}`} - - )} + render={state => { + if (isChannelMember) { + return ( + + Leave channel + + ); + } else { + return ( + + Join {channel.name} + + ); + } + }} /> ); } @@ -397,9 +425,11 @@ class ChannelView extends React.Component { {channel.metaData.onlineMembers} online )} - - {actionButton} +
+ + {actionButton} + {isLoggedIn && userHasPermissions && diff --git a/src/views/community/index.js b/src/views/community/index.js index 89f0ba7daa..85d14f529d 100644 --- a/src/views/community/index.js +++ b/src/views/community/index.js @@ -32,6 +32,8 @@ import { } from 'src/components/segmentedControl'; import { LoginButton, + LoginOutlineButton, + SettingsButton, Grid, Meta, Content, @@ -99,25 +101,22 @@ class CommunityView extends React.Component { } componentDidUpdate(prevProps) { + const { community: prevCommunity } = prevProps.data; + const { community: currCommunity } = this.props.data; if ( - (!prevProps.data.community && - this.props.data.community && - this.props.data.community.id) || - (prevProps.data.community && - prevProps.data.community.id !== this.props.data.community.id) + (!prevCommunity && currCommunity && currCommunity.id) || + (prevCommunity && prevCommunity.id !== currCommunity.id) ) { track(events.COMMUNITY_VIEWED, { - community: transformations.analyticsCommunity( - this.props.data.community - ), + community: transformations.analyticsCommunity(currCommunity), }); // if the user is new and signed up through a community page, push // the community data into the store to hydrate the new user experience // with their first community they should join - if (this.props.currentUser) return; - - this.props.dispatch(addCommunityToOnboarding(this.props.data.community)); + if (!this.props.currentUser || !this.props.currentUser.username) { + return this.props.dispatch(addCommunityToOnboarding(currCommunity)); + } } } @@ -293,32 +292,40 @@ class CommunityView extends React.Component { ) : !isOwner ? ( ( - - {isMember ? 'Member' : `Join ${community.name}`} - - )} + render={state => { + if (isMember) { + return ( + + Leave community + + ); + } else { + return ( + + Join {community.name} + + ); + } + }} /> ) : null} {currentUser && (isOwner || isModerator) && ( - Settings - + )} @@ -326,8 +333,6 @@ class CommunityView extends React.Component { - props.isMember ? props.theme.text.alt : props.theme.success.default}; + margin-top: 16px; + background-color: ${props => props.theme.success.default}; background-image: ${props => - props.isMember - ? Gradient(props.theme.text.placeholder, props.theme.text.alt) - : Gradient(props.theme.success.alt, props.theme.success.default)}; + Gradient(props.theme.success.alt, props.theme.success.default)}; +`; + +export const LoginOutlineButton = styled(OutlineButton)` + width: 100%; + margin-top: 16px; + color: ${props => props.theme.text.alt}; + box-shadow: 0 0 1px ${props => props.theme.text.alt}; + + &:hover { + color: ${props => props.theme.warn.default}; + box-shadow: 0 0 1px ${props => props.theme.warn.default}; + } +`; + +export const SettingsButton = styled(LoginOutlineButton)` + justify-content: center; + &:hover { + color: ${props => props.theme.text.secondary}; + box-shadow: 0 0 1px ${props => props.theme.text.secondary}; + } `; export const CoverButton = styled(IconButton)` diff --git a/src/views/communityLogin/index.js b/src/views/communityLogin/index.js index 2af4343667..20722deed2 100644 --- a/src/views/communityLogin/index.js +++ b/src/views/communityLogin/index.js @@ -36,26 +36,26 @@ type Props = { }; export class Login extends React.Component { + redirectPath = null; + escape = () => { this.props.history.push(`/${this.props.match.params.communitySlug}`); }; componentDidMount() { const { location } = this.props; - let redirectPath; if (location) { const searchObj = queryString.parse(this.props.location.search); - redirectPath = searchObj.r; + this.redirectPath = searchObj.r; } - track(events.LOGIN_PAGE_VIEWED, { redirectPath }); + track(events.LOGIN_PAGE_VIEWED, { redirectPath: this.redirectPath }); } render() { const { data: { community }, isLoading, - redirectPath, match, } = this.props; @@ -84,7 +84,8 @@ export class Login extends React.Component { diff --git a/src/views/dashboard/components/threadFeed.js b/src/views/dashboard/components/threadFeed.js index bbe21bd6dd..4974de3622 100644 --- a/src/views/dashboard/components/threadFeed.js +++ b/src/views/dashboard/components/threadFeed.js @@ -8,21 +8,26 @@ import { connect } from 'react-redux'; import InfiniteList from 'src/components/infiniteScroll'; import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; import FlipMove from 'react-flip-move'; -import { sortByDate } from '../../../helpers/utils'; -import { LoadingInboxThread } from '../../../components/loading'; -import { changeActiveThread } from '../../../actions/dashboardFeed'; +import { sortByDate } from 'src/helpers/utils'; +import { LoadingInboxThread } from 'src/components/loading'; +import { changeActiveThread } from 'src/actions/dashboardFeed'; import LoadingThreadFeed from './loadingThreadFeed'; import ErrorThreadFeed from './errorThreadFeed'; import EmptyThreadFeed from './emptyThreadFeed'; import EmptySearchFeed from './emptySearchFeed'; import InboxThread from './inboxThread'; import DesktopAppUpsell from './desktopAppUpsell'; -import viewNetworkHandler from '../../../components/viewNetworkHandler'; -import type { ViewNetworkHandlerType } from '../../../components/viewNetworkHandler'; +import viewNetworkHandler from 'src/components/viewNetworkHandler'; +import type { ViewNetworkHandlerType } from 'src/components/viewNetworkHandler'; import type { GetThreadType } from 'shared/graphql/queries/thread/getThread'; import type { GetCommunityThreadConnectionType } from 'shared/graphql/queries/community/getCommunityThreadConnection'; import type { Dispatch } from 'redux'; import { ErrorBoundary } from 'src/components/error'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; type Node = { node: { @@ -43,6 +48,7 @@ type Props = { networkStatus: number, hasNextPage: boolean, feed: string, + refetch: Function, }, history: Function, dispatch: Dispatch, @@ -50,6 +56,9 @@ type Props = { activeCommunity: ?string, activeChannel: ?string, hasActiveCommunity: boolean, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; type State = { @@ -81,6 +90,9 @@ class ThreadFeed extends React.Component { shouldComponentUpdate(nextProps) { const curr = this.props; + if (curr.networkOnline !== nextProps.networkOnline) return true; + if (curr.websocketConnection !== nextProps.websocketConnection) return true; + if (curr.pageVisibility !== nextProps.pageVisibility) return true; // fetching more if (curr.data.networkStatus === 7 && nextProps.isFetchingMore) return false; return true; @@ -94,10 +106,16 @@ class ThreadFeed extends React.Component { } }; - componentDidUpdate(prevProps) { + componentDidUpdate(prev) { const isDesktop = window.innerWidth > 768; const { scrollElement } = this.state; - const { mountedWithActiveThread, queryString } = this.props; + const curr = this.props; + const { mountedWithActiveThread, queryString } = curr; + + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } // user is searching, don't select anything if (queryString) { @@ -106,78 +124,77 @@ class ThreadFeed extends React.Component { // If we mount with ?t and are on mobile, we have to redirect to ?thread if (!isDesktop && mountedWithActiveThread) { - this.props.history.replace(`/?thread=${mountedWithActiveThread}`); - this.props.dispatch({ type: 'REMOVE_MOUNTED_THREAD_ID' }); + curr.history.replace(`/?thread=${mountedWithActiveThread}`); + curr.dispatch({ type: 'REMOVE_MOUNTED_THREAD_ID' }); return; } - const hasThreadsButNoneSelected = - this.props.data.threads && !this.props.selectedId; + const hasThreadsButNoneSelected = curr.data.threads && !curr.selectedId; const justLoadedThreads = !mountedWithActiveThread && - ((!prevProps.data.threads && this.props.data.threads) || - (prevProps.data.loading && !this.props.data.loading)); + ((!prev.data.threads && curr.data.threads) || + (prev.data.loading && !curr.data.loading)); // if the app loaded with a ?t query param, it means the user was linked to a thread from the inbox view and is already logged in. In this case we want to load the thread identified in the url and ignore the fact that a feed is loading in which auto-selects a different thread. if (justLoadedThreads && mountedWithActiveThread) { - this.props.dispatch({ type: 'REMOVE_MOUNTED_THREAD_ID' }); + curr.dispatch({ type: 'REMOVE_MOUNTED_THREAD_ID' }); return; } // don't select a thread if the composer is open - if (prevProps.selectedId === 'new') { + if (prev.selectedId === 'new') { return; } if ( isDesktop && (hasThreadsButNoneSelected || justLoadedThreads) && - this.props.data.threads.length > 0 && - !prevProps.isFetchingMore + curr.data.threads.length > 0 && + !prev.isFetchingMore ) { if ( - (this.props.data.community && - this.props.data.community.watercooler && - this.props.data.community.watercooler.id) || - (this.props.data.community && - this.props.data.community.pinnedThread && - this.props.data.community.pinnedThread.id) + (curr.data.community && + curr.data.community.watercooler && + curr.data.community.watercooler.id) || + (curr.data.community && + curr.data.community.pinnedThread && + curr.data.community.pinnedThread.id) ) { - const selectId = this.props.data.community.watercooler - ? this.props.data.community.watercooler.id - : this.props.data.community.pinnedThread.id; + const selectId = curr.data.community.watercooler + ? curr.data.community.watercooler.id + : curr.data.community.pinnedThread.id; - this.props.history.replace(`/?t=${selectId}`); - this.props.dispatch(changeActiveThread(selectId)); + curr.history.replace(`/?t=${selectId}`); + curr.dispatch(changeActiveThread(selectId)); return; } - const threadNodes = this.props.data.threads + const threadNodes = curr.data.threads .slice() .map(thread => thread && thread.node); const sortedThreadNodes = sortByDate(threadNodes, 'lastActive', 'desc'); const hasFirstThread = sortedThreadNodes.length > 0; const firstThreadId = hasFirstThread ? sortedThreadNodes[0].id : ''; if (hasFirstThread) { - this.props.history.replace(`/?t=${firstThreadId}`); - this.props.dispatch(changeActiveThread(firstThreadId)); + curr.history.replace(`/?t=${firstThreadId}`); + curr.dispatch(changeActiveThread(firstThreadId)); } } // if the user changes the feed from all to a specific community, we need to reset the active thread in the inbox and reset our subscription for updates if ( - (!prevProps.data.feed && this.props.data.feed) || - (prevProps.data.feed && prevProps.data.feed !== this.props.data.feed) + (!prev.data.feed && curr.data.feed) || + (prev.data.feed && prev.data.feed !== curr.data.feed) ) { - const threadNodes = this.props.data.threads + const threadNodes = curr.data.threads .slice() .map(thread => thread && thread.node); const sortedThreadNodes = sortByDate(threadNodes, 'lastActive', 'desc'); const hasFirstThread = sortedThreadNodes.length > 0; const firstThreadId = hasFirstThread ? sortedThreadNodes[0].id : ''; if (hasFirstThread) { - this.props.history.replace(`/?t=${firstThreadId}`); - this.props.dispatch(changeActiveThread(firstThreadId)); + curr.history.replace(`/?t=${firstThreadId}`); + curr.dispatch(changeActiveThread(firstThreadId)); } if (scrollElement) { @@ -333,6 +350,9 @@ const map = state => ({ mountedWithActiveThread: state.dashboardFeed.mountedWithActiveThread, activeCommunity: state.dashboardFeed.activeCommunity, activeChannel: state.dashboardFeed.activeChannel, + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, }); export default compose( withRouter, diff --git a/src/views/dashboard/components/upsellExploreCommunities.js b/src/views/dashboard/components/upsellExploreCommunities.js index 0d05b9fbd2..abc05ab734 100644 --- a/src/views/dashboard/components/upsellExploreCommunities.js +++ b/src/views/dashboard/components/upsellExploreCommunities.js @@ -117,7 +117,7 @@ class UpsellExploreCommunities extends React.Component { onClick={() => track(events.INBOX_UPSELL_COMMUNITY_CLICKED)} > - + ); diff --git a/src/views/directMessages/components/avatars.js b/src/views/directMessages/components/avatars.js index 6f39d9661d..dee9f387dc 100644 --- a/src/views/directMessages/components/avatars.js +++ b/src/views/directMessages/components/avatars.js @@ -17,7 +17,7 @@ export const renderAvatars = (users: Array) => { @@ -33,7 +33,7 @@ export const renderAvatars = (users: Array) => { @@ -53,7 +53,7 @@ export const renderAvatars = (users: Array) => { user={user} key={user.id} size={20} - clickable={false} + isClickable={false} showHoverProfile={false} showOnlineStatus={false} /> @@ -72,7 +72,7 @@ export const renderAvatars = (users: Array) => { user={user} key={user.id} size={19} - clickable={false} + isClickable={false} showHoverProfile={false} showOnlineStatus={false} /> @@ -94,7 +94,7 @@ export const renderAvatars = (users: Array) => { user={user} key={user.id} size={19} - clickable={false} + isClickable={false} showHoverProfile={false} showOnlineStatus={false} /> diff --git a/src/views/directMessages/containers/existingThread.js b/src/views/directMessages/containers/existingThread.js index 9904ffff31..4910b6ddb9 100644 --- a/src/views/directMessages/containers/existingThread.js +++ b/src/views/directMessages/containers/existingThread.js @@ -8,14 +8,24 @@ import Messages from '../components/messages'; import Header from '../components/header'; import ChatInput from 'src/components/chatInput'; import viewNetworkHandler from 'src/components/viewNetworkHandler'; -import getDirectMessageThread from 'shared/graphql/queries/directMessageThread/getDirectMessageThread'; +import getDirectMessageThread, { + type GetDirectMessageThreadType, +} from 'shared/graphql/queries/directMessageThread/getDirectMessageThread'; import { MessagesContainer, ViewContent } from '../style'; import { Loading } from 'src/components/loading'; import ViewError from 'src/components/viewError'; import { ErrorBoundary } from 'src/components/error'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; type Props = { - data: Object, + data: { + refetch: Function, + directMessageThread: GetDirectMessageThreadType, + }, isLoading: boolean, setActiveThread: Function, setLastSeen: Function, @@ -23,7 +33,11 @@ type Props = { id: ?string, currentUser: Object, threadSliderIsOpen: boolean, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; + class ExistingThread extends React.Component { scrollBody: ?HTMLDivElement; chatInput: ?ChatInput; @@ -40,29 +54,32 @@ class ExistingThread extends React.Component { } } - componentDidUpdate(prevProps) { + componentDidUpdate(prev) { + const curr = this.props; + + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } + // if the thread slider is open, dont be focusing shit up in heyuhr - if (this.props.threadSliderIsOpen) return; + if (curr.threadSliderIsOpen) return; // if the thread slider is closed and we're viewing DMs, refocus the chat input - if ( - prevProps.threadSliderIsOpen && - !this.props.threadSliderIsOpen && - this.chatInput - ) { + if (prev.threadSliderIsOpen && !curr.threadSliderIsOpen && this.chatInput) { this.chatInput.triggerFocus(); } // as soon as the direct message thread is loaded, refocus the chat input if ( - this.props.data.directMessageThread && - !prevProps.data.directMessageThread && + curr.data.directMessageThread && + !prev.data.directMessageThread && this.chatInput ) { this.chatInput.triggerFocus(); } - if (prevProps.match.params.threadId !== this.props.match.params.threadId) { - const threadId = this.props.match.params.threadId; - this.props.setActiveThread(threadId); - this.props.setLastSeen(threadId); + if (prev.match.params.threadId !== curr.match.params.threadId) { + const threadId = curr.match.params.threadId; + curr.setActiveThread(threadId); + curr.setLastSeen(threadId); this.forceScrollToBottom(); // autofocus on desktop if (window && window.innerWidth > 768 && this.chatInput) { @@ -103,7 +120,6 @@ class ExistingThread extends React.Component { { } } -const map = state => ({ threadSliderIsOpen: state.threadSlider.isOpen }); +const map = state => ({ + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, + threadSliderIsOpen: state.threadSlider.isOpen, +}); export default compose( // $FlowIssue connect(map), diff --git a/src/views/directMessages/containers/index.js b/src/views/directMessages/containers/index.js index bb46610a2d..8e47059964 100644 --- a/src/views/directMessages/containers/index.js +++ b/src/views/directMessages/containers/index.js @@ -17,6 +17,11 @@ import { View, MessagesList, ComposeHeader } from '../style'; import { track, events } from 'src/helpers/analytics'; import type { Dispatch } from 'redux'; import { withCurrentUser } from 'src/components/withCurrentUser'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; type Props = { subscribeToUpdatedDirectMessageThreads: Function, @@ -30,8 +35,13 @@ type Props = { fetchMore: Function, data: { user: GetCurrentUserDMThreadConnectionType, + refetch: Function, }, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; + type State = { activeThread: string, subscription: ?Function, @@ -61,6 +71,25 @@ class DirectMessages extends React.Component { } }; + shouldComponentUpdate(nextProps: Props) { + const curr = this.props; + + // fetching more + if (curr.data.networkStatus === 7 && nextProps.data.networkStatus === 3) + return false; + + return true; + } + + componentDidUpdate(prev: Props) { + const curr = this.props; + + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } + } + componentDidMount() { this.props.markDirectMessageNotificationsSeen(); this.subscribe(); @@ -172,10 +201,17 @@ class DirectMessages extends React.Component { } } +const map = state => ({ + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, +}); + export default compose( withCurrentUser, getCurrentUserDirectMessageThreads, markDirectMessageNotificationsSeenMutation, viewNetworkHandler, - connect() + // $FlowIssue + connect(map) )(DirectMessages); diff --git a/src/views/navbar/components/messagesTab.js b/src/views/navbar/components/messagesTab.js index bc951b3e2d..416f3503a5 100644 --- a/src/views/navbar/components/messagesTab.js +++ b/src/views/navbar/components/messagesTab.js @@ -12,6 +12,11 @@ import markDirectMessageNotificationsSeenMutation from 'shared/graphql/mutations import { MessageTab, Label } from '../style'; import { track, events } from 'src/helpers/analytics'; import type { Dispatch } from 'redux'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; type Props = { active: boolean, @@ -21,11 +26,15 @@ type Props = { markDirectMessageNotificationsSeen: Function, data: { directMessageNotifications: GetDirectMessageNotificationsType, + refetch: Function, }, subscribeToDMs: Function, refetch: Function, count: number, dispatch: Dispatch, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; type State = { @@ -43,50 +52,59 @@ class MessagesTab extends React.Component { } shouldComponentUpdate(nextProps) { - const prevProps = this.props; + const curr = this.props; + + if (curr.networkOnline !== nextProps.networkOnline) return true; + if (curr.websocketConnection !== nextProps.websocketConnection) return true; + if (curr.pageVisibility !== nextProps.pageVisibility) return true; // if a refetch completes - if (prevProps.isRefetching !== nextProps.isRefetching) return true; + if (curr.isRefetching !== nextProps.isRefetching) return true; // once the initial query finishes loading if ( - !prevProps.data.directMessageNotifications && + !curr.data.directMessageNotifications && nextProps.data.directMessageNotifications ) return true; // if a subscription updates the number of records returned if ( - prevProps.data && - prevProps.data.directMessageNotifications && - prevProps.data.directMessageNotifications.edges && + curr.data && + curr.data.directMessageNotifications && + curr.data.directMessageNotifications.edges && nextProps.data && nextProps.data.directMessageNotifications && nextProps.data.directMessageNotifications.edges && - prevProps.data.directMessageNotifications.edges.length !== + curr.data.directMessageNotifications.edges.length !== nextProps.data.directMessageNotifications.edges.length ) return true; // if the user clicks on the messages tab - if (prevProps.active !== nextProps.active) return true; + if (curr.active !== nextProps.active) return true; // any time the count changes - if (prevProps.count !== nextProps.count) return true; + if (curr.count !== nextProps.count) return true; return false; } - componentDidUpdate(prevProps) { - const { data: prevData } = prevProps; + componentDidUpdate(prev: Props) { + const { data: prevData } = prev; const curr = this.props; + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } + // never update the badge if the user is viewing the messages tab // set the count to 0 if the tab is active so that if a user loads // /messages view directly, the badge won't update // if the user is viewing /messages, mark any incoming notifications // as seen, so that when they navigate away the message count won't shoot up - if (!prevProps.active && this.props.active) { + if (!prev.active && this.props.active) { return this.markAllAsSeen(); } @@ -241,6 +259,9 @@ class MessagesTab extends React.Component { const map = state => ({ count: state.notifications.directMessageNotifications, + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, }); export default compose( // $FlowIssue diff --git a/src/views/navbar/components/notificationsTab.js b/src/views/navbar/components/notificationsTab.js index 306202e4c2..afd28c11fd 100644 --- a/src/views/navbar/components/notificationsTab.js +++ b/src/views/navbar/components/notificationsTab.js @@ -17,6 +17,11 @@ import { Tab, NotificationTab, Label } from '../style'; import { deduplicateChildren } from 'src/components/infiniteScroll/deduplicateChildren'; import { track, events } from 'src/helpers/analytics'; import type { Dispatch } from 'redux'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; type Props = { active: boolean, @@ -36,6 +41,9 @@ type Props = { client: Function, dispatch: Dispatch, count: number, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; type State = { @@ -51,15 +59,19 @@ class NotificationsTab extends React.Component { shouldRenderDropdown: false, }; - shouldComponentUpdate(nextProps, nextState) { - const prevProps = this.props; + shouldComponentUpdate(nextProps: Props, nextState: State) { + const curr = this.props; const prevState = this.state; - const prevLocation = prevProps.location; + if (curr.networkOnline !== nextProps.networkOnline) return true; + if (curr.websocketConnection !== nextProps.websocketConnection) return true; + if (curr.pageVisibility !== nextProps.pageVisibility) return true; + + const prevLocation = curr.location; const nextLocation = nextProps.location; const { thread: prevThreadParam } = queryString.parse(prevLocation.search); const { thread: nextThreadParam } = queryString.parse(nextLocation.search); - const prevActiveInboxThread = prevProps.activeInboxThread; + const prevActiveInboxThread = curr.activeInboxThread; const nextActiveInboxThread = nextProps.activeInboxThread; const prevParts = prevLocation.pathname.split('/'); const nextParts = nextLocation.pathname.split('/'); @@ -74,29 +86,28 @@ class NotificationsTab extends React.Component { if (prevParts[2] !== nextParts[2]) return true; // if a refetch completes - if (prevProps.isRefetching !== nextProps.isRefetching) return true; + if (curr.isRefetching !== nextProps.isRefetching) return true; // once the initial query finishes loading - if (!prevProps.data.notifications && nextProps.data.notifications) - return true; + if (!curr.data.notifications && nextProps.data.notifications) return true; // after refetch - if (prevProps.isRefetching !== nextProps.isRefetching) return true; + if (curr.isRefetching !== nextProps.isRefetching) return true; // if a subscription updates the number of records returned if ( - prevProps.data && - prevProps.data.notifications && - prevProps.data.notifications.edges && + curr.data && + curr.data.notifications && + curr.data.notifications.edges && nextProps.data.notifications && nextProps.data.notifications.edges && - prevProps.data.notifications.edges.length !== + curr.data.notifications.edges.length !== nextProps.data.notifications.edges.length ) return true; // if the user clicks on the notifications tab - if (prevProps.active !== nextProps.active) return true; + if (curr.active !== nextProps.active) return true; // when the notifications get set for the first time if (!prevState.notifications && nextState.notifications) return true; @@ -106,7 +117,7 @@ class NotificationsTab extends React.Component { return true; // any time the count changes - if (prevProps.count !== nextProps.count) return true; + if (curr.count !== nextProps.count) return true; // any time the count changes if ( @@ -119,14 +130,19 @@ class NotificationsTab extends React.Component { return false; } - componentDidUpdate(prevProps) { + componentDidUpdate(prev: Props) { const { data: prevData, location: prevLocation, activeInboxThread: prevActiveInboxThread, - } = prevProps; + } = prev; const curr = this.props; + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } + const { notifications } = this.state; if (!notifications && curr.data.notifications) { @@ -178,7 +194,7 @@ class NotificationsTab extends React.Component { return this.processAndMarkSeenNotifications(); // when the component finishes a refetch - if (prevProps.isRefetching && !curr.isRefetching) { + if (prev.isRefetching && !curr.isRefetching) { return this.processAndMarkSeenNotifications(); } } @@ -434,6 +450,9 @@ class NotificationsTab extends React.Component { const map = state => ({ activeInboxThread: state.dashboardFeed.activeThread, count: state.notifications.notifications, + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, }); export default compose( // $FlowIssue diff --git a/src/views/navbar/index.js b/src/views/navbar/index.js index cbc38e89a7..fc6811be57 100644 --- a/src/views/navbar/index.js +++ b/src/views/navbar/index.js @@ -29,6 +29,10 @@ import { import { track, events } from 'src/helpers/analytics'; import { isViewingMarketingPage } from 'src/helpers/is-viewing-marketing-page'; import { isDesktopApp } from 'src/helpers/desktop-app-utils'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; type Props = { isLoading: boolean, @@ -43,6 +47,9 @@ type Props = { currentUser?: GetUserType, isLoadingCurrentUser: boolean, activeInboxThread: ?string, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; type State = { @@ -54,32 +61,35 @@ class Navbar extends React.Component { isSkipLinkFocused: false, }; - shouldComponentUpdate(nextProps, nextState) { - const currProps = this.props; + shouldComponentUpdate(nextProps: Props, nextState: State) { + const curr = this.props; const isMobile = window && window.innerWidth <= 768; + if (curr.networkOnline !== nextProps.networkOnline) return true; + if (curr.websocketConnection !== nextProps.websocketConnection) return true; + if (curr.pageVisibility !== nextProps.pageVisibility) return true; + // If the update was caused by the focus on the skip link if (nextState.isSkipLinkFocused !== this.state.isSkipLinkFocused) return true; // if route changes - if (currProps.location.pathname !== nextProps.location.pathname) - return true; + if (curr.location.pathname !== nextProps.location.pathname) return true; // if route query params change we need to re-render on mobile - if (isMobile && currProps.location.search !== nextProps.location.search) + if (isMobile && curr.location.search !== nextProps.location.search) return true; // Had no user, now have user or user changed - if (nextProps.currentUser !== currProps.currentUser) return true; - if (nextProps.isLoadingCurrentUser !== currProps.isLoadingCurrentUser) + if (nextProps.currentUser !== curr.currentUser) return true; + if (nextProps.isLoadingCurrentUser !== curr.isLoadingCurrentUser) return true; const newDMNotifications = - currProps.notificationCounts.directMessageNotifications !== + curr.notificationCounts.directMessageNotifications !== nextProps.notificationCounts.directMessageNotifications; const newNotifications = - currProps.notificationCounts.notifications !== + curr.notificationCounts.notifications !== nextProps.notificationCounts.notifications; if (newDMNotifications || newNotifications) return true; @@ -87,7 +97,7 @@ class Navbar extends React.Component { // if the user is mobile and is viewing a thread or DM thread, re-render // the navbar when they exit the thread const { thread: thisThreadParam } = queryString.parse( - currProps.history.location.search + curr.history.location.search ); const { thread: nextThreadParam } = queryString.parse( nextProps.history.location.search @@ -250,7 +260,7 @@ class Navbar extends React.Component { size={32} showHoverProfile={false} showOnlineStatus={false} - clickable={false} + isClickable={false} dataCy="navbar-profile" /> @@ -307,7 +317,7 @@ class Navbar extends React.Component { @@ -330,11 +340,14 @@ class Navbar extends React.Component { } } -const mapStateToProps = state => ({ +const map = state => ({ notificationCounts: state.notifications, + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, }); export default compose( // $FlowIssue - connect(mapStateToProps), + connect(map), withCurrentUser )(Navbar); diff --git a/src/views/navbar/style.js b/src/views/navbar/style.js index 3ffbe86edf..8e476d4397 100644 --- a/src/views/navbar/style.js +++ b/src/views/navbar/style.js @@ -269,7 +269,7 @@ export const ExploreTab = styled(Tab)` `}; ${props => - props.loggedOut && + props.loggedout && css` grid-area: explore; `} ${Label} { diff --git a/src/views/newCommunity/components/createCommunityForm/index.js b/src/views/newCommunity/components/createCommunityForm/index.js index 09ee156066..9651425bc9 100644 --- a/src/views/newCommunity/components/createCommunityForm/index.js +++ b/src/views/newCommunity/components/createCommunityForm/index.js @@ -545,7 +545,7 @@ class CreateCommunityForm extends React.Component { {suggestion.name}{' '} diff --git a/src/views/newUserOnboarding/components/setUsername/index.js b/src/views/newUserOnboarding/components/setUsername/index.js index c55c3bfe39..252a1be5a0 100644 --- a/src/views/newUserOnboarding/components/setUsername/index.js +++ b/src/views/newUserOnboarding/components/setUsername/index.js @@ -29,6 +29,8 @@ type State = { }; class SetUsername extends React.Component { + _isMounted = false; + constructor(props) { super(props); const { user } = props; @@ -52,9 +54,14 @@ class SetUsername extends React.Component { } componentDidMount() { + this._isMounted = true; track(events.USER_ONBOARDING_SET_USERNAME_STEP_VIEWED); } + componentWillUnmount() { + this._isMounted = false; + } + handleUsernameValidation = ({ error, success, username }) => { this.setState({ error, @@ -78,6 +85,7 @@ class SetUsername extends React.Component { this.props .editUser(input) .then(() => { + if (!this._isMounted) return; this.setState({ isLoading: false, success: '', @@ -89,6 +97,7 @@ class SetUsername extends React.Component { return this.props.save(); }) .catch(err => { + if (!this._isMounted) return; this.setState({ isLoading: false, success: '', diff --git a/src/views/newUserOnboarding/index.js b/src/views/newUserOnboarding/index.js index e62111f147..4d15b625eb 100644 --- a/src/views/newUserOnboarding/index.js +++ b/src/views/newUserOnboarding/index.js @@ -52,6 +52,8 @@ type State = {| |}; class NewUserOnboarding extends Component { + _isMounted = false; + constructor(props) { super(props); @@ -76,14 +78,14 @@ class NewUserOnboarding extends Component { }, }; } - // - // shouldComponentUpdate(nextProps, nextState) { - // // don't reload the component as the user saves info - // if (!this.props.currentUser.username && nextProps.currentUser.username) - // return false; - // - // return true; - // } + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } saveUsername = () => { const { community } = this.props; @@ -96,16 +98,14 @@ class NewUserOnboarding extends Component { // thing they will be asked to do is set a username. After they save their // username, they should proceed to the 'joinFirstCommunity' step; otherwise // we can just close the onboarding - if (!community) return this.props.close(); - // if the user signed in via a comunity, channel, or thread view, but they - // are already members of that community, we can escape the onboarding - if (community.communityPermissions.isMember) return this.props.close(); - // if the user signed up via a community, channel, or thread view and - // has not yet joined that community, move them to that step in the onboarding + if (community) { + return this.props.close(); + } return this.toStep('joinFirstCommunity'); }; toStep = (step: ActiveStep) => { + if (!this._isMounted) return; return this.setState({ activeStep: step, }); diff --git a/src/views/notifications/index.js b/src/views/notifications/index.js index a8a2523b4f..d49e8c4939 100644 --- a/src/views/notifications/index.js +++ b/src/views/notifications/index.js @@ -47,6 +47,11 @@ import { track, events } from 'src/helpers/analytics'; import type { Dispatch } from 'redux'; import { ErrorBoundary } from 'src/components/error'; import { isDesktopApp } from 'src/helpers/desktop-app-utils'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; type Props = { markAllNotificationsSeen?: Function, @@ -62,8 +67,13 @@ type Props = { notifications: { edges: Array, }, + refetch: Function, }, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; + type State = { showWebPushPrompt: boolean, webPushPromptLoading: boolean, @@ -129,14 +139,25 @@ class NotificationsPure extends React.Component { }); } - shouldComponentUpdate(nextProps) { + shouldComponentUpdate(nextProps: Props) { const curr = this.props; + if (curr.networkOnline !== nextProps.networkOnline) return true; + if (curr.websocketConnection !== nextProps.websocketConnection) return true; + if (curr.pageVisibility !== nextProps.pageVisibility) return true; // fetching more if (curr.data.networkStatus === 7 && nextProps.data.networkStatus === 3) return false; return true; } + componentDidUpdate(prev: Props) { + const curr = this.props; + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } + } + subscribeToWebPush = () => { track(events.WEB_PUSH_NOTIFICATIONS_PROMPT_CLICKED); this.setState({ @@ -412,6 +433,12 @@ class NotificationsPure extends React.Component { } } +const map = state => ({ + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, +}); + export default compose( subscribeToWebPush, getNotifications, @@ -419,5 +446,6 @@ export default compose( markNotificationsSeenMutation, viewNetworkHandler, withCurrentUser, - connect() + // $FlowIssue + connect(map) )(NotificationsPure); diff --git a/src/views/pages/view.js b/src/views/pages/view.js index 69d86e9e6d..d46923ee75 100644 --- a/src/views/pages/view.js +++ b/src/views/pages/view.js @@ -640,7 +640,11 @@ export const Yours = (props: Props) => { regarding social interaction in 2017 - + Alexander Traykov @Traykov @@ -657,7 +661,7 @@ export const Yours = (props: Props) => { and interact. Except realtime and trolling-free - + Guillermo Rauch @rauchg @@ -676,7 +680,7 @@ export const Yours = (props: Props) => { Tayler O’Dea @tayler-m-odea diff --git a/src/views/status/index.js b/src/views/status/index.js index 4b029c1cdf..127e3b5ea5 100644 --- a/src/views/status/index.js +++ b/src/views/status/index.js @@ -37,6 +37,8 @@ class Status extends React.Component { componentDidMount() { window.addEventListener('offline', this.handleOnlineChange); window.addEventListener('online', this.handleOnlineChange); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + // Only show the bar after a five second timeout setTimeout(() => { this.setState({ @@ -50,6 +52,16 @@ class Status extends React.Component { window.removeEventListener('online', this.handleOnlineChange); } + handleVisibilityChange = () => { + if (document && document.visibilityState === 'hidden') { + return this.props.dispatch({ type: 'PAGE_VISIBILITY', value: 'hidden' }); + } else if (document && document.visibilityState === 'visible') { + return this.props.dispatch({ type: 'PAGE_VISIBILITY', value: 'visible' }); + } else { + return; + } + }; + handleOnlineChange = () => { const online = window.navigator.onLine; this.setState({ diff --git a/src/views/thread/components/messages.js b/src/views/thread/components/messages.js index 2845f48349..3b90f93a93 100644 --- a/src/views/thread/components/messages.js +++ b/src/views/thread/components/messages.js @@ -29,6 +29,11 @@ import { ErrorBoundary } from 'src/components/error'; import getThreadLink from 'src/helpers/get-thread-link'; import type { GetThreadMessageConnectionType } from 'shared/graphql/queries/thread/getThreadMessageConnection'; import type { GetThreadType } from 'shared/graphql/queries/thread/getThread'; +import { useConnectionRestored } from 'src/hooks/useConnectionRestored'; +import type { + WebsocketConnectionType, + PageVisibilityType, +} from 'src/reducers/connectionStatus'; type State = { subscription: ?Function, @@ -47,10 +52,16 @@ type Props = { scrollContainer: any, subscribeToNewMessages: Function, lastSeen: ?number | ?Date, - data: { thread: GetThreadMessageConnectionType }, + data: { + thread: GetThreadMessageConnectionType, + refetch: Function, + }, thread: GetThreadType, currentUser: ?Object, hasError: boolean, + networkOnline: boolean, + websocketConnection: WebsocketConnectionType, + pageVisibility: PageVisibilityType, }; class MessagesWithData extends React.Component { @@ -58,9 +69,14 @@ class MessagesWithData extends React.Component { subscription: null, }; - componentDidUpdate(prev = {}) { + componentDidUpdate(prev: Props) { const curr = this.props; + const didReconnect = useConnectionRestored({ curr, prev }); + if (didReconnect && curr.data.refetch) { + curr.data.refetch(); + } + if (!curr.data.thread) return; const previousMessagesHaveLoaded = @@ -355,10 +371,17 @@ class MessagesWithData extends React.Component { } } +const map = state => ({ + networkOnline: state.connectionStatus.networkOnline, + websocketConnection: state.connectionStatus.websocketConnection, + pageVisibility: state.connectionStatus.pageVisibility, +}); + export default compose( withRouter, withCurrentUser, getThreadMessages, viewNetworkHandler, - connect() + // $FlowIssue + connect(map) )(MessagesWithData); diff --git a/src/views/thread/container.js b/src/views/thread/container.js index 3f13f147b5..626afe3a22 100644 --- a/src/views/thread/container.js +++ b/src/views/thread/container.js @@ -42,7 +42,6 @@ import { import { CommunityAvatar } from 'src/components/avatar'; import WatercoolerActionBar from './components/watercoolerActionBar'; import { ErrorBoundary } from 'src/components/error'; -import generateImageFromText from 'src/helpers/generate-image-from-text'; import getThreadLink from 'src/helpers/get-thread-link'; type Props = { @@ -450,12 +449,7 @@ class ThreadContainer extends React.Component { const headDescription = isWatercooler ? `Watercooler chat for the ${thread.community.name} community` : description; - const metaImage = generateImageFromText({ - title: isWatercooler - ? `Chat with the ${thread.community.name} community` - : thread.content.title, - footer: `spectrum.chat/${thread.community.slug}`, - }); + const metaImage = thread.metaImage; return ( diff --git a/yarn.lock b/yarn.lock index 14dd1d4bec..cd2315c43b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5270,24 +5270,18 @@ event-emitter@~0.3.5: d "1" es5-ext "~0.10.14" -event-stream@~0.5: - version "0.5.3" - resolved "http://registry.npmjs.org/event-stream/-/event-stream-0.5.3.tgz#b77b9309f7107addfeab63f0c0eafd8db0bd8c1c" +event-stream@3.3.4, event-stream@~0.5, event-stream@~3.3.0: + version "3.3.4" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" + integrity sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE= dependencies: - optimist "0.2" - -event-stream@~3.3.0: - version "3.3.6" - resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.6.tgz#cac1230890e07e73ec9cacd038f60a5b66173eef" - dependencies: - duplexer "^0.1.1" - flatmap-stream "^0.1.0" - from "^0.1.7" - map-stream "0.0.7" - pause-stream "^0.0.11" - split "^1.0.1" - stream-combiner "^0.2.2" - through "^2.3.8" + duplexer "~0.1.1" + from "~0" + map-stream "~0.1.0" + pause-stream "0.0.11" + split "0.3" + stream-combiner "~0.0.4" + through "~2.3.1" eventemitter2@0.4.14, eventemitter2@~0.4.14: version "0.4.14" @@ -5754,10 +5748,6 @@ flatiron@~0.4.2: optimist "0.6.0" prompt "0.2.14" -flatmap-stream@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/flatmap-stream/-/flatmap-stream-0.1.1.tgz#d34f39ef3b9aa5a2fc225016bd3adf28ac5ae6ea" - flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" @@ -5882,9 +5872,10 @@ friendly-errors-webpack-plugin@^1.6.1: error-stack-parser "^2.0.0" string-width "^2.0.0" -from@^0.1.7: +from@~0: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" + integrity sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4= fs-extra@3.0.1: version "3.0.1" @@ -8295,9 +8286,10 @@ map-obj@^1.0.0, map-obj@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" -map-stream@0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8" +map-stream@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" + integrity sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ= map-visit@^1.0.0: version "1.0.0" @@ -9023,12 +9015,6 @@ optimism@^0.6.6: dependencies: immutable-tuple "^0.4.4" -optimist@0.2: - version "0.2.8" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.2.8.tgz#e981ab7e268b457948593b55674c099a815cac31" - dependencies: - wordwrap ">=0.0.1 <0.1.0" - optimist@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.0.tgz#69424826f3405f79f142e6fc3d9ae58d4dbb9200" @@ -9352,9 +9338,10 @@ path-type@^2.0.0: dependencies: pify "^2.0.0" -pause-stream@^0.0.11: +pause-stream@0.0.11: version "0.0.11" - resolved "http://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + integrity sha1-/lo0sMvOErWqaitAPuLnO2AvFEU= dependencies: through "~2.3" @@ -10198,9 +10185,10 @@ react-loadable@^5.5.0: dependencies: prop-types "^15.5.0" -react-modal@3.x: +react-modal@^3.6.1: version "3.6.1" resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.6.1.tgz#54d27a1ec2b493bbc451c7efaa3557b6af82332d" + integrity sha512-vAhnawahH1fz8A5x/X/1X20KHMe6Q0mkfU5BKPgKSVPYhMhsxtRbNHSitsoJ7/oP27xZo3naZZlwYuuzuSO1xw== dependencies: exenv "^1.2.0" prop-types "^15.5.10" @@ -11396,9 +11384,10 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" -split@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" +split@0.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" + integrity sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8= dependencies: through "2" @@ -11477,12 +11466,12 @@ stream-combiner2@^1.1.1: duplexer2 "~0.1.0" readable-stream "^2.0.2" -stream-combiner@^0.2.2: - version "0.2.2" - resolved "http://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz#aec8cbac177b56b6f4fa479ced8c1912cee52858" +stream-combiner@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14" + integrity sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ= dependencies: duplexer "~0.1.1" - through "~2.3.4" stream-http@^2.0.0, stream-http@^2.7.2: version "2.8.3" @@ -11868,9 +11857,10 @@ through2@^2.0.0: readable-stream "^2.1.5" xtend "~4.0.1" -through@2, "through@>=2.2.7 <3", through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.4: +through@2, "through@>=2.2.7 <3", through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.1, through@~2.3.4: version "2.3.8" - resolved "http://registry.npmjs.org/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= thunky@^1.0.2: version "1.0.3" @@ -12801,7 +12791,7 @@ wordwrap@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" -"wordwrap@>=0.0.1 <0.1.0", wordwrap@~0.0.2: +wordwrap@~0.0.2: version "0.0.3" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"