Add UI elements for searching and selecting members in a group chat

pull/513/head
Maxim Shishmarev 6 years ago
parent 3152637cdc
commit 1496a368e9

@ -126,6 +126,7 @@
<div class='bottom-bar' id='footer'>
<div class='emoji-panel-container'></div>
<div class='member-list-container'></div>
<div class='attachment-list'></div>
<div class='compose'>
<form class='send clearfix file-input'>
@ -681,6 +682,7 @@
<script type='text/javascript' src='js/views/message_view.js'></script>
<script type='text/javascript' src='js/views/key_verification_view.js'></script>
<script type='text/javascript' src='js/views/message_list_view.js'></script>
<script type='text/javascript' src='js/views/member_list_view.js'></script>
<script type='text/javascript' src='js/views/group_member_list_view.js'></script>
<script type='text/javascript' src='js/views/recorder_view.js'></script>
<script type='text/javascript' src='js/views/conversation_view.js'></script>

@ -43,6 +43,7 @@ const {
const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const { MainHeader } = require('../../ts/components/MainHeader');
const { MemberList } = require('../../ts/components/conversation/MemberList');
const {
MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
@ -217,6 +218,7 @@ exports.setup = (options = {}) => {
Lightbox,
LightboxGallery,
MainHeader,
MemberList,
MediaGallery,
Message,
MessageBody,

@ -280,6 +280,13 @@
this.$('.discussion-container').append(this.view.el);
this.view.render();
this.memberView = new Whisper.MemberListView({
el: this.$('.member-list-container'),
onClicked: this.selectMember.bind(this),
});
this.memberView.render();
this.$messageField = this.$('.send-message');
this.onResize = this.forceUpdateMessageFieldSize.bind(this);
@ -307,9 +314,9 @@
events: {
keydown: 'onKeyDown',
'submit .send': 'checkUnverifiedSendMessage',
'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize',
'submit .send': 'handleSubmitPressed',
'input .send-message': 'handleInputEvent',
'keydown .send-message': 'handleInputEvent',
'keyup .send-message': 'onKeyUp',
click: 'onClick',
'click .bottom-bar': 'focusMessageField',
@ -1556,6 +1563,34 @@
dialog.focusCancel();
},
selectMember(member) {
const stripQuery = input => {
const pos = input.lastIndexOf('@');
// This should never happen, but we check just in case
if (pos === -1) {
return input;
}
return input.substr(0, pos);
};
const prev = stripQuery(this.$messageField.val());
const result = `${prev}@${member.authorPhoneNumber} `;
this.$messageField.val(result);
this.$messageField.trigger('input');
},
async handleSubmitPressed(e, options = {}) {
if (this.memberView.members_shown()) {
const member = this.memberView.selectedMember();
this.selectMember(member);
} else {
await this.checkUnverifiedSendMessage(e, options);
}
},
async checkUnverifiedSendMessage(e, options = {}) {
e.preventDefault();
this.sendStart = Date.now();
@ -2112,7 +2147,10 @@
}
},
updateMessageFieldSize(event) {
// Note: not only input, but keypresses too (rename?)
handleInputEvent(event) {
this.maybeShowMembers(event);
const keyCode = event.which || event.keyCode;
if (
@ -2126,6 +2164,41 @@
this.$('.bottom-bar form').submit();
return;
}
const keyPressedLeft = keyCode === 37;
const keyPressedUp = keyCode === 38;
const keyPressedRight = keyCode === 39;
const keyPressedDown = keyCode === 40;
const keyPressedTab = keyCode === 9;
const preventDefault = keyPressedUp || keyPressedDown || keyPressedTab;
if (this.memberView.members_shown() && preventDefault) {
if (keyPressedDown) {
this.memberView.selectDown();
} else if (keyPressedUp) {
this.memberView.selectUp();
} else if (keyPressedTab) {
// Tab is treated as Enter in this context
this.handleSubmitPressed();
}
const $selected = this.$('.member-selected');
if ($selected.length) {
$selected[0].scrollIntoView({ behavior: 'smooth' });
}
event.preventDefault();
return;
}
if (keyPressedLeft || keyPressedRight) {
this.$messageField.trigger('input');
}
this.updateMessageFieldSize();
},
updateMessageFieldSize() {
this.toggleMicrophone();
this.view.measureScrollPosition();
@ -2150,6 +2223,66 @@
this.view.scrollToBottomIfNeeded();
},
maybeShowMembers(event) {
const filterMembers = (caseSensitiveQuery, member) => {
const { authorPhoneNumber, authorProfileName } = member;
const profileName = authorProfileName
? authorProfileName.toLowerCase()
: '';
const query = caseSensitiveQuery.toLowerCase();
if (
authorPhoneNumber.indexOf(query) !== -1 ||
profileName.indexOf(query) !== -1
) {
return true;
}
return false;
};
const getQuery = input => {
const atPos = input.lastIndexOf('@');
if (atPos === -1) {
return null;
}
// Whitespace is required right before @
if (atPos > 0 && /\w/.test(input.substr(atPos - 1, 1))) {
return null;
}
const query = input.substr(atPos + 1);
// No whitespaces allowed in a query
if (/\s/.test(query)) {
return null;
}
return query;
};
const query = getQuery(event.target.value);
// TODO: for now, extract members from the conversation,
// but change to use a server endpoint in the future
let allMembers = this.model.messageCollection.models.map(
d => d.propsForMessage
);
allMembers = allMembers.filter(d => !!d);
allMembers = _.uniq(allMembers, true, d => d.authorPhoneNumber);
let membersToShow = [];
if (query === null) {
// do nothing
} else if (query !== '') {
membersToShow = allMembers.filter(filterMembers.bind(null, query));
} else {
membersToShow = allMembers;
}
this.memberView.update_members(membersToShow);
},
forceUpdateMessageFieldSize(event) {
if (this.isHidden()) {
return;

@ -0,0 +1,65 @@
/* global _, Whisper, */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.MemberListView = Whisper.View.extend({
initialize(options) {
this.member_list = [];
this.selected_idx = 0;
this.onClicked = options.onClicked;
this.listenTo(this.model, 'change', this.render);
},
render() {
if (this.memberView) {
this.memberView.remove();
this.memberView = null;
}
this.memberView = new Whisper.ReactWrapperView({
className: 'member-list',
Component: window.Signal.Components.MemberList,
props: {
members: this.member_list,
selected: this.selectedMember(),
onMemberClicked: this.handleMemberClicked.bind(this),
},
});
this.$el.append(this.memberView.el);
return this;
},
handleMemberClicked(member) {
this.onClicked(member);
},
update_members(members) {
if (!_.isEqual(this.member_list, members)) {
// Whenever the list is updated, we reset the selection
this.selected_idx = 0;
this.member_list = members;
this.render();
}
},
members_shown() {
return this.member_list.length !== 0;
},
selectUp() {
this.selected_idx = Math.max(this.selected_idx - 1, 0);
this.render();
},
selectDown() {
this.selected_idx = Math.min(
this.selected_idx + 1,
this.member_list.length - 1
);
this.render();
},
selectedMember() {
return this.member_list[this.selected_idx];
},
});
})();

@ -0,0 +1,49 @@
.member-list-container {
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: scroll;
.member-item {
padding: 4px;
user-select: none;
&:hover:not(.member-selected) {
background-color: $color-light-20;
}
background-color: $color-light-10;
.name-part {
font-weight: 300;
margin-left: 6px;
}
.pubkey-part {
margin-left: 6px;
}
}
.member-selected {
background-color: $color-light-35;
}
}
.dark-theme {
.member-list-container {
.member-item {
&:hover:not(.member-selected) {
background-color: $color-dark-55;
}
background-color: $color-dark-70;
color: white;
}
.member-selected {
background-color: $color-dark-60;
}
}
}

@ -114,6 +114,7 @@ $color-white-08: rgba($color-white, 0.8);
$color-white-085: rgba($color-white, 0.85);
$color-light-02: #f9fafa;
$color-light-10: #eeefef;
$color-light-20: #c1c5cd;
$color-light-35: #a4a6a9;
$color-light-45: #8b8e91;
$color-light-60: #62656a;

@ -10,6 +10,7 @@
@import 'lightbox';
@import 'recorder';
@import 'emoji';
@import 'mentions';
@import 'settings';
@import 'password';

@ -0,0 +1,93 @@
import React from 'react';
import classNames from 'classnames';
import { Avatar } from '../Avatar';
interface MemberItemProps {
member: any;
selected: Boolean;
onClicked: any;
}
class MemberItem extends React.Component<MemberItemProps> {
constructor(props: any) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
public render() {
const name = this.props.member.authorProfileName;
const pubkey = this.props.member.authorPhoneNumber;
const selected = this.props.selected;
return (
<div
role="button"
className={classNames(
'member-item',
selected ? 'member-selected' : null
)}
onClick={this.handleClick}
>
{this.renderAvatar()}
<span className="name-part">{name}</span>
<span className="pubkey-part">{pubkey}</span>
</div>
);
}
private handleClick() {
this.props.onClicked(this.props.member);
}
private renderAvatar() {
return (
<Avatar
avatarPath={this.props.member.authorAvatarPath}
color={this.props.member.authorColor}
conversationType="direct"
i18n={this.props.member.i18n}
name={this.props.member.authorName}
phoneNumber={this.props.member.authorPhoneNumber}
profileName={this.props.member.authorProfileName}
size={28}
/>
);
}
}
interface MemberListProps {
members: [any];
selected: any;
onMemberClicked: any;
}
export class MemberList extends React.Component<MemberListProps> {
constructor(props: any) {
super(props);
this.handleMemberClicked = this.handleMemberClicked.bind(this);
}
public render() {
const { members } = this.props;
const itemList = members.map(item => {
const selected = item === this.props.selected;
return (
<MemberItem
key={item.id}
member={item}
selected={selected}
onClicked={this.handleMemberClicked}
/>
);
});
return <div>{itemList}</div>;
}
private handleMemberClicked(member: any) {
this.props.onMemberClicked(member);
}
}
Loading…
Cancel
Save