Skip to content

Commit

Permalink
Implement messages threads in react (#1173)
Browse files Browse the repository at this point in the history
* Add Thread component for /messages/<username>

* Marginally nicer tribes-in-common code

* Minor formatting update

* Style refinements, more flex, less row

* Indentation fix

* Implement infinite scroller for chat thread

* Update package lock

* Some code tidying

* Mark messages as read

* Add message sending and scrolling to bottom

* Small code tidy

* Don't send empty messages

* Save message draft in local storage

* Don't add fake messages on initial fetch

* Still show message input when no messages

* Add "You haven't been talking yet" bit in messages

* Add message for non-public user

* Add "Your profile seems quite empty" bit

* Use lodash like lodash/func not lodash.func

I was wrong!

* Add quick replies

* Remove premature optimization

Should probably use useMemo there anyway

* Preserve position on resize and styling fixes

* More styled-components, less inline style

* Extra a few components into their own files

* Add Prettier - an opinionated code formatter (#932)

Lint and prettify staged files in pre-commit hook
Reformat js, md, html and json files
Remove eslint rules that conflict with prettier

* Reformat messages thread files with prettier

* Add message when user does not exist

* Minor tidy

* Put all messages text into i18n

* A little refactor

* Use loading indictor

... and fixup some imports

* Add initial message thread tests

* Update translations

Not sure I should add this as some of the changes look odd...

* Add note about fake mongo id generator

* Move more general components into better places

* Implement paginated messages loading

* Fix tests to use new paginated API

* Remove comment

It's actually too hard to write a pagination test for now
because there is no scrollHeight in jsdom as it's not actually
doing the layout stuff, so can't really test that bit.

Would be nice to have a way to test the _logic_ though...

* Add some documentation about InfiniteMessages

* Remove undeeded @todos

* Redirect back to inbox if visiting self thread

* Remove old angular messages stuff

* Remove redundant error handling

* Use mongolib to generate test id

* Revert translations to master

* Don't use translation function with dynamic text

* Remove unused thread dimensions directive

* Fix messages-read API response

* Remove trailing <br> inside TrEditor

Moving it a bit closer to the source

* Show monkeybox for the correct chat user

* Refocus input after sending a message

* Fix link to profile in Monkeybox

* Always show reply box for existing conversations

* Add Monkeybox "Languages" heading to i18n

Co-authored-by: Mikael Korpela <[email protected]>
  • Loading branch information
nicksellen and simison authored Apr 4, 2020
1 parent 734c546 commit 3acc87e
Show file tree
Hide file tree
Showing 35 changed files with 16,397 additions and 1,022 deletions.
2 changes: 2 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ module.exports = {
// doesn't play nicely with jest, see jest.setup.js for more info
'angular-waypoints/dist/angular-waypoints.all':
'<rootDir>/jest/jest.empty-module.js',
'^.+\\.(css)$': '<rootDir>/jest/jest.empty-module.js',
},
testMatch: ['<rootDir>/modules/*/tests/client/**/*.tests.js'],
testEnvironment: 'jest-environment-jsdom-sixteen',
setupFilesAfterEnv: ['<rootDir>/jest/jest.setup.js'],
transform: {
'^.+\\.js$': 'babel-jest',
Expand Down
202 changes: 202 additions & 0 deletions modules/core/client/components/TrEditor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import MediumEditor from 'react-medium-editor';
import 'medium-editor/dist/css/medium-editor.css';

const options = {
disableReturn: false,
disableDoubleReturn: false,
disableExtraSpaces: false,
// Automatically turns URLs entered into
// the text field into HTML anchor tags
autoLink: false,
paste: {
// Forces pasting as plain text
forcePlainText: false,
// Cleans pasted content from different sources, like google docs etc
cleanPastedHTML: true,
// List of element attributes to remove during
// paste when `cleanPastedHTML` is `true`
cleanAttrs: [
'class',
'style',
'dir',
'id',
'title',
'target',
'tabindex',
'onclick',
'oncontextmenu',
'ondblclick',
'onmousedown',
'onmouseenter',
'onmouseleave',
'onmousemove',
'onmouseover',
'onmouseout',
'onmouseup',
'onwheel',
'onmousewheel',
'onmessage',
'ontouchstart',
'ontouchmove',
'ontouchend',
'ontouchcancel',
'onload',
'onscroll',
],
// list of element tag names to remove during
// paste when `cleanPastedHTML` is `true`
cleanTags: [
'link',
'iframe',
'frameset',
'noframes',
'object',
'video',
'audio',
'track',
'source',
'base',
'basefont',
'applet',
'param',
'embed',
'script',
'meta',
'head',
'title',
'svg',
'script',
'style',
'input',
'textarea',
'form',
'hr',
'select',
'optgroup',
'label',
'img',
'canvas',
'area',
'map',
'figure',
'picture',
'figcaption',
'noscript',
],
// list of element tag names to unwrap (remove the element tag but retain
// its child elements) during paste when `cleanPastedHTML` is `true`
unwrapTags: [
'!DOCTYPE',
'html',
'body',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'th',
'tr',
'td',
'tbody',
'thead',
'tfoot',
'article',
'header',
'footer',
'section',
'aside',
'font',
'center',
'big',
'code',
'pre',
'small',
'button',
'label',
'fieldset',
'legend',
'datalist',
'keygen',
'output',
'nav',
'main',
'div',
'span',
],
},
// Toolbar buttons which appear when highlighting text
toolbar: {
buttons: [
{
name: 'bold',
contentDefault: '<span class="icon-bold"></span>',
},
{
name: 'italic',
contentDefault: '<span class="icon-italic"></span>',
},
{
name: 'underline',
contentDefault: '<span class="icon-underline"></span>',
},
{
name: 'anchor',
contentDefault: '<span class="icon-link"></span>',
},
{
name: 'quote',
contentDefault: '<span class="icon-quote"></span>',
},
{
name: 'unorderedlist',
contentDefault: '<span class="icon-list"></span>',
},
],
},
};

// medium-editor can give us a <br> at the end that we don't want
function removeTrailingBr(value) {
return value.replace(/<br><\/p>$/, '</p>');
}

export default function TrEditor({ id, text, onChange, onCtrlEnter }) {
const ref = React.createRef();

useEffect(() => {
const { medium } = ref.current;
const onEnter = event => event.ctrlKey && onCtrlEnter(event);
medium.subscribe('editableKeydownEnter', onEnter);
return () => {
// the onCtrlEnter that gets passed through will change quite a lot as it
// probably gets redefined over and over with different bound state
// this means it'll actually subscribe/unsubscribe per keypress...
// seems a bit much, but that's how these react hooks work!
medium.unsubscribe('editableKeydownEnter', onEnter);
};
}, [onCtrlEnter]);

const props = { id, text, options, className: 'tr-editor' };
return (
<MediumEditor
ref={ref}
onChange={value => onChange(removeTrailingBr(value))}
{...props}
/>
);
}

TrEditor.defaultProps = {
onCtrlEnter: () => {},
};

TrEditor.propTypes = {
id: PropTypes.string,
text: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onCtrlEnter: PropTypes.func.isRequired,
};
23 changes: 15 additions & 8 deletions modules/core/client/filters/plain-text-length.client.filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,25 @@
* Usage via JS:
* $filter('plainTextLength')('myString')
*
* Usage via JS import:
* import plainTextLength from '@/modules/core/client/filters/plain-text-length.client.filter';
* plainTextLength('mystring')
*
* @link https://docs.angularjs.org/api/ng/filter/filter
* @link http://stackoverflow.com/a/17315483/1984644
*/
angular.module('core').filter('plainTextLength', plainTextLengthFilter);

function plainTextLengthFilter() {
return function(string) {
return string && angular.isString(string)
? String(string)
.replace(/&nbsp;/g, ' ')
.replace(/<[^>]+>/gm, '')
.trim().length
: 0;
};
return plainTextLength;
}

// Allow it to be used via direct import too
export default function plainTextLength(string) {
return string && angular.isString(string)
? String(string)
.replace(/&nbsp;/g, ' ')
.replace(/<[^>]+>/gm, '')
.trim().length
: 0;
}
8 changes: 8 additions & 0 deletions modules/core/client/services/angular-compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ export function $broadcast(...args) {
export function eventTrack(...args) {
return get('$analytics').eventTrack(...args);
}

export function getRouteParams() {
return get('$stateParams');
}

export function go(...args) {
return get('$state').go(...args);
}
39 changes: 37 additions & 2 deletions modules/messages/client/api/messages.api.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import axios from 'axios';
import parseLinkheader from 'parse-link-header';

export async function fetchThreads(params = {}) {
const { data: threads, headers } = await axios.get('/api/messages', {
async function fetchWithNextParams(url, params = {}) {
const { data, headers } = await axios.get(url, {
params,
});
let nextParams;
Expand All @@ -15,8 +15,43 @@ export async function fetchThreads(params = {}) {
nextParams = params;
}
}
return {
data,
nextParams,
};
}

export async function fetchThreads(params = {}) {
const { data: threads, nextParams } = await fetchWithNextParams(
'/api/messages',
params,
);
return {
threads,
nextParams,
};
}

export async function fetchMessages(userId, params = {}) {
const { data: messages, nextParams } = await fetchWithNextParams(
`/api/messages/${userId}`,
params,
);
return {
messages,
nextParams,
};
}

export async function sendMessage(userToId, content) {
const { data: message } = await axios.post('/api/messages', {
userTo: userToId,
content,
read: false,
});
return message;
}

export async function markRead(messageIds) {
await axios.post('/api/messages-read', { messageIds });
}
50 changes: 50 additions & 0 deletions modules/messages/client/components/Flashcard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

export default function Flashcard() {
const { t } = useTranslation('messages');

const flashcards = [
{
title: t('Make sure your profile is complete'),
content: t(
"You're much more likely to get a positive response if you have written a bit about yourself.",
),
},
{
title: t('Tell a little bit about yourself'),
content: t(
"You're much more likely to get a positive response if you have written a bit about yourself.",
),
},
{
title: t('Explain to them why you are choosing them'),
content: t(
'...explaining that you are interested in meeting them, not just looking for free accommodation.',
),
},
{
title: t("Tell your host why you're on a trip"),
content: t(
'What are your expectations in regards with going through their town?',
),
},
{
title: t('Trustroots is very much about spontaneous travel'),
content: t("Don't write to people 2 months ahead."),
},
];

function getRandomCard() {
return flashcards[Math.floor(Math.random() * flashcards.length)];
}

const { title, content } = getRandomCard();
return (
<a href="/guide" className="tr-flashcards text-center font-brand-regular">
<small className="tr-flashcards-tip text-uppercase">{t('Tip')}</small>
<p className="tr-flashcards-title">{title}</p>
<p className="tr-flashcards-content">{content}</p>
</a>
);
}
2 changes: 1 addition & 1 deletion modules/messages/client/components/Inbox.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
$broadcast,
eventTrack,
} from '@/modules/core/client/services/angular-compat';
import InboxThread from 'modules/messages/client/components/InboxThread';
import InboxThread from '@/modules/messages/client/components/InboxThread';
import { userType } from '@/modules/users/client/users.prop-types';

export default function Inbox({ user }) {
Expand Down
Loading

0 comments on commit 3acc87e

Please sign in to comment.