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
`
: `${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"