diff --git a/mattermost-plugin/webapp/src/components/boardSelector.test.tsx b/mattermost-plugin/webapp/src/components/boardSelector.test.tsx index 1cd8c91d8..a56ea51f6 100644 --- a/mattermost-plugin/webapp/src/components/boardSelector.test.tsx +++ b/mattermost-plugin/webapp/src/components/boardSelector.test.tsx @@ -34,6 +34,7 @@ describe('components/boardSelector', () => { teams: { allTeams: [team], current: team, + currentId: team.id, }, language: { value: 'en', diff --git a/mattermost-plugin/webapp/src/components/boardSelector.tsx b/mattermost-plugin/webapp/src/components/boardSelector.tsx index 63971ca32..877f235dc 100644 --- a/mattermost-plugin/webapp/src/components/boardSelector.tsx +++ b/mattermost-plugin/webapp/src/components/boardSelector.tsx @@ -7,9 +7,11 @@ import debounce from 'lodash/debounce' import {getMessages} from '../../../../webapp/src/i18n' import {getLanguage} from '../../../../webapp/src/store/language' +import {useWebsockets} from '../../../../webapp/src/hooks/websockets' + import octoClient from '../../../../webapp/src/octoClient' import mutator from '../../../../webapp/src/mutator' -import {getCurrentTeam, getAllTeams, Team} from '../../../../webapp/src/store/teams' +import {getCurrentTeamId, getAllTeams, Team} from '../../../../webapp/src/store/teams' import {createBoard, BoardsAndBlocks, Board} from '../../../../webapp/src/blocks/board' import {createBoardView} from '../../../../webapp/src/blocks/boardView' import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks' @@ -20,6 +22,7 @@ import SearchIcon from '../../../../webapp/src/widgets/icons/search' import Button from '../../../../webapp/src/widgets/buttons/button' import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards' import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient' +import {WSClient} from '../../../../webapp/src/wsclient' import BoardSelectorItem from './boardSelectorItem' @@ -31,7 +34,7 @@ const BoardSelector = () => { teamsById[t.id] = t }) const intl = useIntl() - const team = useAppSelector(getCurrentTeam) + const teamId = useAppSelector(getCurrentTeamId) const currentChannel = useAppSelector(getCurrentLinkToChannel) const dispatch = useAppDispatch() @@ -43,20 +46,45 @@ const BoardSelector = () => { const searchHandler = useCallback(async (query: string): Promise => { setSearchQuery(query) - if (query.trim().length === 0 || !team) { + if (query.trim().length === 0 || !teamId) { return } - const items = await octoClient.search(team.id, query) + const items = await octoClient.search(teamId, query) setResults(items) setIsSearching(false) - }, [team?.id]) + }, [teamId]) const debouncedSearchHandler = useMemo(() => debounce(searchHandler, 200), [searchHandler]) const emptyResult = results.length === 0 && !isSearching && searchQuery - if (!team) { + useWebsockets(teamId, (wsClient: WSClient) => { + const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => { + const newResults = [...results] + let updated = false + results.forEach((board, idx) => { + for (const newBoard of boards) { + if (newBoard.id == board.id) { + newResults[idx] = newBoard + updated = true + } + } + }) + if (updated) { + setResults(newResults) + } + } + + wsClient.addOnChange(onChangeBoardHandler, 'board') + + return () => { + wsClient.removeOnChange(onChangeBoardHandler, 'board') + } + }, [results]) + + + if (!teamId) { return null } if (!currentChannel) { @@ -68,34 +96,18 @@ const BoardSelector = () => { setShowLinkBoardConfirmation(board) return } - const newBoard = createBoard(board) - newBoard.channelId = currentChannel + const newBoard = createBoard({...board, channelId: currentChannel}) await mutator.updateBoard(newBoard, board, 'linked channel') - for (const result of results) { - if (result.id == board.id) { - result.channelId = currentChannel - setResults([...results]) - } - } setShowLinkBoardConfirmation(null) } const unlinkBoard = async (board: Board): Promise => { - const newBoard = createBoard(board) - newBoard.channelId = '' + const newBoard = createBoard({...board, channelId: ''}) await mutator.updateBoard(newBoard, board, 'unlinked channel') - for (const result of results) { - if (result.id == board.id) { - result.channelId = '' - setResults([...results]) - } - } } const newLinkedBoard = async (): Promise => { - const board = createBoard() - board.teamId = team.id - board.channelId = currentChannel + const board = {...createBoard(), teamId, channelId: currentChannel} const view = createBoardView() view.fields.viewType = 'board' @@ -111,7 +123,7 @@ const BoardSelector = () => { const newBoard = bab.boards[0] // TODO: Maybe create a new event for create linked board TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoard?.id}) - windowAny.WebappUtils.browserHistory.push(`/boards/team/${team.id}/${newBoard.id}`) + windowAny.WebappUtils.browserHistory.push(`/boards/team/${teamId}/${newBoard.id}`) dispatch(setLinkToChannel('')) }, async () => {return}, @@ -122,7 +134,13 @@ const BoardSelector = () => {
dispatch(setLinkToChannel(''))} + onClose={() => { + dispatch(setLinkToChannel('')) + setResults([]) + setIsSearching(false) + setSearchQuery('') + setShowLinkBoardConfirmation(null) + }} > {showLinkBoardConfirmation &&
+
+
+
diff --git a/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.test.tsx b/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.test.tsx index 0a4ff8437..30d4f360b 100644 --- a/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.test.tsx +++ b/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.test.tsx @@ -10,6 +10,7 @@ import {Provider as ReduxProvider} from 'react-redux' import {mocked} from 'jest-mock' +import {Utils} from '../../../../../webapp/src/utils' import {createCard} from '../../../../../webapp/src/blocks/card' import {createBoard} from '../../../../../webapp/src/blocks/board' import octoClient from '../../../../../webapp/src/octoClient' @@ -18,29 +19,39 @@ import {wrapIntl} from '../../../../../webapp/src/testUtils' import BoardsUnfurl from './boardsUnfurl' jest.mock('../../../../../webapp/src/octoClient') +jest.mock('../../../../../webapp/src/utils') const mockedOctoClient = mocked(octoClient, true) +const mockedUtils = mocked(Utils, true) +mockedUtils.createGuid = jest.requireActual('../../../../../webapp/src/utils').Utils.createGuid +mockedUtils.blockTypeToIDType = jest.requireActual('../../../../../webapp/src/utils').Utils.blockTypeToIDType +mockedUtils.displayDateTime = jest.requireActual('../../../../../webapp/src/utils').Utils.displayDateTime describe('components/boardsUnfurl/BoardsUnfurl', () => { + const team = { + id: 'team-id', + name: 'team', + display_name: 'Team name', + } + beforeEach(() => { + // This is done to the websocket not to try to connect directly + mockedUtils.isFocalboardPlugin.mockReturnValue(true) jest.clearAllMocks() }) it('renders normally', async () => { const mockStore = configureStore([]) const store = mockStore({ - entities: { - users: { - currentUserId: 'id_1', - profiles: { - id_1: { - locale: 'en', - }, - }, - }, + language: { + value: 'en', + }, + teams: { + allTeams: [team], + current: team, }, }) - const cards = [{...createCard(), title: 'test card'}] + const cards = [{...createCard(), title: 'test card', updateAt: 12345}] const board = {...createBoard(), title: 'test board'} mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards) @@ -50,7 +61,7 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => { {wrapIntl( , )} @@ -62,6 +73,8 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => { const result = render(component) container = result.container }) + expect(mockedOctoClient.getBoard).toBeCalledWith(board.id) + expect(mockedOctoClient.getBlocksWithBlockID).toBeCalledWith(cards[0].id, board.id, "abc") expect(container).toMatchSnapshot() }) @@ -69,19 +82,16 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => { it('renders when limited', async () => { const mockStore = configureStore([]) const store = mockStore({ - entities: { - users: { - currentUserId: 'id_1', - profiles: { - id_1: { - locale: 'en', - }, - }, - }, + language: { + value: 'en', + }, + teams: { + allTeams: [team], + current: team, }, }) - const cards = [{...createCard(), title: 'test card', limited: true}] + const cards = [{...createCard(), title: 'test card', limited: true, updateAt: 12345}] const board = {...createBoard(), title: 'test board'} mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards) @@ -91,7 +101,7 @@ describe('components/boardsUnfurl/BoardsUnfurl', () => { {wrapIntl( , )} diff --git a/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.tsx b/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.tsx index 631d88435..f1e7a5378 100644 --- a/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.tsx +++ b/mattermost-plugin/webapp/src/components/boardsUnfurl/boardsUnfurl.tsx @@ -1,22 +1,29 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {useState, useEffect} from 'react' -import {IntlProvider, FormattedMessage} from 'react-intl' -import {connect} from 'react-redux' +import {IntlProvider, FormattedMessage, useIntl} from 'react-intl' -import {GlobalState} from 'mattermost-redux/types/store' -import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n' +import WithWebSockets from '../../../../../webapp/src/components/withWebSockets' +import {useWebsockets} from '../../../../../webapp/src/hooks/websockets' + +import {getLanguage} from '../../../../../webapp/src/store/language' +import {useAppSelector} from '../../../../../webapp/src/store/hooks' +import {getCurrentTeamId} from '../../../../../webapp/src/store/teams' + +import {WSClient, MMWebSocketClient} from '../../../../../webapp/src/wsclient' +import manifest from '../../manifest' import {getMessages} from './../../../../../webapp/src/i18n' import {Utils} from './../../../../../webapp/src/utils' +import {Block} from './../../../../../webapp/src/blocks/block' import {Card} from './../../../../../webapp/src/blocks/card' import {Board} from './../../../../../webapp/src/blocks/board' import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock' + import octoClient from './../../../../../webapp/src/octoClient' const noop = () => '' const Avatar = (window as any).Components?.Avatar || noop -const Timestamp = (window as any).Components?.Timestamp || noop const imageURLForUser = (window as any).Components?.imageURLForUser || noop import './boardsUnfurl.scss' @@ -26,15 +33,7 @@ type Props = { embed: { data: string, }, - locale: string, -} - -function mapStateToProps(state: GlobalState) { - const locale = getCurrentUserLocale(state) - - return { - locale, - } + webSocketClient?: MMWebSocketClient, } class FocalboardEmbeddedData { @@ -59,8 +58,11 @@ export const BoardsUnfurl = (props: Props): JSX.Element => { return <> } - const {embed, locale} = props + const intl = useIntl() + + const {embed, webSocketClient} = props const focalboardInformation: FocalboardEmbeddedData = new FocalboardEmbeddedData(embed.data) + const currentTeamId = useAppSelector(getCurrentTeamId) const {teamID, cardID, boardID, readToken, originalPath} = focalboardInformation const baseURL = window.location.origin @@ -111,6 +113,26 @@ export const BoardsUnfurl = (props: Props): JSX.Element => { fetchData() }, [originalPath]) + useWebsockets(currentTeamId, (wsClient: WSClient) => { + const onChangeHandler = (_: WSClient, blocks: Block[]): void => { + const cardBlock: Block|undefined = blocks.find(b => b.id === cardID) + if (cardBlock && !cardBlock.deleteAt) { + setCard(cardBlock as Card) + } + + const contentBlock: Block|undefined = blocks.find(b => b.id === content?.id) + if (contentBlock && !contentBlock.deleteAt) { + setContent(contentBlock) + } + } + + wsClient.addOnChange(onChangeHandler, 'block') + + return () => { + wsClient.removeOnChange(onChangeHandler, 'block') + } + }, [cardID, content?.id]) + let remainder = 0 let html = '' const propertiesToDisplay: Array> = [] @@ -160,10 +182,7 @@ export const BoardsUnfurl = (props: Props): JSX.Element => { } return ( - + {!loading && (!card || !board) && <>} {!loading && card && board && { } {card.limited && -

-

} +

+

} {/* Footer of the Card*/} {!card.limited && -
-
- -
-
-
- {propertiesToDisplay.map((property) => ( -
- {property.optionValue} -
- ))} - {remainder > 0 && - - - - } -
- - - ), - }} +
+
+ - -
-
} +
+
+
+ {propertiesToDisplay.map((property) => ( +
+ {property.optionValue} +
+ ))} + {remainder > 0 && + + + + } +
+ + + +
+
}
} {loading &&
} + + ) +} + +const IntlBoardsUnfurl = (props: Props) => { + const language = useAppSelector(getLanguage) + + return ( + + ) } -export default connect(mapStateToProps)(BoardsUnfurl) +export default IntlBoardsUnfurl diff --git a/mattermost-plugin/webapp/src/components/rhsChannelBoards.test.tsx b/mattermost-plugin/webapp/src/components/rhsChannelBoards.test.tsx index d57a5e83d..3a9362021 100644 --- a/mattermost-plugin/webapp/src/components/rhsChannelBoards.test.tsx +++ b/mattermost-plugin/webapp/src/components/rhsChannelBoards.test.tsx @@ -4,12 +4,18 @@ import React from 'react' import {Provider as ReduxProvider} from 'react-redux' import {render} from '@testing-library/react' +import {mocked} from 'jest-mock' +import thunk from 'redux-thunk' +import octoClient from '../../../../webapp/src/octoClient' import {createBoard} from '../../../../webapp/src/blocks/board' import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils' import RHSChannelBoards from './rhsChannelBoards' +jest.mock('../../../../webapp/src/octoClient') +const mockedOctoClient = mocked(octoClient, true) + describe('components/rhsChannelBoards', () => { const board1 = createBoard() board1.updateAt = 1657311058157 @@ -29,6 +35,7 @@ describe('components/rhsChannelBoards', () => { teams: { allTeams: [team], current: team, + currentId: team.id, }, language: { value: 'en', @@ -55,8 +62,13 @@ describe('components/rhsChannelBoards', () => { }, } + beforeEach(() => { + mockedOctoClient.getBoards.mockResolvedValue([board1, board2, board3]) + jest.clearAllMocks() + }) + it('renders the RHS for channel boards', async () => { - const store = mockStateStore([], state) + const store = mockStateStore([thunk], state) const {container} = render(wrapIntl( @@ -67,7 +79,7 @@ describe('components/rhsChannelBoards', () => { it('renders with empty list of boards', async () => { const localState = {...state, boards: {...state.boards, boards: {}}} - const store = mockStateStore([], localState) + const store = mockStateStore([thunk], localState) const {container} = render(wrapIntl( diff --git a/mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx b/mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx index 07a44e009..a2a6fa2f6 100644 --- a/mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx +++ b/mattermost-plugin/webapp/src/components/rhsChannelBoards.tsx @@ -1,18 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react' +import React, {useEffect} from 'react' import {FormattedMessage, IntlProvider} from 'react-intl' import {getMessages} from '../../../../webapp/src/i18n' import {getLanguage} from '../../../../webapp/src/store/language' -import {getCurrentTeam} from '../../../../webapp/src/store/teams' +import {useWebsockets} from '../../../../webapp/src/hooks/websockets' + +import {Board, BoardMember} from '../../../../webapp/src/blocks/board' +import {getCurrentTeamId} from '../../../../webapp/src/store/teams' +import {loadBoards} from '../../../../webapp/src/store/initialLoad' import {getCurrentChannel} from '../../../../webapp/src/store/channels' -import {getMySortedBoards, setLinkToChannel} from '../../../../webapp/src/store/boards' +import {getMySortedBoards, setLinkToChannel, updateBoards, updateMembers} from '../../../../webapp/src/store/boards' import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks' import AddIcon from '../../../../webapp/src/widgets/icons/add' import Button from '../../../../webapp/src/widgets/buttons/button' +import {WSClient} from '../../../../webapp/src/wsclient' + import RHSChannelBoardItem from './rhsChannelBoardItem' import './rhsChannelBoards.scss' @@ -21,14 +27,35 @@ const boardsScreenshots = (window as any).baseURL + '/public/boards-screenshots. const RHSChannelBoards = () => { const boards = useAppSelector(getMySortedBoards) - const team = useAppSelector(getCurrentTeam) + const teamId = useAppSelector(getCurrentTeamId) const currentChannel = useAppSelector(getCurrentChannel) const dispatch = useAppDispatch() + useEffect(() => { + dispatch(loadBoards()) + }, []) + + useWebsockets(teamId || '', (wsClient: WSClient) => { + const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => { + dispatch(updateBoards(boards)) + } + const onChangeMemberHandler = (_: WSClient, members: BoardMember[]): void => { + dispatch(updateMembers(members)) + } + + wsClient.addOnChange(onChangeBoardHandler, 'board') + wsClient.addOnChange(onChangeMemberHandler, 'boardMembers') + + return () => { + wsClient.removeOnChange(onChangeBoardHandler, 'board') + wsClient.removeOnChange(onChangeMemberHandler, 'boardMembers') + } + }, []) + if (!boards) { return null } - if (!team) { + if (!teamId) { return null } if (!currentChannel) { diff --git a/mattermost-plugin/webapp/src/index.tsx b/mattermost-plugin/webapp/src/index.tsx index 547e9dd72..a0aed794b 100644 --- a/mattermost-plugin/webapp/src/index.tsx +++ b/mattermost-plugin/webapp/src/index.tsx @@ -23,6 +23,7 @@ windowAny.isFocalboardPlugin = true import App from '../../../webapp/src/app' import store from '../../../webapp/src/store' import {setTeam} from '../../../webapp/src/store/teams' +import WithWebSockets from '../../../webapp/src/components/withWebSockets' import {setChannel} from '../../../webapp/src/store/channels' import {initialLoad} from '../../../webapp/src/store/initialLoad' import {Utils} from '../../../webapp/src/utils' @@ -129,8 +130,6 @@ function customHistory() { let browserHistory: History const MainApp = (props: Props) => { - wsClient.initPlugin(manifest.id, manifest.version, props.webSocketClient) - useEffect(() => { document.body.classList.add('focalboard-body') document.body.classList.add('app__body') @@ -151,10 +150,12 @@ const MainApp = (props: Props) => { return ( -
- -
-
+ +
+ +
+
+ ) @@ -187,6 +188,36 @@ export default class Plugin { UserSettings.nameFormat = mmStore.getState().entities.preferences?.myPreferences['display_settings--name_format']?.value || null let theme = mmStore.getState().entities.preferences.myPreferences.theme setMattermostTheme(theme) + + // register websocket handlers + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data)) + this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data)) + this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge) + this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => { + let preferences + try { + preferences = JSON.parse(e.data.preferences) + } catch { + preferences = [] + } + if (preferences) { + for (const preference of preferences) { + if (preference.category === 'theme' && theme !== preference.value) { + setMattermostTheme(JSON.parse(preference.value)) + theme = preference.value + } + if(preference.category === 'display_settings' && preference.name === 'name_format'){ + UserSettings.nameFormat = preference.value + } + } + } + }) + let lastViewedChannel = mmStore.getState().entities.channels.currentChannelId let prevTeamID: string @@ -210,10 +241,11 @@ export default class Plugin { if (currentTeamID && currentTeamID !== prevTeamID) { if (prevTeamID && window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) { browserHistory.push(`/team/${currentTeamID}`) - wsClient.subscribeToTeam(currentTeamID) } prevTeamID = currentTeamID store.dispatch(setTeam(currentTeamID)) + octoClient.teamId = currentTeamID + store.dispatch(initialLoad()) } }) @@ -221,9 +253,11 @@ export default class Plugin { windowAny.frontendBaseURL = subpath + '/boards' const {rhsId, toggleRHSPlugin} = this.registry.registerRightHandSidebarComponent( - () => ( + (props: {webSocketClient: MMWebSocketClient}) => ( - + + + ), @@ -263,12 +297,25 @@ export default class Plugin { this.registry.registerAppBarComponent(appBarIconURL, () => mmStore.dispatch(toggleRHSPlugin), 'Boards') } - this.registry.registerPostWillRenderEmbedComponent((embed) => embed.type === 'boards', BoardsUnfurl, false) + this.registry.registerPostWillRenderEmbedComponent( + (embed) => embed.type === 'boards', + (props: {embed: {data: string}, webSocketClient: MMWebSocketClient}) => ( + + + + ), + false + ) } - this.boardSelectorId = this.registry.registerRootComponent(() => ( + this.boardSelectorId = this.registry.registerRootComponent((props: {webSocketClient: MMWebSocketClient}) => ( - + + + )) @@ -307,34 +354,6 @@ export default class Plugin { } } - // register websocket handlers - this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data)) - this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data)) - this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data)) - this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data)) - this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data)) - this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data)) - this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data)) - this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge) - this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => { - let preferences - try { - preferences = JSON.parse(e.data.preferences) - } catch { - preferences = [] - } - if (preferences) { - for (const preference of preferences) { - if (preference.category === 'theme' && theme !== preference.value) { - setMattermostTheme(JSON.parse(preference.value)) - theme = preference.value - } - if(preference.category === 'display_settings' && preference.name === 'name_format'){ - UserSettings.nameFormat = preference.value - } - } - } - }) windowAny.setTeamInSidebar = (teamID: string) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -345,7 +364,6 @@ export default class Plugin { // @ts-ignore return mmStore.getState().entities.teams.currentTeamId } - store.dispatch(initialLoad()) } uninitialize(): void { diff --git a/server/api/api.go b/server/api/api.go index 4bcef924a..a269fe943 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -98,6 +98,7 @@ func (a *API) RegisterRoutes(r *mux.Router) { apiv2.HandleFunc("/teams/{teamID}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET") apiv2.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).Methods("GET") apiv2.HandleFunc("/boards", a.sessionRequired(a.handleCreateBoard)).Methods("POST") + apiv2.HandleFunc("/boards/search", a.sessionRequired(a.handleSearchAllBoards)).Methods("GET") apiv2.HandleFunc("/boards/{boardID}", a.attachSession(a.handleGetBoard, false)).Methods("GET") apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH") apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE") @@ -3366,7 +3367,7 @@ func (a *API) handleGetBoardMetadata(w http.ResponseWriter, r *http.Request) { func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/boards/search searchBoards // - // Returns the boards that match with a search term + // Returns the boards that match with a search term in the team // // --- // produces: @@ -3415,7 +3416,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("teamID", teamID) // retrieve boards list - boards, err := a.app.SearchBoardsForUser(term, userID) + boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID) if err != nil { a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) return @@ -3439,6 +3440,69 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) { auditRec.Success() } +func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /boards/search searchBoards + // + // Returns the boards that match with a search term + // + // --- + // produces: + // - application/json + // parameters: + // - name: q + // in: query + // description: The search term. Must have at least one character + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // type: array + // items: + // "$ref": "#/definitions/Board" + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + term := r.URL.Query().Get("q") + userID := getUserID(r) + + if len(term) == 0 { + jsonStringResponse(w, http.StatusOK, "[]") + return + } + + auditRec := a.makeAuditRecord(r, "searchAllBoards", audit.Fail) + defer a.audit.LogRecord(audit.LevelRead, auditRec) + + // retrieve boards list + boards, err := a.app.SearchBoardsForUser(term, userID) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + a.logger.Debug("SearchAllBoards", + mlog.Int("boardsCount", len(boards)), + ) + + data, err := json.Marshal(boards) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + // response + jsonBytesResponse(w, http.StatusOK, data) + + auditRec.AddMeta("boardsCount", len(boards)) + auditRec.Success() +} + func (a *API) handleGetMembersForBoard(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /boards/{boardID}/members getMembersForBoard // diff --git a/server/app/boards.go b/server/app/boards.go index 33351151b..1e6b34fe0 100644 --- a/server/app/boards.go +++ b/server/app/boards.go @@ -444,6 +444,10 @@ func (a *App) SearchBoardsForUser(term, userID string) ([]*model.Board, error) { return a.store.SearchBoardsForUser(term, userID) } +func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) { + return a.store.SearchBoardsForUserInTeam(teamID, term, userID) +} + func (a *App) UndeleteBoard(boardID string, modifiedBy string) error { boards, err := a.store.GetBoardHistory(boardID, model.QueryBoardHistoryOptions{Limit: 1, Descending: true}) if err != nil { diff --git a/server/integrationtests/board_test.go b/server/integrationtests/board_test.go index 47df7937b..4219edce9 100644 --- a/server/integrationtests/board_test.go +++ b/server/integrationtests/board_test.go @@ -526,7 +526,7 @@ func TestSearchBoards(t *testing.T) { require.NoError(t, err) board5 := &model.Board{ - Title: "public board where user1 is admin, but in other team", + Title: "private board where user1 is admin, but in other team", Type: model.BoardTypePrivate, TeamID: "other-team-id", } @@ -543,25 +543,25 @@ func TestSearchBoards(t *testing.T) { Name: "should return all boards where user1 is member or that are public", Client: th.Client, Term: "board", - ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID, board5.ID}, + ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, rBoard3.ID}, }, { Name: "matching a full word", Client: th.Client, Term: "admin", - ExpectedIDs: []string{rBoard1.ID, rBoard3.ID, board5.ID}, + ExpectedIDs: []string{rBoard1.ID, rBoard3.ID}, }, { Name: "matching part of the word", Client: th.Client, Term: "ubli", - ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, board5.ID}, + ExpectedIDs: []string{rBoard1.ID, rBoard2.ID}, }, { Name: "case insensitive", Client: th.Client, Term: "UBLI", - ExpectedIDs: []string{rBoard1.ID, rBoard2.ID, board5.ID}, + ExpectedIDs: []string{rBoard1.ID, rBoard2.ID}, }, { Name: "user2 can only see the public boards, as he's not a member of any", @@ -585,50 +585,6 @@ func TestSearchBoards(t *testing.T) { }) } }) - - t.Run("should only return boards in the teams that the user belongs to", func(t *testing.T) { - th := SetupTestHelperPluginMode(t) - defer th.TearDown() - clients := setupClients(th) - - b1, err := th.Server.App().CreateBoard( - &model.Board{Title: "public board", Type: model.BoardTypeOpen, TeamID: "test-team"}, - userAdmin, - true, - ) - require.NoError(t, err) - - _, err = th.Server.App().CreateBoard( - &model.Board{Title: "private board", Type: model.BoardTypePrivate, TeamID: "test-team"}, - userAdmin, - false, - ) - require.NoError(t, err) - - _, err = th.Server.App().CreateBoard( - &model.Board{Title: "public board in empty team", Type: model.BoardTypeOpen, TeamID: "empty-team"}, - userAdmin, - true, - ) - require.NoError(t, err) - - _, err = th.Server.App().CreateBoard( - &model.Board{Title: "private board in empty team", Type: model.BoardTypePrivate, TeamID: "empty-team"}, - userAdmin, - true, - ) - require.NoError(t, err) - - boards, resp := clients.Viewer.SearchBoardsForTeam(testTeamID, "board") - th.CheckOK(resp) - - boardIDs := []string{} - for _, board := range boards { - boardIDs = append(boardIDs, board.ID) - } - - require.ElementsMatch(t, []string{b1.ID}, boardIDs) - }) } func TestGetBoard(t *testing.T) { diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index fdcf75fdb..91e40677c 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -1278,6 +1278,21 @@ func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1) } +// SearchBoardsForUserInTeam mocks base method. +func (m *MockStore) SearchBoardsForUserInTeam(arg0, arg1, arg2 string) ([]*model.Board, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchBoardsForUserInTeam", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.Board) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchBoardsForUserInTeam indicates an expected call of SearchBoardsForUserInTeam. +func (mr *MockStoreMockRecorder) SearchBoardsForUserInTeam(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUserInTeam", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUserInTeam), arg0, arg1, arg2) +} + // SearchUserChannels mocks base method. func (m *MockStore) SearchUserChannels(arg0, arg1, arg2 string) ([]*model0.Channel, error) { m.ctrl.T.Helper() diff --git a/server/services/store/sqlstore/board.go b/server/services/store/sqlstore/board.go index e595232f5..1a88a53b4 100644 --- a/server/services/store/sqlstore/board.go +++ b/server/services/store/sqlstore/board.go @@ -684,6 +684,52 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string) ([ return s.boardsFromRows(rows) } +// searchBoardsForUserInTeam returns all boards that match with the +// term that are either private and which the user is a member of, or +// they're open, regardless of the user membership. +// Search is case-insensitive. +func (s *SQLStore) searchBoardsForUserInTeam(db sq.BaseRunner, teamID, term, userID string) ([]*model.Board, error) { + query := s.getQueryBuilder(db). + Select(boardFields("b.")...). + Distinct(). + From(s.tablePrefix + "boards as b"). + LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id"). + Where(sq.Eq{"b.is_template": false}). + Where(sq.Eq{"b.team_id": teamID}). + Where(sq.Or{ + sq.Eq{"b.type": model.BoardTypeOpen}, + sq.And{ + sq.Eq{"b.type": model.BoardTypePrivate}, + sq.Eq{"bm.user_id": userID}, + }, + }) + + if term != "" { + // break search query into space separated words + // and search for each word. + // This should later be upgraded to industrial-strength + // word tokenizer, that uses much more than space + // to break words. + + conditions := sq.Or{} + + for _, word := range strings.Split(strings.TrimSpace(term), " ") { + conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"}) + } + + query = query.Where(conditions) + } + + rows, err := query.Query() + if err != nil { + s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err)) + return nil, err + } + defer s.CloseRows(rows) + + return s.boardsFromRows(rows) +} + func (s *SQLStore) getBoardHistory(db sq.BaseRunner, boardID string, opts model.QueryBoardHistoryOptions) ([]*model.Board, error) { var order string if opts.Descending { diff --git a/server/services/store/sqlstore/migrate.go b/server/services/store/sqlstore/migrate.go index bb037537b..26903b1e5 100644 --- a/server/services/store/sqlstore/migrate.go +++ b/server/services/store/sqlstore/migrate.go @@ -388,6 +388,7 @@ func (s *SQLStore) createTempSchemaTable() error { return nil } + func (s *SQLStore) populateTempSchemaTable(migrations []*models.Migration, legacySchemaVersion uint32) error { query := s.getQueryBuilder(s.db). Insert(s.tablePrefix+tempSchemaMigrationTableName). diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index 20029d2ea..adee06a61 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -741,6 +741,11 @@ func (s *SQLStore) SearchBoardsForUser(term string, userID string) ([]*model.Boa } +func (s *SQLStore) SearchBoardsForUserInTeam(teamID string, term string, userID string) ([]*model.Board, error) { + return s.searchBoardsForUserInTeam(s.db, teamID, term, userID) + +} + func (s *SQLStore) SearchUserChannels(teamID string, userID string, query string) ([]*mmModel.Channel, error) { return s.searchUserChannels(s.db, teamID, userID, query) diff --git a/server/services/store/store.go b/server/services/store/store.go index 8cc7494e7..79d2ac969 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -101,6 +101,7 @@ type Store interface { GetMembersForBoard(boardID string) ([]*model.BoardMember, error) GetMembersForUser(userID string) ([]*model.BoardMember, error) SearchBoardsForUser(term, userID string) ([]*model.Board, error) + SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) // @withTransaction CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, userID string) (*model.BoardsAndBlocks, []*model.BoardMember, error) diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 74c6efc7d..08fa5925a 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -13,7 +13,6 @@ import {getMessages} from './i18n' import {FlashMessages} from './components/flashMessages' import NewVersionBanner from './components/newVersionBanner' import {Utils} from './utils' -import wsClient from './wsclient' import {fetchMe, getMe} from './store/users' import {getLanguage, fetchLanguage} from './store/language' import {useAppSelector, useAppDispatch} from './store/hooks' @@ -37,22 +36,6 @@ const App = (props: Props): JSX.Element => { dispatch(fetchClientConfig()) }, []) - // this is a temporary solution while we're using legacy routes - // for shared boards as a way to disable websockets, and should be - // removed when anonymous plugin routes are implemented. This - // check is used to detect if we're running inside the plugin but - // in a legacy route - useEffect(() => { - if (!Utils.isFocalboardLegacy()) { - wsClient.open() - } - return () => { - if (!Utils.isFocalboardLegacy()) { - wsClient.close() - } - } - }, []) - useEffect(() => { if (me) { TelemetryClient.setUser(me) diff --git a/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx b/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx index a40715309..5823e5bec 100644 --- a/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx +++ b/webapp/src/components/boardsSwitcherDialog/boardSwitcherDialog.tsx @@ -55,7 +55,7 @@ const BoardSwitcherDialog = (props: Props): JSX.Element => { return [] } - const items = await octoClient.search(team.id, query) + const items = await octoClient.searchAll(query) const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'}) return items.map((item) => { const resultTitle = item.title || untitledBoardTitle diff --git a/webapp/src/components/withWebSockets.tsx b/webapp/src/components/withWebSockets.tsx new file mode 100644 index 000000000..1d24c1193 --- /dev/null +++ b/webapp/src/components/withWebSockets.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useEffect} from 'react' + +import wsClient, {MMWebSocketClient} from '../wsclient' +import {Utils} from '../utils' + +type Props = { + userId?: string, + manifest?: { + id: string, + version: string, + }, + webSocketClient?: MMWebSocketClient, + children: React.ReactNode, +} + +// WithWebSockets component initialises the websocket connection if +// it's not yet running and subscribes to the current team +const WithWebSockets = (props: Props): React.ReactElement => { + const queryString = new URLSearchParams(window.location.search) + + useEffect(() => { + // if the websocket client was already connected, do nothing + if (wsClient.state !== 'init') { + return + } + + // this is a temporary solution to disable websocket + // connections on legacy routes, as there is no such thing as + // an anonymous websocket connection + if (Utils.isFocalboardLegacy()) { + return + } + + if (!Utils.isFocalboardPlugin()) { + const token = localStorage.getItem('focalboardSessionId') || queryString.get('r') || '' + if (token) { + wsClient.authenticate(token) + } + wsClient.open() + return + } + + if (!props.webSocketClient) { + Utils.logWarn('Trying to initialise Boards websocket in plugin mode without base connection. Aborting') + return + } + + if (!props.manifest?.id || !props.manifest?.version) { + Utils.logError('Trying to initialise Boards websocket in plugin mode with an incomplete manifest. Aborting') + return + } + + wsClient.initPlugin(props.manifest?.id, props.manifest?.version, props.webSocketClient) + wsClient.open() + }, [props.webSocketClient]) + + useEffect(() => { + // if we're running on a plugin instance or we don't have a + // user yet, do nothing + if (Utils.isFocalboardPlugin() || !props.userId) { + return + } + + const token = localStorage.getItem('focalboardSessionId') || queryString.get('r') || '' + if (wsClient.token !== token) { + wsClient.authenticate(token) + } + }, [props.userId]) + + return ( + <> + {props.children} + + ) +} + +export default WithWebSockets diff --git a/webapp/src/hooks/websockets.tsx b/webapp/src/hooks/websockets.tsx new file mode 100644 index 000000000..1ccca4808 --- /dev/null +++ b/webapp/src/hooks/websockets.tsx @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {useEffect} from 'react' + +import wsClient, {WSClient} from '../wsclient' + +export const useWebsockets = (teamId: string, fn: (wsClient: WSClient) => () => void, deps: any[] = []): void => { + useEffect(() => { + if (!teamId) { + return + } + + wsClient.subscribeToTeam(teamId) + const teardown = fn(wsClient) + + return () => { + teardown() + wsClient.unsubscribeToTeam(teamId) + } + }, [teamId, ...deps]) +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 8e2c8bb32..dd9a31682 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -10,6 +10,10 @@ import {initThemes} from './theme' import {importNativeAppSettings} from './nativeApp' import {UserSettings} from './userSettings' +import {IUser} from './user' +import {getMe} from './store/users' +import {useAppSelector} from './store/hooks' + import '@mattermost/compass-icons/css/compass-icons.css' import './styles/variables.scss' @@ -18,15 +22,27 @@ import './styles/labels.scss' import './styles/_markdown.scss' import store from './store' +import WithWebSockets from './components/withWebSockets' emojiMartStore.setHandlers({getter: UserSettings.getEmojiMartSetting, setter: UserSettings.setEmojiMartSetting}) importNativeAppSettings() initThemes() + +const MainApp = () => { + const me = useAppSelector(getMe) + + return ( + + + + ) +} + ReactDOM.render( ( - + ), document.getElementById('focalboard-app'), diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index 4798b5f4d..fb6e4c2f0 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -773,7 +773,21 @@ class OctoClient { } async search(teamID: string, query: string): Promise> { - const url = `${this.teamPath()}/boards/search?q=${encodeURIComponent(query)}` + const url = `${this.teamPath(teamID)}/boards/search?q=${encodeURIComponent(query)}` + const response = await fetch(this.getBaseURL() + url, { + method: 'GET', + headers: this.headers(), + }) + + if (response.status !== 200) { + return [] + } + + return (await this.getJson(response, [])) as Array + } + + async searchAll(query: string): Promise> { + const url = `/api/v2/boards/search?q=${encodeURIComponent(query)}` const response = await fetch(this.getBaseURL() + url, { method: 'GET', headers: this.headers(), diff --git a/webapp/src/pages/boardPage/boardPage.tsx b/webapp/src/pages/boardPage/boardPage.tsx index 198c68604..90b57a193 100644 --- a/webapp/src/pages/boardPage/boardPage.tsx +++ b/webapp/src/pages/boardPage/boardPage.tsx @@ -1,18 +1,43 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {useEffect, useState, useMemo, useCallback} from 'react' +import {batch} from 'react-redux' import {FormattedMessage, useIntl} from 'react-intl' import {useRouteMatch} from 'react-router-dom' import Workspace from '../../components/workspace' import CloudMessage from '../../components/messages/cloudMessage' import octoClient from '../../octoClient' +import {Subscription, WSClient} from '../../wsclient' import {Utils} from '../../utils' -import wsClient from '../../wsclient' -import {getCurrentBoardId, setCurrent as setCurrentBoard, fetchBoardMembers} from '../../store/boards' +import {useWebsockets} from '../../hooks/websockets' +import {IUser} from '../../user' +import {Block} from '../../blocks/block' +import {ContentBlock} from '../../blocks/contentBlock' +import {CommentBlock} from '../../blocks/commentBlock' +import {Board, BoardMember} from '../../blocks/board' +import {BoardView} from '../../blocks/boardView' +import {Card} from '../../blocks/card' +import { + updateBoards, + updateMembersEnsuringBoardsAndUsers, + getCurrentBoardId, + setCurrent as setCurrentBoard, + fetchBoardMembers, +} from '../../store/boards' import {getCurrentViewId, setCurrent as setCurrentView} from '../../store/views' import {initialLoad, initialReadOnlyLoad, loadBoardData} from '../../store/initialLoad' import {useAppSelector, useAppDispatch} from '../../store/hooks' +import {updateViews} from '../../store/views' +import {updateCards} from '../../store/cards' +import {updateComments} from '../../store/comments' +import {updateContents} from '../../store/contents' +import { + fetchUserBlockSubscriptions, + getMe, + followBlock, + unfollowBlock, +} from '../../store/users' import {setGlobalError} from '../../store/globalError' import {UserSettings} from '../../userSettings' @@ -20,8 +45,6 @@ import IconButton from '../../widgets/buttons/iconButton' import CloseIcon from '../../widgets/icons/close' import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient' -import {fetchUserBlockSubscriptions, getMe} from '../../store/users' -import {IUser} from '../../user' import {Constants} from "../../constants" @@ -45,6 +68,7 @@ const BoardPage = (props: Props): JSX.Element => { const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, teamId?: string}>() const [mobileWarningClosed, setMobileWarningClosed] = useState(UserSettings.mobileWarningClosed) const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId + const viewId = match.params.viewId const me = useAppSelector(getMe) // if we're in a legacy route and not showing a shared board, @@ -68,7 +92,6 @@ const BoardPage = (props: Props): JSX.Element => { useEffect(() => { UserSettings.lastTeamId = teamId octoClient.teamId = teamId - wsClient.teamId = teamId const windowAny = (window as any) if (windowAny.setTeamInSidebar) { windowAny.setTeamInSidebar(teamId) @@ -82,6 +105,52 @@ const BoardPage = (props: Props): JSX.Element => { return initialLoad }, [props.readonly]) + useWebsockets(teamId, (wsClient) => { + const incrementalBlockUpdate = (_: WSClient, blocks: Block[]) => { + const teamBlocks = blocks + + batch(() => { + dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[])) + dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[])) + dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[])) + dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment') as ContentBlock[])) + }) + } + + const incrementalBoardUpdate = (_: WSClient, boards: Board[]) => { + // only takes into account the entities that belong to the team or the user boards + const teamBoards = boards.filter((b: Board) => b.teamId === Constants.globalTeamId || b.teamId === teamId) + dispatch(updateBoards(teamBoards)) + } + + const incrementalBoardMemberUpdate = (_: WSClient, members: BoardMember[]) => { + dispatch(updateMembersEnsuringBoardsAndUsers(members)) + } + + wsClient.addOnChange(incrementalBlockUpdate, 'block') + wsClient.addOnChange(incrementalBoardUpdate, 'board') + wsClient.addOnChange(incrementalBoardMemberUpdate, 'boardMembers') + wsClient.addOnReconnect(() => dispatch(loadAction(match.params.boardId))) + + wsClient.setOnFollowBlock((_: WSClient, subscription: Subscription): void => { + if (subscription.subscriberId === me?.id) { + dispatch(followBlock(subscription)) + } + }) + wsClient.setOnUnfollowBlock((_: WSClient, subscription: Subscription): void => { + if (subscription.subscriberId === me?.id) { + dispatch(unfollowBlock(subscription)) + } + }) + + return () => { + wsClient.removeOnChange(incrementalBlockUpdate, 'block') + wsClient.removeOnChange(incrementalBoardUpdate, 'board') + wsClient.removeOnChange(incrementalBoardMemberUpdate, 'boardMembers') + wsClient.removeOnReconnect(() => dispatch(loadAction(match.params.boardId))) + } + }) + const loadOrJoinBoard = useCallback(async (userId: string, boardTeamId: string, boardId: string) => { // and fetch its data const result: any = await dispatch(loadBoardData(boardId)) @@ -112,16 +181,16 @@ const BoardPage = (props: Props): JSX.Element => { // and set it as most recently viewed board UserSettings.setLastBoardID(teamId, match.params.boardId) - if (match.params.viewId && match.params.viewId !== Constants.globalTeamId) { - dispatch(setCurrentView(match.params.viewId)) - UserSettings.setLastViewId(match.params.boardId, match.params.viewId) + if (viewId && viewId !== Constants.globalTeamId) { + dispatch(setCurrentView(viewId)) + UserSettings.setLastViewId(match.params.boardId, viewId) } if (!props.readonly && me) { loadOrJoinBoard(me.id, teamId, match.params.boardId) } } - }, [teamId, match.params.boardId, match.params.viewId, me?.id]) + }, [teamId, match.params.boardId, viewId, me?.id]) if (props.readonly) { useEffect(() => { @@ -137,12 +206,7 @@ const BoardPage = (props: Props): JSX.Element => { - + {!mobileWarningClosed && @@ -170,7 +234,6 @@ const BoardPage = (props: Props): JSX.Element => {
} { - // Don't display Templates page // if readonly mode and no board defined. (!props.readonly || activeBoardId !== undefined) && diff --git a/webapp/src/pages/boardPage/websocketConnection.tsx b/webapp/src/pages/boardPage/websocketConnection.tsx index b203d9f3d..66ad1537b 100644 --- a/webapp/src/pages/boardPage/websocketConnection.tsx +++ b/webapp/src/pages/boardPage/websocketConnection.tsx @@ -1,90 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState, useMemo} from 'react' -import {batch} from 'react-redux' -import {useLocation} from 'react-router-dom' +import React, {useEffect, useState} from 'react' import {FormattedMessage} from 'react-intl' -import {Block} from '../../blocks/block' -import {ContentBlock} from '../../blocks/contentBlock' -import {CommentBlock} from '../../blocks/commentBlock' -import {Board, BoardMember} from '../../blocks/board' -import {Card} from '../../blocks/card' -import {BoardView} from '../../blocks/boardView' -import wsClient, {Subscription, WSClient} from '../../wsclient' -import './boardPage.scss' -import {updateBoards, updateMembersEnsuringBoardsAndUsers} from '../../store/boards' -import {updateViews} from '../../store/views' -import {updateCards} from '../../store/cards' -import {updateContents} from '../../store/contents' -import {updateComments} from '../../store/comments' -import {useAppSelector, useAppDispatch} from '../../store/hooks' +import wsClient, {WSClient} from '../../wsclient' +import {useAppSelector} from '../../store/hooks' -import {followBlock, getMe, unfollowBlock} from '../../store/users' +import {getMe} from '../../store/users' import {IUser} from '../../user' -import {Constants} from "../../constants" const websocketTimeoutForBanner = 5000 -type Props = { - readonly: boolean - teamId: string - boardId: string - loadAction: (boardID: string) => any -} - -const WebsocketConnection = (props: Props) => { - const dispatch = useAppDispatch() +// WebsocketConnection component checks the websockets client for +// state changes and if the connection is closed, shows a banner +// indicating that there has been a connection error +const WebsocketConnection = () => { const [websocketClosed, setWebsocketClosed] = useState(false) - const queryString = new URLSearchParams(useLocation().search) const me = useAppSelector(getMe) - const token = useMemo(() => { - const storedToken = localStorage.getItem('focalboardSessionId') || '' - if (props.readonly) { - return storedToken || queryString.get('r') || '' - } - return storedToken - }, [props.readonly]) - useEffect(() => { - let subscribedToTeam = false - if (wsClient.state === 'open') { - wsClient.authenticate(props.teamId || Constants.globalTeamId, token) - wsClient.subscribeToTeam(props.teamId || Constants.globalTeamId) - subscribedToTeam = true - } - - const incrementalBlockUpdate = (_: WSClient, blocks: Block[]) => { - const teamBlocks = blocks - - batch(() => { - dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[])) - dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[])) - dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[])) - dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment') as ContentBlock[])) - }) - } - - const incrementalBoardUpdate = (_: WSClient, boards: Board[]) => { - // only takes into account the entities that belong to the team or the user boards - const teamBoards = boards.filter((b: Board) => b.teamId === Constants.globalTeamId || b.teamId === props.teamId) - dispatch(updateBoards(teamBoards)) - } - - const incrementalBoardMemberUpdate = (_: WSClient, members: BoardMember[]) => { - dispatch(updateMembersEnsuringBoardsAndUsers(members)) - } - let timeout: ReturnType const updateWebsocketState = (_: WSClient, newState: 'init'|'open'|'close'): void => { - if (newState === 'open') { - const newToken = localStorage.getItem('focalboardSessionId') || '' - wsClient.authenticate(props.teamId || Constants.globalTeamId, newToken) - wsClient.subscribeToTeam(props.teamId || Constants.globalTeamId) - subscribedToTeam = true - } - if (timeout) { clearTimeout(timeout) } @@ -92,42 +28,21 @@ const WebsocketConnection = (props: Props) => { if (newState === 'close') { timeout = setTimeout(() => { setWebsocketClosed(true) - subscribedToTeam = false }, websocketTimeoutForBanner) } else { setWebsocketClosed(false) } } - wsClient.addOnChange(incrementalBlockUpdate, 'block') - wsClient.addOnChange(incrementalBoardUpdate, 'board') - wsClient.addOnChange(incrementalBoardMemberUpdate, 'boardMembers') - wsClient.addOnReconnect(() => dispatch(props.loadAction(props.boardId))) wsClient.addOnStateChange(updateWebsocketState) - wsClient.setOnFollowBlock((_: WSClient, subscription: Subscription): void => { - if (subscription.subscriberId === me?.id) { - dispatch(followBlock(subscription)) - } - }) - wsClient.setOnUnfollowBlock((_: WSClient, subscription: Subscription): void => { - if (subscription.subscriberId === me?.id) { - dispatch(unfollowBlock(subscription)) - } - }) + return () => { if (timeout) { clearTimeout(timeout) } - if (subscribedToTeam) { - // wsClient.unsubscribeToTeam(props.teamId || '0') - } - wsClient.removeOnChange(incrementalBlockUpdate, 'block') - wsClient.removeOnChange(incrementalBoardUpdate, 'board') - wsClient.removeOnChange(incrementalBoardMemberUpdate, 'boardMembers') - wsClient.removeOnReconnect(() => dispatch(props.loadAction(props.boardId))) wsClient.removeOnStateChange(updateWebsocketState) } - }, [me?.id, props.teamId, props.readonly, props.boardId, props.loadAction]) + }, [me?.id]) if (websocketClosed) { return ( diff --git a/webapp/src/store/boards.ts b/webapp/src/store/boards.ts index 228132df9..95ad29d3b 100644 --- a/webapp/src/store/boards.ts +++ b/webapp/src/store/boards.ts @@ -7,7 +7,7 @@ import {default as client} from '../octoClient' import {Board, BoardMember} from '../blocks/board' import {IUser} from '../user' -import {initialLoad, initialReadOnlyLoad, loadBoardData} from './initialLoad' +import {initialLoad, initialReadOnlyLoad, loadBoardData, loadBoards} from './initialLoad' import {addBoardUsers, removeBoardUsersById, setBoardUsers} from './users' @@ -95,7 +95,7 @@ export const updateMembersEnsuringBoardsAndUsers = createAsyncThunk( }, ) -export const updateMembers = (state: BoardsState, action: PayloadAction) => { +export const updateMembersHandler = (state: BoardsState, action: PayloadAction) => { if (action.payload.length === 0) { return } @@ -140,7 +140,7 @@ const boardsSlice = createSlice({ } } }, - updateMembers, + updateMembers: updateMembersHandler, }, extraReducers: (builder) => { @@ -178,6 +178,12 @@ const boardsSlice = createSlice({ state.myBoardMemberships[boardMember.boardId] = boardMember }) }) + builder.addCase(loadBoards.fulfilled, (state, action) => { + state.boards = {} + action.payload.boards.forEach((board) => { + state.boards[board.id] = board + }) + }) builder.addCase(fetchBoardMembers.fulfilled, (state, action) => { if (action.payload.length === 0) { return @@ -192,11 +198,11 @@ const boardsSlice = createSlice({ }, {}) state.membersInBoards[boardId] = boardMembersMap }) - builder.addCase(updateMembersEnsuringBoardsAndUsers.fulfilled, updateMembers) + builder.addCase(updateMembersEnsuringBoardsAndUsers.fulfilled, updateMembersHandler) }, }) -export const {updateBoards, setCurrent, setLinkToChannel} = boardsSlice.actions +export const {updateBoards, setCurrent, setLinkToChannel, updateMembers} = boardsSlice.actions export const {reducer} = boardsSlice export const getBoards = (state: RootState): {[key: string]: Board} => state.boards?.boards || {} diff --git a/webapp/src/store/initialLoad.ts b/webapp/src/store/initialLoad.ts index af9348dc4..99d5ef871 100644 --- a/webapp/src/store/initialLoad.ts +++ b/webapp/src/store/initialLoad.ts @@ -69,6 +69,16 @@ export const loadBoardData = createAsyncThunk( }, ) +export const loadBoards = createAsyncThunk( + 'loadBoards', + async () => { + const boards = await client.getBoards() + return { + boards, + } + }, +) + export const getUserBlockSubscriptions = (state: RootState): Array => state.users.blockSubscriptions export const getUserBlockSubscriptionList = createSelector( diff --git a/webapp/src/store/teams.ts b/webapp/src/store/teams.ts index 3d2d781c8..5a5ae4b80 100644 --- a/webapp/src/store/teams.ts +++ b/webapp/src/store/teams.ts @@ -35,6 +35,7 @@ export const refreshCurrentTeam = createAsyncThunk( ) type TeamState = { + currentId: string current: Team | null allTeams: Array } @@ -43,11 +44,13 @@ const teamSlice = createSlice({ name: 'teams', initialState: { current: null, + currentId: '', allTeams: [], } as TeamState, reducers: { setTeam: (state, action: PayloadAction) => { const teamID = action.payload + state.currentId = teamID const team = state.allTeams.find((t) => t.id === teamID) if (!team) { Utils.log(`Unable to find team in store. TeamID: ${teamID}`) @@ -80,6 +83,7 @@ const teamSlice = createSlice({ export const {setTeam} = teamSlice.actions export const {reducer} = teamSlice +export const getCurrentTeamId = (state: RootState): string => state.teams.currentId export const getCurrentTeam = (state: RootState): Team|null => state.teams.current export const getFirstTeam = (state: RootState): Team|null => state.teams.allTeams[0] export const getAllTeams = (state: RootState): Array => state.teams.allTeams diff --git a/webapp/src/wsclient.ts b/webapp/src/wsclient.ts index e92c3571c..1dede881e 100644 --- a/webapp/src/wsclient.ts +++ b/webapp/src/wsclient.ts @@ -97,10 +97,15 @@ type ChangeHandlers = { BoardMember: OnChangeHandler[] } +type Subscriptions = { + Teams: Record +} + class WSClient { ws: WebSocket|null = null client: MMWebSocketClient|null = null onPluginReconnect: null|(() => void) = null + token = '' pluginId = '' pluginVersion = '' teamId = '' @@ -121,6 +126,7 @@ class WSClient { private updatedData: UpdatedData = {Blocks: [], Categories: [], BoardCategories: [], Boards: [], BoardMembers: []} private updateTimeout?: NodeJS.Timeout private errorPollId?: NodeJS.Timeout + private subscriptions: Subscriptions = {Teams: {}} private logged = false @@ -154,6 +160,17 @@ class WSClient { Utils.log(`WSClient initialised for plugin id "${pluginId}"`) } + resetSubscriptions() { + this.subscriptions = {Teams: {}} as Subscriptions + } + + // this function sends the necessary commands for the connection + // to subscribe to all registered subscriptions + subscribe() { + Utils.log('Sending commands for the registered subscriptions') + Object.keys(this.subscriptions.Teams).forEach(teamId => this.sendSubscribeToTeamCommand(teamId)) + } + sendCommand(command: WSCommand): void { if (this.client !== null) { const {action, ...data} = command @@ -164,6 +181,30 @@ class WSClient { this.ws?.send(JSON.stringify(command)) } + sendAuthenticationCommand(token: string): void { + const command = { action: ACTION_AUTH, token } + + this.sendCommand(command) + } + + sendSubscribeToTeamCommand(teamId: string): void { + const command: WSCommand = { + action: ACTION_SUBSCRIBE_TEAM, + teamId, + } + + this.sendCommand(command) + } + + sendUnsubscribeToTeamCommand(teamId: string): void { + const command: WSCommand = { + action: ACTION_UNSUBSCRIBE_TEAM, + teamId, + } + + this.sendCommand(command) + } + addOnChange(handler: OnChangeHandler, type: ChangeHandlerType): void { switch (type) { case 'block': @@ -275,6 +316,10 @@ class WSClient { const onConnect = () => { Utils.log('WSClient in plugin mode, reusing Mattermost WS connection') + // if there are any subscriptions set by the + // components, send their subscribe messages + this.subscribe() + for (const handler of this.onStateChange) { handler(this, 'open') } @@ -341,6 +386,16 @@ class WSClient { ws.onopen = () => { Utils.log('WSClient webSocket opened.') this.state = 'open' + + // if has a token defined when connecting, authenticate + if (this.token) { + this.sendAuthenticationCommand(this.token) + } + + // if there are any subscriptions set by the components, + // send their subscribe messages + this.subscribe() + for (const handler of this.onStateChange) { handler(this, 'open') } @@ -363,6 +418,8 @@ class WSClient { } this.state = 'close' setTimeout(() => { + // ToDo: assert that this actually runs the onopen + // contents (auth + this.subscribe()) this.open() for (const handler of this.onReconnect) { handler(this) @@ -498,22 +555,55 @@ class WSClient { } } - authenticate(teamId: string, token: string): void { - if (!this.hasConn()) { - Utils.assertFailure('WSClient.addBlocks: ws is not open') - return - } - + authenticate(token: string): void { if (!token) { + Utils.assertFailure('WSClient trying to authenticate without a token') return } - const command = { - action: ACTION_AUTH, - token, - teamId, + + if (this.hasConn()) { + this.sendAuthenticationCommand(token) } - this.sendCommand(command) + this.token = token + } + + subscribeToTeam(teamId: string): void { + if (!this.subscriptions.Teams[teamId]) { + Utils.log(`First component subscribing to team ${teamId}`) + // only send command if the WS connection has already been + // stablished. If not, the connect or reconnect functions + // will do + if (this.hasConn()) { + this.sendSubscribeToTeamCommand(teamId) + } + + this.teamId = teamId + this.subscriptions.Teams[teamId] = 1 + return + } + + this.subscriptions.Teams[teamId] += 1 + } + + unsubscribeToTeam(teamId: string): void { + if (!this.subscriptions.Teams[teamId]) { + Utils.logError('Component trying to unsubscribe to a team when no subscriptions are registered. Doing nothing') + return + } + + this.subscriptions.Teams[teamId] -= 1 + if (this.subscriptions.Teams[teamId] === 0) { + Utils.log(`Last subscription to team ${teamId} being removed`) + if (this.hasConn()) { + this.sendUnsubscribeToTeamCommand(teamId) + } + + if (teamId == this.teamId) { + this.teamId = '' + } + delete this.subscriptions.Teams[teamId] + } } subscribeToBlocks(teamId: string, blockIds: string[], readToken = ''): void { @@ -532,34 +622,6 @@ class WSClient { this.sendCommand(command) } - unsubscribeToTeam(teamId: string): void { - if (!this.hasConn()) { - Utils.assertFailure('WSClient.subscribeToTeam: ws is not open') - return - } - - const command: WSCommand = { - action: ACTION_UNSUBSCRIBE_TEAM, - teamId, - } - - this.sendCommand(command) - } - - subscribeToTeam(teamId: string): void { - if (!this.hasConn()) { - Utils.assertFailure('WSClient.subscribeToTeam: ws is not open') - return - } - - const command: WSCommand = { - action: ACTION_SUBSCRIBE_TEAM, - teamId, - } - - this.sendCommand(command) - } - unsubscribeFromBlocks(teamId: string, blockIds: string[], readToken = ''): void { if (!this.hasConn()) { Utils.assertFailure('WSClient.removeBlocks: ws is not open') @@ -609,21 +671,6 @@ class WSClient { }, this.notificationDelay) } - // private queueUpdateBoardNotification(board: Board) { - // this.updatedBoards = this.updatedBoards.filter((o) => o.id !== board.id) // Remove existing queued update - // // ToDo: hydrate required? - // // this.updatedBoards.push(OctoUtils.hydrateBoard(board)) - // this.updatedBoards.push(board) - // if (this.updateTimeout) { - // clearTimeout(this.updateTimeout) - // this.updateTimeout = undefined - // } - // - // this.updateTimeout = setTimeout(() => { - // this.flushUpdateNotifications() - // }, this.notificationDelay) - // } - private logUpdateNotification() { for (const block of this.updatedData.Blocks) { Utils.log(`WSClient flush update block: ${block.id}`)