diff --git a/backend/package.json b/backend/package.json
index b710d1c9b..be5fb086d 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -43,7 +43,7 @@
},
"dependencies": {
"activitystrea.ms": "~2.1.3",
- "apollo-cache-inmemory": "~1.6.0",
+ "apollo-cache-inmemory": "~1.6.1",
"apollo-client": "~2.6.1",
"apollo-link-context": "~1.0.14",
"apollo-link-http": "~1.5.14",
@@ -52,7 +52,7 @@
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~5.2.0",
- "date-fns": "2.0.0-alpha.27",
+ "date-fns": "2.0.0-alpha.29",
"debug": "~4.1.1",
"dotenv": "~8.0.0",
"express": "~4.17.1",
diff --git a/backend/src/middleware/filterBubble/filterBubble.js b/backend/src/middleware/filterBubble/filterBubble.js
new file mode 100644
index 000000000..bfdad5e2c
--- /dev/null
+++ b/backend/src/middleware/filterBubble/filterBubble.js
@@ -0,0 +1,12 @@
+import replaceParams from './replaceParams'
+
+const replaceFilterBubbleParams = async (resolve, root, args, context, resolveInfo) => {
+ args = await replaceParams(args, context)
+ return resolve(root, args, context, resolveInfo)
+}
+
+export default {
+ Query: {
+ Post: replaceFilterBubbleParams,
+ },
+}
diff --git a/backend/src/middleware/filterBubble/filterBubble.spec.js b/backend/src/middleware/filterBubble/filterBubble.spec.js
new file mode 100644
index 000000000..afe1df1c9
--- /dev/null
+++ b/backend/src/middleware/filterBubble/filterBubble.spec.js
@@ -0,0 +1,76 @@
+import { GraphQLClient } from 'graphql-request'
+import { host, login } from '../../jest/helpers'
+import Factory from '../../seed/factories'
+
+const factory = Factory()
+
+const currentUserParams = {
+ email: 'you@example.org',
+ name: 'This is you',
+ password: '1234',
+}
+const followedAuthorParams = {
+ id: 'u2',
+ email: 'followed@example.org',
+ name: 'Followed User',
+ password: '1234',
+}
+const randomAuthorParams = {
+ email: 'someone@example.org',
+ name: 'Someone else',
+ password: 'else',
+}
+
+beforeEach(async () => {
+ await Promise.all([
+ factory.create('User', currentUserParams),
+ factory.create('User', followedAuthorParams),
+ factory.create('User', randomAuthorParams),
+ ])
+ const [asYourself, asFollowedUser, asSomeoneElse] = await Promise.all([
+ Factory().authenticateAs(currentUserParams),
+ Factory().authenticateAs(followedAuthorParams),
+ Factory().authenticateAs(randomAuthorParams),
+ ])
+ await asYourself.follow({ id: 'u2', type: 'User' })
+ await asFollowedUser.create('Post', { title: 'This is the post of a followed user' })
+ await asSomeoneElse.create('Post', { title: 'This is some random post' })
+})
+
+afterEach(async () => {
+ await factory.cleanDatabase()
+})
+
+describe('FilterBubble middleware', () => {
+ describe('given an authenticated user', () => {
+ let authenticatedClient
+
+ beforeEach(async () => {
+ const headers = await login(currentUserParams)
+ authenticatedClient = new GraphQLClient(host, { headers })
+ })
+
+ describe('no filter bubble', () => {
+ it('returns all posts', async () => {
+ const query = '{ Post( filterBubble: {}) { title } }'
+ const expected = {
+ Post: [
+ { title: 'This is some random post' },
+ { title: 'This is the post of a followed user' },
+ ],
+ }
+ await expect(authenticatedClient.request(query)).resolves.toEqual(expected)
+ })
+ })
+
+ describe('filtering for posts of followed users only', () => {
+ it('returns only posts authored by followed users', async () => {
+ const query = '{ Post( filterBubble: { author: following }) { title } }'
+ const expected = {
+ Post: [{ title: 'This is the post of a followed user' }],
+ }
+ await expect(authenticatedClient.request(query)).resolves.toEqual(expected)
+ })
+ })
+ })
+})
diff --git a/backend/src/middleware/filterBubble/replaceParams.js b/backend/src/middleware/filterBubble/replaceParams.js
new file mode 100644
index 000000000..a10b6c29d
--- /dev/null
+++ b/backend/src/middleware/filterBubble/replaceParams.js
@@ -0,0 +1,31 @@
+import { UserInputError } from 'apollo-server'
+
+export default async function replaceParams(args, context) {
+ const { author = 'all' } = args.filterBubble || {}
+ const { user } = context
+
+ if (author === 'following') {
+ if (!user)
+ throw new UserInputError(
+ "You are unauthenticated - I don't know any users you are following.",
+ )
+
+ const session = context.driver.session()
+ let { records } = await session.run(
+ 'MATCH(followed:User)<-[:FOLLOWS]-(u {id: $userId}) RETURN followed.id',
+ { userId: context.user.id },
+ )
+ const followedIds = records.map(record => record.get('followed.id'))
+
+ // carefully override `id_in`
+ args.filter = args.filter || {}
+ args.filter.author = args.filter.author || {}
+ args.filter.author.id_in = followedIds
+
+ session.close()
+ }
+
+ delete args.filterBubble
+
+ return args
+}
diff --git a/backend/src/middleware/filterBubble/replaceParams.spec.js b/backend/src/middleware/filterBubble/replaceParams.spec.js
new file mode 100644
index 000000000..e14fda416
--- /dev/null
+++ b/backend/src/middleware/filterBubble/replaceParams.spec.js
@@ -0,0 +1,129 @@
+import replaceParams from './replaceParams.js'
+
+describe('replaceParams', () => {
+ let args
+ let context
+ let run
+
+ let action = () => {
+ return replaceParams(args, context)
+ }
+
+ beforeEach(() => {
+ args = {}
+ run = jest.fn().mockResolvedValue({
+ records: [{ get: () => 1 }, { get: () => 2 }, { get: () => 3 }],
+ })
+ context = {
+ driver: {
+ session: () => {
+ return {
+ run,
+ close: () => {},
+ }
+ },
+ },
+ }
+ })
+
+ describe('args == ', () => {
+ describe('{}', () => {
+ it('does not crash', async () => {
+ await expect(action()).resolves.toEqual({})
+ })
+ })
+
+ describe('unauthenticated user', () => {
+ beforeEach(() => {
+ context.user = null
+ })
+
+ describe('{ filterBubble: { author: following } }', () => {
+ it('throws error', async () => {
+ args = { filterBubble: { author: 'following' } }
+ await expect(action()).rejects.toThrow('You are unauthenticated')
+ })
+ })
+
+ describe('{ filterBubble: { author: all } }', () => {
+ it('removes filterBubble param', async () => {
+ const expected = {}
+ await expect(action()).resolves.toEqual(expected)
+ })
+
+ it('does not make database calls', async () => {
+ await action()
+ expect(run).not.toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('authenticated user', () => {
+ beforeEach(() => {
+ context.user = { id: 'u4711' }
+ })
+
+ describe('{ filterBubble: { author: following } }', () => {
+ beforeEach(() => {
+ args = { filterBubble: { author: 'following' } }
+ })
+
+ it('returns args object with resolved ids of followed users', async () => {
+ const expected = { filter: { author: { id_in: [1, 2, 3] } } }
+ await expect(action()).resolves.toEqual(expected)
+ })
+
+ it('makes database calls', async () => {
+ await action()
+ expect(run).toHaveBeenCalledTimes(1)
+ })
+
+ describe('given any additional filter args', () => {
+ describe('merges', () => {
+ it('empty filter object', async () => {
+ args.filter = {}
+ const expected = { filter: { author: { id_in: [1, 2, 3] } } }
+ await expect(action()).resolves.toEqual(expected)
+ })
+
+ it('filter.title', async () => {
+ args.filter = { title: 'bla' }
+ const expected = { filter: { title: 'bla', author: { id_in: [1, 2, 3] } } }
+ await expect(action()).resolves.toEqual(expected)
+ })
+
+ it('filter.author', async () => {
+ args.filter = { author: { name: 'bla' } }
+ const expected = { filter: { author: { name: 'bla', id_in: [1, 2, 3] } } }
+ await expect(action()).resolves.toEqual(expected)
+ })
+ })
+ })
+ })
+
+ describe('{ filterBubble: { } }', () => {
+ it('removes filterBubble param', async () => {
+ const expected = {}
+ await expect(action()).resolves.toEqual(expected)
+ })
+
+ it('does not make database calls', async () => {
+ await action()
+ expect(run).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('{ filterBubble: { author: all } }', () => {
+ it('removes filterBubble param', async () => {
+ const expected = {}
+ await expect(action()).resolves.toEqual(expected)
+ })
+
+ it('does not make database calls', async () => {
+ await action()
+ expect(run).not.toHaveBeenCalled()
+ })
+ })
+ })
+ })
+})
diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js
index 75314abc0..6bc7be000 100644
--- a/backend/src/middleware/index.js
+++ b/backend/src/middleware/index.js
@@ -13,6 +13,7 @@ import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware'
import validation from './validation'
import notifications from './notifications'
+import filterBubble from './filterBubble/filterBubble'
export default schema => {
const middlewares = {
@@ -30,11 +31,13 @@ export default schema => {
user: user,
includedFields: includedFields,
orderBy: orderBy,
+ filterBubble: filterBubble,
}
let order = [
'permissions',
'activityPub',
+ 'filterBubble',
'password',
'dateTime',
'validation',
diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql
index 6e23862ed..1179c3e20 100644
--- a/backend/src/schema/types/type/Post.gql
+++ b/backend/src/schema/types/type/Post.gql
@@ -1,3 +1,40 @@
+enum FilterBubbleAuthorEnum {
+ following
+ all
+}
+
+input FilterBubble {
+ author: FilterBubbleAuthorEnum
+}
+
+type Query {
+ Post(
+ id: ID
+ activityId: String
+ objectId: String
+ title: String
+ slug: String
+ content: String
+ contentExcerpt: String
+ image: String
+ imageUpload: Upload
+ visibility: Visibility
+ deleted: Boolean
+ disabled: Boolean
+ createdAt: String
+ updatedAt: String
+ commentsCount: Int
+ shoutedCount: Int
+ shoutedByCurrentUser: Boolean
+ _id: String
+ first: Int
+ offset: Int
+ orderBy: [_PostOrdering]
+ filter: _PostFilter
+ filterBubble: FilterBubble
+ ): [Post]
+}
+
type Post {
id: ID!
activityId: String
@@ -40,4 +77,4 @@ type Post {
RETURN COUNT(u) >= 1
"""
)
-}
\ No newline at end of file
+}
diff --git a/backend/yarn.lock b/backend/yarn.lock
index 03a8260ba..9c8fdc3c5 100644
--- a/backend/yarn.lock
+++ b/backend/yarn.lock
@@ -1296,18 +1296,18 @@ apollo-cache-control@^0.1.0:
dependencies:
graphql-extensions "^0.0.x"
-apollo-cache-inmemory@~1.6.0:
- version "1.6.0"
- resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.0.tgz#a106cdc520f0a043be2575372d5dbb7e4790254c"
- integrity sha512-Mr86ucMsXnRH9YRvcuuy6kc3dtyRBuVSo8gdxp2sJVuUAtvQ6r/8E+ok2qX84em9ZBAYxoyvPnKeShhvcKiiDw==
+apollo-cache-inmemory@~1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.1.tgz#536b6f366461f6264250041f9146363e2faa1d4c"
+ integrity sha512-c/WJjh9MTWcdussCTjLKufpPjTx3qOFkBPHIDOOpQ+U0B7K1PczPl9N0LaC4ir3wAWL7s4A0t2EKtoR+6UP92g==
dependencies:
- apollo-cache "^1.3.0"
- apollo-utilities "^1.3.0"
+ apollo-cache "^1.3.1"
+ apollo-utilities "^1.3.1"
optimism "^0.9.0"
ts-invariant "^0.4.0"
tslib "^1.9.3"
-apollo-cache@1.3.1, apollo-cache@^1.3.0:
+apollo-cache@1.3.1, apollo-cache@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.1.tgz#c015f93a9a7f32b3eeea0c471addd6e854da754c"
integrity sha512-BJ/Mehr3u6XCaHYSmgZ6DM71Fh30OkW6aEr828WjHvs+7i0RUuP51/PM7K6T0jPXtuw7UbArFFPZZsNgXnyyJA==
@@ -1559,7 +1559,7 @@ apollo-upload-server@^7.0.0:
http-errors "^1.7.0"
object-path "^0.11.4"
-apollo-utilities@1.3.1, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.1:
+apollo-utilities@1.3.1, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.1.tgz#4c45f9b52783c324e2beef822700bdea374f82d1"
integrity sha512-P5cJ75rvhm9hcx9V/xCW0vlHhRd0S2icEcYPoRYNTc5djbynpuO+mQuJ4zMHgjNDpvvDxDfZxXTJ6ZUuJZodiQ==
@@ -2579,10 +2579,10 @@ data-urls@^1.0.0:
whatwg-mimetype "^2.2.0"
whatwg-url "^7.0.0"
-date-fns@2.0.0-alpha.27:
- version "2.0.0-alpha.27"
- resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.27.tgz#5ecd4204ef0e7064264039570f6e8afbc014481c"
- integrity sha512-cqfVLS+346P/Mpj2RpDrBv0P4p2zZhWWvfY5fuWrXNR/K38HaAGEkeOwb47hIpQP9Jr/TIxjZ2/sNMQwdXuGMg==
+date-fns@2.0.0-alpha.29:
+ version "2.0.0-alpha.29"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.29.tgz#9d4a36e3ebba63d009e957fea8fdfef7921bc6cb"
+ integrity sha512-AIFZ0hG/1fdb7HZHTDyiEJdNiaFyZxXcx/kF8z3I9wxbhkN678KrrLSneKcsb0Xy5KqCA4wCIxmGpdVWSNZnpA==
debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
version "2.6.9"
diff --git a/cypress/integration/common/profile.js b/cypress/integration/common/profile.js
index cb5689f63..1df1e2652 100644
--- a/cypress/integration/common/profile.js
+++ b/cypress/integration/common/profile.js
@@ -12,14 +12,16 @@ Then('I should be able to change my profile picture', () => {
cy.fixture(avatarUpload, 'base64').then(fileContent => {
cy.get('#customdropzone').upload(
{ fileContent, fileName: avatarUpload, mimeType: 'image/png' },
- { subjectType: 'drag-n-drop' },
+ { subjectType: 'drag-n-drop' }
)
})
- cy.get('#customdropzone')
- .should('have.attr', 'style')
+ cy.get('.profile-avatar img')
+ .should('have.attr', 'src')
.and('contains', 'onourjourney')
- cy.contains('.iziToast-message', 'Upload successful')
- .should('have.length', 1)
+ cy.contains('.iziToast-message', 'Upload successful').should(
+ 'have.length',
+ 1
+ )
})
When("I visit another user's profile page", () => {
@@ -31,4 +33,4 @@ Then('I cannot upload a picture', () => {
.children()
.should('not.have.id', 'customdropzone')
.should('have.class', 'ds-avatar')
-})
\ No newline at end of file
+})
diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js
index 387f33ac0..8f5bcc8ea 100644
--- a/cypress/integration/common/steps.js
+++ b/cypress/integration/common/steps.js
@@ -230,7 +230,7 @@ When('I type in the following text:', text => {
Then('the post shows up on the landing page at position {int}', index => {
cy.openPage('landing')
- const selector = `:nth-child(${index}) > .ds-card > .ds-card-content`
+ const selector = `.post-card:nth-child(${index}) > .ds-card-content`
cy.get(selector).should('contain', lastPost.title)
cy.get(selector).should('contain', lastPost.content)
})
diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js
new file mode 100644
index 000000000..c312a401b
--- /dev/null
+++ b/webapp/components/FilterMenu/FilterMenu.spec.js
@@ -0,0 +1,54 @@
+import { mount, createLocalVue } from '@vue/test-utils'
+import FilterMenu from './FilterMenu.vue'
+import Styleguide from '@human-connection/styleguide'
+
+const localVue = createLocalVue()
+
+localVue.use(Styleguide)
+
+describe('FilterMenu.vue', () => {
+ let wrapper
+ let mocks
+
+ const createWrapper = mountMethod => {
+ return mountMethod(FilterMenu, {
+ mocks,
+ localVue,
+ })
+ }
+
+ beforeEach(() => {
+ mocks = { $t: () => {} }
+ })
+
+ describe('mount', () => {
+ beforeEach(() => {
+ wrapper = createWrapper(mount)
+ })
+
+ it('renders a card', () => {
+ expect(wrapper.is('.ds-card')).toBe(true)
+ })
+
+ describe('click "filter-by-followed-authors-only" button', () => {
+ it('emits filterBubble object', () => {
+ wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
+ expect(wrapper.emitted('changeFilterBubble')).toBeTruthy()
+ })
+
+ it('toggles filterBubble.author property', () => {
+ wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
+ expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([{ author: 'following' }])
+ wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
+ expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{ author: 'all' }])
+ })
+
+ it('makes button primary', () => {
+ wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
+ expect(
+ wrapper.find({ name: 'filter-by-followed-authors-only' }).classes('ds-button-primary'),
+ ).toBe(true)
+ })
+ })
+ })
+})
diff --git a/webapp/components/FilterMenu/FilterMenu.vue b/webapp/components/FilterMenu/FilterMenu.vue
new file mode 100644
index 000000000..a2195a5fd
--- /dev/null
+++ b/webapp/components/FilterMenu/FilterMenu.vue
@@ -0,0 +1,57 @@
+
+