You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/js/views/conversation_view.js

1196 lines
34 KiB
JavaScript

/* global
$,
_,
extension,
i18n,
Signal,
storage,
Whisper,
*/
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
5 years ago
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') };
},
});
Whisper.ConversationView = Whisper.View.extend({
className() {
return ['conversation', this.model.get('type')].join(' ');
},
id() {
return `conversation-${this.model.cid}`;
},
template: $('#conversation').html(),
render_attributes() {
return {
'disable-inputs': false,
'send-message': i18n('sendMessage'),
};
},
initialize(options) {
this.listenTo(this.model, 'destroy', this.stopListening);
this.listenTo(this.model, 'newmessage', this.addMessage);
this.listenTo(this.model, 'opened', this.onOpened);
this.listenTo(this.model, 'prune', this.onPrune);
this.listenTo(this.model, 'unload', () => this.unload('model trigger'));
this.listenTo(this.model, 'typing-update', this.renderTypingBubble);
this.listenTo(
this.model.messageCollection,
'show-identity',
this.showSafetyNumber
);
this.listenTo(this.model.messageCollection, 'force-send', this.forceSend);
this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage);
this.listenTo(
this.model.messageCollection,
'scroll-to-message',
this.scrollToMessage
);
this.listenTo(
this.model.messageCollection,
'show-contact-detail',
this.showContactDetail
);
this.listenTo(
this.model.messageCollection,
'show-lightbox',
this.showLightbox
);
this.listenTo(
this.model.messageCollection,
'download',
this.downloadAttachment
);
this.listenTo(
this.model.messageCollection,
'open-conversation',
this.openConversation
);
this.listenTo(
this.model.messageCollection,
'show-message-detail',
this.showMessageDetail
);
this.lazyUpdateVerified = _.debounce(
this.model.updateVerified.bind(this.model),
1000 // one second
);
this.throttledGetProfiles = _.throttle(
this.model.getProfiles.bind(this.model),
1000 * 60 * 5 // five minutes
);
6 years ago
this.debouncedMaybeGrabLinkPreview = _.debounce(
this.maybeGrabLinkPreview.bind(this),
200
);
this.render();
this.window = options.window;
Whisper.events.on('mediaPermissionsChanged', () =>
this.toggleMicrophone()
);
this.memberView.render();
this.$messageField = this.$('.send-message');
this.onResize = this.forceUpdateMessageFieldSize.bind(this);
this.window.addEventListener('resize', this.onResize);
this.onFocus = () => {
if (!this.isHidden()) {
this.markRead();
}
};
this.window.addEventListener('focus', this.onFocus);
extension.windows.onClosed(() => {
this.unload('windows closed');
});
this.$('.send-message').focus(this.focusBottomBar.bind(this));
this.$('.send-message').blur(this.unfocusBottomBar.bind(this));
this.selectMember = this.selectMember.bind(this);
const updateMemberList = async () => {
const allPubKeys = await window.Signal.Data.getPubkeysInPublicConversation(
this.model.id
);
const allMembers = allPubKeys.map(pubKey => {
const conv = window.getConversationController().get(pubKey);
let profileName = 'Anonymous';
if (conv) {
profileName = conv.getProfileName();
}
return {
id: pubKey,
authorPhoneNumber: pubKey,
authorProfileName: profileName,
};
});
window.lokiPublicChatAPI.setListOfMembers(allMembers);
};
6 years ago
if (this.model.isPublic()) {
updateMemberList();
setInterval(updateMemberList, 10000);
}
},
events: {
keydown: 'onKeyDown',
'submit .send': 'handleSubmitPressed',
'input .send-message': 'handleInputEvent',
'keydown .send-message': 'handleInputEvent',
6 years ago
'keyup .send-message': 'onKeyUp',
click: 'onClick',
'click .bottom-bar': 'focusMessageField',
'click .capture-audio .microphone': 'captureAudio',
'click .module-scroll-down': 'scrollToBottom',
'focus .send-message': 'focusBottomBar',
'change .file-input': 'toggleMicrophone',
'blur .send-message': 'unfocusBottomBar',
'force-resize': 'forceUpdateMessageFieldSize',
'change input.file-input': 'onChoseAttachment',
dragover: 'onDragOver',
dragleave: 'onDragLeave',
// TODO(Loki): restore when we support attachments
// drop: 'onDrop',
// paste: 'onPaste',
},
onPaste(e) {
this.fileInput.onPaste(e);
},
onPrune() {
if (!this.model.messageCollection.length || !this.lastActivity) {
return;
}
const oneHourAgo = Date.now() - 60 * 60 * 1000;
if (this.isHidden() && this.lastActivity < oneHourAgo) {
this.unload('inactivity');
} else if (this.view.atBottom()) {
this.trim();
}
},
unload(reason) {
window.log.info(
'unloading conversation',
this.model.idForLogging(),
'due to:',
reason
);
this.fileInput.remove();
if (this.captureAudioView) {
this.captureAudioView.remove();
}
if (this.banner) {
this.banner.remove();
}
if (this.lastSeenIndicator) {
this.lastSeenIndicator.remove();
}
if (this.scrollDownButton) {
this.scrollDownButton.remove();
}
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightBoxView) {
this.lightBoxView.remove();
}
if (this.lightboxGalleryView) {
this.lightboxGalleryView.remove();
}
if (this.panels && this.panels.length) {
for (let i = 0, max = this.panels.length; i < max; i += 1) {
const panel = this.panels[i];
panel.remove();
}
}
this.window.removeEventListener('resize', this.onResize);
this.window.removeEventListener('focus', this.onFocus);
this.view.remove();
this.remove();
this.model.messageCollection.forEach(model => {
model.trigger('unload');
});
this.model.messageCollection.reset([]);
},
trim() {
const MAX = 100;
const toRemove = this.model.messageCollection.length - MAX;
if (toRemove <= 0) {
return;
}
const models = [];
for (let i = 0; i < toRemove; i += 1) {
const model = this.model.messageCollection.at(i);
models.push(model);
}
if (!models.length) {
return;
}
window.log.info(
'trimming conversation',
this.model.idForLogging(),
'of',
models.length,
'old messages'
);
this.model.messageCollection.remove(models);
_.forEach(models, model => {
model.trigger('unload');
});
},
markAllAsVerifiedDefault(unverified) {
return Promise.all(
unverified.map(contact => {
if (contact.isUnverified()) {
return contact.setVerifiedDefault();
}
return null;
})
);
},
markAllAsApproved(untrusted) {
return Promise.all(untrusted.map(contact => contact.setApproved()));
},
openSafetyNumberScreens(unverified) {
if (unverified.length === 1) {
this.showSafetyNumber(unverified.at(0));
return;
}
this.showMembers(null, unverified, { needVerify: true });
},
onVerifiedChange() {
if (this.model.isUnverified()) {
const unverified = this.model.getUnverified();
let message;
if (!unverified.length) {
return;
}
if (unverified.length > 1) {
message = i18n('multipleNoLongerVerified');
} else {
message = i18n('noLongerVerified', unverified.at(0).getTitle());
}
// Need to re-add, since unverified set may have changed
if (this.banner) {
this.banner.remove();
this.banner = null;
}
this.banner = new Whisper.BannerView({
message,
onDismiss: () => {
this.markAllAsVerifiedDefault(unverified);
},
onClick: () => {
this.openSafetyNumberScreens(unverified);
},
});
const container = this.$('.discussion-container');
container.append(this.banner.el);
} else if (this.banner) {
this.banner.remove();
this.banner = null;
}
},
renderTypingBubble() {
const timers = this.model.contactTypingTimers || {};
const records = _.values(timers);
const mostRecent = _.first(_.sortBy(records, 'timestamp'));
if (!mostRecent && this.typingBubbleView) {
this.typingBubbleView.remove();
this.typingBubbleView = null;
}
},
updateUnread() {
this.resetLastSeenIndicator();
// Waiting for scrolling caused by resetLastSeenIndicator to settle down
setTimeout(this.markRead.bind(this), 1);
},
onLoaded() {
const view = this.loadingScreen;
if (view) {
const openDelta = Date.now() - this.openStart;
window.log.info(
'Conversation',
this.model.idForLogging(),
'took',
openDelta,
'milliseconds to load'
);
this.loadingScreen = null;
view.remove();
}
},
onOpened() {
this.openStart = Date.now();
this.lastActivity = Date.now();
this.model.updateLastMessage();
if (this.model.isRss()) {
$('.compose').hide();
$('.conversation-stack').removeClass('conversation-stack-no-border');
$('.conversation-stack').addClass('conversation-stack-border');
} else {
$('.compose').show();
$('.conversation-stack').removeClass('conversation-stack-border');
$('.conversation-stack').addClass('conversation-stack-no-border');
}
// const statusPromise = this.throttledGetProfiles();
// // eslint-disable-next-line more/no-then
// this.statusFetch = statusPromise.then(() =>
// // eslint-disable-next-line more/no-then
// this.model.updateVerified().then(() => {
// this.onVerifiedChange();
// this.statusFetch = null;
// window.log.info('done with status fetch');
// })
// );
// We schedule our catch-up decrypt right after any in-progress fetch of
// messages from the database, then ensure that the loading screen is only
// dismissed when that is complete.
const messagesLoaded = this.inProgressFetch || Promise.resolve();
// eslint-disable-next-line more/no-then
messagesLoaded.then(this.onLoaded.bind(this), this.onLoaded.bind(this));
this.view.resetScrollPosition();
this.$el.trigger('force-resize');
this.focusMessageField();
this.renderTypingBubble();
if (this.inProgressFetch) {
// eslint-disable-next-line more/no-then
this.inProgressFetch.then(this.updateUnread.bind(this));
} else {
this.updateUnread();
}
},
async loadMoreMessages() {
if (this.inProgressFetch) {
return;
}
this.view.measureScrollPosition();
const startingHeight = this.view.scrollHeight;
// We delay this work to let scrolling/layout settle down first
setTimeout(() => {
this.view.measureScrollPosition();
const endingHeight = this.view.scrollHeight;
const delta = endingHeight - startingHeight;
const height = this.view.outerHeight;
const newScrollPosition = this.view.scrollPosition + delta - height;
this.view.$el.scrollTop(newScrollPosition);
}, 1);
},
addMessage(message) {
// This is debounced, so it won't hit the database too often.
this.lazyUpdateVerified();
// We do this here because we don't want convo.messageCollection to have
// anything in it unless it has an associated view. This is so, when we
// fetch on open, it's clean.
this.model.addSingleMessage(message);
if (message.isOutgoing()) {
this.removeLastSeenIndicator();
}
if (this.lastSeenIndicator) {
this.lastSeenIndicator.increment(1);
}
if (!this.isHidden() && !window.isFocused()) {
// The conversation is visible, but window is not focused
if (!this.lastSeenIndicator) {
this.resetLastSeenIndicator({ scroll: false });
} else if (
this.view.atBottom() &&
this.model.get('unreadCount') === this.lastSeenIndicator.getCount()
) {
// The count check ensures that the last seen indicator is still in
// sync with the real number of unread, so we can scroll to it.
// We only do this if we're at the bottom, because that signals that
// the user is okay with us changing scroll around so they see the
// right unseen message first.
this.resetLastSeenIndicator({ scroll: true });
}
} else if (!this.isHidden() && window.isFocused()) {
// The conversation is visible and in focus
this.markRead();
// When we're scrolled up and we don't already have a last seen indicator
// we add a new one.
if (!this.view.atBottom() && !this.lastSeenIndicator) {
this.resetLastSeenIndicator({ scroll: false });
}
}
},
onClick() {
// If there are sub-panels open, we don't want to respond to clicks
if (!this.panels || !this.panels.length) {
this.markRead();
}
},
findNewestVisibleUnread() {
const collection = this.model.messageCollection;
const { length } = collection;
const viewportBottom = this.view.outerHeight;
const unreadCount = this.model.get('unreadCount') || 0;
// Start with the most recent message, search backwards in time
let foundUnread = 0;
for (let i = length - 1; i >= 0; i -= 1) {
// Search the latest 30, then stop if we believe we've covered all known
// unread messages. The unread should be relatively recent.
// Why? local notifications can be unread but won't be reflected the
// conversation's unread count.
if (i > 30 && foundUnread >= unreadCount) {
return null;
}
const message = collection.at(i);
if (!message.get('unread')) {
// eslint-disable-next-line no-continue
continue;
}
foundUnread += 1;
const el = this.$(`#${message.id}`);
const position = el.position();
// This message is likely not loaded yet in the DOM
if (!position) {
// should this be break?
5 years ago
5 years ago
// eslint-disable-next-line no-continue
continue;
}
const { top } = position;
Profiles (#1453) * Add AES-GCM encryption for profiles With tests. * Add profileKey to DataMessage protobuf // FREEBIE * Decrypt and save profile names // FREEBIE * Save incoming profile keys * Move pad/unpad to crypto module // FREEBIE * Support fetching avatars from the cdn // FREEBIE * Translate failed authentication errors When AES-GCM authentication fails, webcrypto returns a very generic error. The same error is thrown for invalid length inputs, but our earlier checks in decryptProfile should rule out those failure modes and leave us safe to assume that we either had bad ciphertext or the wrong key. // FREEBIE * Handle profile avatars (wip) and log decrypt errors // FREEBIE * Display profile avatars Synced contact avatars will still override profile avatars. * Display profile names in convo list Only if we don't have a synced contact name. // FREEBIE * Make cdn url an environment config Use different ones for staging and production // FREEBIE * Display profile name in conversation header * Display profile name in group messages * Update conversation header if profile avatar changes // FREEBIE * Style profile names small with ~ * Save profileKeys from contact sync messages // FREEBIE * Save profile keys from provisioning messages For standalone accounts, generate a random profile key. // FREEBIE * Special case for one-time sync of our profile key Android will use a contact sync message to sync a profile key from Android clients who have just upgraded and generated their profile key. Normally we should receive this data in a provisioning message. // FREEBIE * Infer profile sharing from synced data messages * Populate profile keys on outgoing messages Requires that `profileSharing` be set on the conversation. // FREEBIE * Support for the profile key update flag When receiving a message with this flag, don't init a message record, just process the profile key and move on. // FREEBIE * Display profile names in group member list * Refresh contact's profile on profile key changes // FREEBIE * Catch errors on profile save // FREEBIE * Save our own synced contact info Don't return early if we get a contact sync for our own number // FREEBIE
8 years ago
// We're fully below the viewport, continue searching up.
if (top > viewportBottom) {
// eslint-disable-next-line no-continue
continue;
}
// If the bottom fits on screen, we'll call it visible. Even if the
// message is really tall.
const height = el.height();
const bottom = top + height;
if (bottom <= viewportBottom) {
return message;
}
// Continue searching up.
}
return null;
},
async showMembers(e, providedMembers, options = {}) {
_.defaults(options, { needVerify: false });
const model = providedMembers || this.model.contactCollection;
const view = new Whisper.GroupMemberList({
model,
// we pass this in to allow nested panels
listenBack: this.listenBack.bind(this),
needVerify: options.needVerify,
});
this.listenBack(view);
},
forceSend({ contact, message }) {
window.confirmationDialog({
message: i18n('identityKeyErrorOnSend', [
contact.getTitle(),
contact.getTitle(),
]),
messageSub: i18n('youMayWishToVerifyContact'),
okText: i18n('sendAnyway'),
resolve: async () => {
await contact.updateVerified();
if (contact.isUnverified()) {
await contact.setVerifiedDefault();
}
const untrusted = await contact.isUntrusted();
if (untrusted) {
await contact.setApproved();
}
message.resend(contact.id);
},
});
},
showContactDetail({ contact, hasSignalAccount }) {
const regionCode = storage.get('regionCode');
const { contactSelector } = Signal.Types.Contact;
const view = new Whisper.ReactWrapperView({
Component: Signal.Components.ContactDetail,
className: 'contact-detail-pane panel',
props: {
contact: contactSelector(contact, {
regionCode,
getAbsoluteAttachmentPath,
}),
hasSignalAccount,
onSendMessage: () => {
const number =
contact.number && contact.number[0] && contact.number[0].value;
if (number) {
this.openConversation(number);
}
},
},
onClose: () => {
this.resetPanel();
this.updateHeader();
},
});
this.listenBack(view);
this.updateHeader();
},
listenBack(view) {
this.panels = this.panels || [];
if (this.panels.length > 0) {
this.panels[0].$el.hide();
}
this.panels.unshift(view);
view.$el.insertBefore(this.$('.panel').first());
},
resetPanel() {
if (!this.panels || !this.panels.length) {
return;
}
const view = this.panels.shift();
if (this.panels.length > 0) {
this.panels[0].$el.show();
}
view.remove();
if (this.panels.length === 0) {
this.$el.trigger('force-resize');
}
},
setDisappearingMessages(seconds) {
if (seconds > 0) {
this.model.updateExpirationTimer(seconds);
} else {
this.model.updateExpirationTimer(null);
}
},
showSendConfirmationDialog(e, contacts) {
let message;
const isUnverified = this.model.isUnverified();
if (contacts.length > 1) {
if (isUnverified) {
message = i18n('changedSinceVerifiedMultiple');
} else {
message = i18n('changedRecentlyMultiple');
}
} else {
const contactName = contacts.at(0).getTitle();
if (isUnverified) {
message = i18n('changedSinceVerified', [contactName, contactName]);
} else {
message = i18n('changedRecently', [contactName, contactName]);
}
}
window.confirmationDialog({
title: i18n('changedSinceVerifiedTitle'),
message,
okText: i18n('sendAnyway'),
resolve: () => {
this.checkUnverifiedSendMessage(e, { force: true });
},
reject: () => {
this.focusMessageFieldAndClearDisabled();
},
});
},
stripQuery(text, cursorPos) {
const end = text.slice(cursorPos).search(/[^a-fA-F0-9]/);
const mentionEnd = end === -1 ? text.length : cursorPos + end;
const stripped = text.substr(0, mentionEnd);
const mentionStart = stripped.lastIndexOf('@');
const query = stripped.substr(mentionStart, mentionEnd - mentionStart);
return [stripped.substr(0, mentionStart), query, text.substr(mentionEnd)];
},
selectMember(member) {
const cursorPos = this.$messageField[0].selectionStart;
// Note: skipping the middle value here
const [prev, , end] = this.stripQuery(
this.$messageField.val(),
cursorPos
);
const handle = this.memberView.addPubkeyMapping(
member.authorProfileName,
member.authorPhoneNumber
);
let firstHalf = `${prev}${handle}`;
let newCursorPos = firstHalf.length;
const needExtraWhitespace = end.length === 0 || /\b/.test(end[0]);
if (needExtraWhitespace) {
firstHalf += ' ';
newCursorPos += 1;
}
const result = firstHalf + end;
this.$messageField.val(result);
this.$messageField[0].selectionStart = newCursorPos;
this.$messageField[0].selectionEnd = newCursorPos;
this.$messageField.trigger('input');
},
async handleSubmitPressed(e, options = {}) {
if (this.memberView.membersShown()) {
const member = this.memberView.selectedMember();
this.selectMember(member);
} else {
await this.checkUnverifiedSendMessage(e, options);
}
},
async checkUnverifiedSendMessage(e, options = {}) {
e.preventDefault();
this.sendStart = Date.now();
this.$messageField.attr('disabled', true);
_.defaults(options, { force: false });
// This will go to the trust store for the latest identity key information,
// and may result in the display of a new banner for this conversation.
try {
await this.model.updateVerified();
const contacts = this.model.getUnverified();
if (!contacts.length) {
this.checkUntrustedSendMessage(e, options);
return;
}
if (options.force) {
await this.markAllAsVerifiedDefault(contacts);
this.checkUnverifiedSendMessage(e, options);
return;
}
this.showSendConfirmationDialog(e, contacts);
} catch (error) {
this.focusMessageFieldAndClearDisabled();
window.log.error(
'checkUnverifiedSendMessage error:',
error && error.stack ? error.stack : error
);
}
},
async checkUntrustedSendMessage(e, options = {}) {
_.defaults(options, { force: false });
try {
const contacts = await this.model.getUntrusted();
if (!contacts.length) {
this.sendMessage(e);
return;
}
if (options.force) {
await this.markAllAsApproved(contacts);
this.sendMessage(e);
return;
}
this.showSendConfirmationDialog(e, contacts);
} catch (error) {
this.focusMessageFieldAndClearDisabled();
window.log.error(
'checkUntrustedSendMessage error:',
error && error.stack ? error.stack : error
);
}
},
async sendMessage(e) {
this.removeLastSeenIndicator();
this.model.clearTypingTimers();
const input = this.$messageField;
const message = this.memberView.replaceMentions(input.val());
try {
if (!message.length && !this.fileInput.hasFiles()) {
return;
}
const attachments = await this.fileInput.getFiles();
const sendDelta = Date.now() - this.sendStart;
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
6 years ago
this.model.sendMessage(
message,
attachments,
this.quote,
this.getLinkPreview()
);
input.val('');
this.memberView.deleteMention();
this.setQuoteMessage(null);
6 years ago
this.resetLinkPreview();
this.focusMessageFieldAndClearDisabled();
this.forceUpdateMessageFieldSize(e);
this.fileInput.clearAttachments();
} catch (error) {
window.log.error(
'Error pulling attached files before send',
error && error.stack ? error.stack : error
);
} finally {
this.focusMessageFieldAndClearDisabled();
}
},
handleDeleteOrBackspace(event, isDelete) {
const $input = this.$messageField[0];
const text = this.$messageField.val();
// Only handle the case when nothing is selected
if ($input.selectionDirection !== 'none') {
// Note: if this ends up deleting a handle, we should
// (ideally) check if we need to update the mapping in
// `this.memberView`, but that's not vital as we already
// reset it on every 'send'
return;
}
const mentions = this.memberView.pendingMentions();
const _ = window.Lodash; // no underscore.js please
const predicate = isDelete ? _.startsWith : _.endsWith;
const pos = $input.selectionStart;
const part = isDelete ? text.substr(pos) : text.substr(0, pos);
const curMention = _.keys(mentions).find(key => predicate(part, key));
if (!curMention) {
return;
}
event.preventDefault();
const beforeMention = isDelete
? text.substr(0, pos)
: text.substr(0, pos - curMention.length);
const afterMention = isDelete
? text.substr(pos + curMention.length)
: text.substr(pos);
const resText = beforeMention + afterMention;
// NOTE: this doesn't work well with undo/redo, perhaps
// we should fix it one day
this.$messageField.val(resText);
const nextPos = isDelete ? pos : pos - curMention.length;
$input.selectionStart = nextPos;
$input.selectionEnd = nextPos;
this.memberView.deleteMention(curMention);
},
handleLeftRight(event, isLeft) {
// Return next cursor position candidate before we take
// various modifier keys into account
const nextPos = (text, cursorPos, isLeft2, isAltPressed) => {
// If the next char is ' ', skip it if Alt is pressed
let pos = cursorPos;
if (isAltPressed) {
const nextChar = isLeft2
? text.substr(pos - 1, 1)
: text.substr(pos, 1);
if (nextChar === ' ') {
pos = isLeft2 ? pos - 1 : pos + 1;
}
}
const part = isLeft2 ? text.substr(0, pos) : text.substr(pos);
const mentions = this.memberView.pendingMentions();
const predicate = isLeft2
? window.Lodash.endsWith
: window.Lodash.startsWith;
const curMention = _.keys(mentions).find(key => predicate(part, key));
const offset = curMention ? curMention.length : 1;
const resPos = isLeft2 ? Math.max(0, pos - offset) : pos + offset;
return resPos;
};
event.preventDefault();
const $input = this.$messageField[0];
const posStart = $input.selectionStart;
const posEnd = $input.selectionEnd;
const text = this.$messageField.val();
const posToChange =
$input.selectionDirection === 'forward' ? posEnd : posStart;
let newPos = nextPos(text, posToChange, isLeft, event.altKey);
// If command (macos) key is pressed, go to the beginning/end
// (this shouldn't affect Windows, but we should double check that)
if (event.metaKey) {
newPos = isLeft ? 0 : text.length;
}
// Alt would normally make the cursor go until the next whitespace,
// but we need to take the presence of a mention into account
if (event.altKey || event.ctrlKey) {
const searchFrom = isLeft ? posToChange - 1 : posToChange + 1;
const toSearch = isLeft
? text.substr(0, searchFrom)
: text.substr(searchFrom);
// Note: we don't seem to support tabs etc, thus no /\s/
let nextAltPos = isLeft
? toSearch.lastIndexOf(' ')
: toSearch.indexOf(' ');
if (nextAltPos === -1) {
nextAltPos = isLeft ? 0 : text.length;
} else if (isLeft) {
nextAltPos += 1;
}
if (isLeft) {
newPos = Math.min(newPos, nextAltPos);
} else {
newPos = Math.max(newPos, nextAltPos + searchFrom);
}
}
// ==== Handle selection business ====
let newPosStart = newPos;
let newPosEnd = newPos;
let direction = $input.selectionDirection;
if (event.shiftKey) {
if (direction === 'none' || direction === 'forward') {
if (isLeft) {
direction = 'backward';
} else {
direction = 'forward';
}
}
} else {
direction = 'none';
}
if (direction === 'forward') {
newPosStart = posStart;
} else if (direction === 'backward') {
newPosEnd = posEnd;
}
if (newPosStart === newPosEnd) {
direction = 'none';
}
$input.setSelectionRange(newPosStart, newPosEnd, direction);
},
// Note: not only input, but keypresses too (rename?)
handleInputEvent(event) {
// Note: schedule the member list handler shortly afterwards, so
// that the input element has time to update its cursor position to
// what the user would expect
if (this.model.get('type') === 'group') {
window.requestAnimationFrame(this.maybeShowMembers.bind(this, event));
}
const keyCode = event.which || event.keyCode;
if (
keyCode === 13 &&
!event.altKey &&
!event.shiftKey &&
!event.ctrlKey
) {
// enter pressed - submit the form now
event.preventDefault();
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.membersShown() && 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.handleLeftRight(event, keyPressedLeft);
return;
}
const keyPressedDelete = keyCode === 46;
const keyPressedBackspace = keyCode === 8;
if (keyPressedDelete) {
this.handleDeleteOrBackspace(event, true);
}
if (keyPressedBackspace) {
this.handleDeleteOrBackspace(event, false);
}
this.updateMessageFieldSize();
},
updateMessageFieldSize() {
this.toggleMicrophone();
this.view.measureScrollPosition();
const $attachmentPreviews = this.$('.attachment-previews');
const $bottomBar = this.$('.bottom-bar');
const includeMargin = true;
const quoteHeight = this.quoteView
? this.quoteView.$el.outerHeight(includeMargin)
: 0;
const height =
this.$messageField.outerHeight() +
$attachmentPreviews.outerHeight() +
quoteHeight +
parseInt($bottomBar.css('min-height'), 10);
$bottomBar.outerHeight(height);
this.view.scrollToBottomIfNeeded();
},
async maybeShowMembers(event) {
const filterMembers = (caseSensitiveQuery, member) => {
const { authorPhoneNumber, authorProfileName } = member;
const profileName = authorProfileName
? authorProfileName.toLowerCase()
: '';
const query = caseSensitiveQuery.toLowerCase();
if (authorPhoneNumber.includes(query) || profileName.includes(query)) {
return true;
}
return false;
};
// This is not quite the same as stripQuery
// as this one searches until the current
// cursor position
const getQuery = (srcLine, cursorPos) => {
const input = srcLine.substr(0, cursorPos);
const atPos = input.lastIndexOf('@');
if (atPos === -1) {
return null;
}
// Whitespace is required right before @ unless
// the beginning of line
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;
};
let allMembers;
if (this.model.isPublic()) {
// const api = await this.model.getPublicSendData();
// not quite in the right format tho yet...
// let members = await api.getSubscribers();
let members = await window.lokiPublicChatAPI.getListOfMembers();
members = members
.filter(d => !!d)
.filter(d => d.authorProfileName !== 'Anonymous');
allMembers = _.uniq(members, true, d => d.authorPhoneNumber);
} else {
const members = this.model.get('members');
if (!members || members.length === 0) {
return;
}
const privateConvos = window
.getConversationController()
.getConversations()
.filter(d => d.isPrivate());
const memberConvos = members
.map(m => privateConvos.find(c => c.id === m))
.filter(c => !!c && c.getLokiProfile());
allMembers = memberConvos.map(c => ({
id: c.id,
authorPhoneNumber: c.id,
authorProfileName: c.getLokiProfile().displayName,
}));
}
const cursorPos = event.target.selectionStart;
// can't use pubkeyPattern here, as we are matching incomplete
// pubkeys (including the single @)
const query = getQuery(event.target.value, cursorPos);
let membersToShow = [];
if (query !== null) {
membersToShow =
query !== ''
? allMembers.filter(m => filterMembers(query, m))
: allMembers;
}
6 years ago
membersToShow = membersToShow.map(m =>
_.pick(m, ['authorPhoneNumber', 'authorProfileName', 'id'])
);
this.memberView.updateMembers(membersToShow);
},
forceUpdateMessageFieldSize(event) {
if (this.isHidden()) {
return;
}
this.view.scrollToBottomIfNeeded();
this.updateMessageFieldSize(event);
},
isHidden() {
return (
(this.$el.css('display') !== 'none' &&
this.$el.css('display') !== '') ||
this.$('.panel').css('display') === 'none'
);
},
});
})();