merge next into next-reply

This commit is contained in:
or-else
2021-08-29 11:10:13 -07:00
17 changed files with 993 additions and 615 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ export function bytesToHumanSize(bytes) {
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
// as Java's String#hash().
function stringToColorHash(value) {
export function stringToColorHash(value) {
let hash = 0;
value = '' + value;
for (let i = 0; i < value.length; i++) {
+2 -2
View File
@@ -2,11 +2,11 @@
import Tinode from 'tinode-sdk';
import { stringHash } from './strformat.js';
import { stringToColorHash } from './strformat.js';
// User avatar letter color id.
export function letterTileColor(userId) {
return Math.abs(stringHash(userId)) % 16;
return Math.abs(stringToColorHash(userId)) % 16;
}
// Make shortcut icon appear with a green dot + show unread count in title.
+91 -93
View File
@@ -91,9 +91,11 @@ class MessagesView extends React.Component {
this.state = MessagesView.getDerivedStateFromProps(props, {});
this.leave = this.leave.bind(this);
this.sendMessage = this.sendMessage.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendFileAttachment = this.sendFileAttachment.bind(this);
this.sendKeyPress = this.sendKeyPress.bind(this);
this.subscribe = this.subscribe.bind(this);
this.handleScrollReference = this.handleScrollReference.bind(this);
this.handleScrollEvent = this.handleScrollEvent.bind(this);
this.handleDescChange = this.handleDescChange.bind(this);
@@ -114,8 +116,6 @@ class MessagesView extends React.Component {
this.postReadNotification = this.postReadNotification.bind(this);
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
this.doSendMessage = this.doSendMessage.bind(this);
this.handleSendMessage = this.handleSendMessage.bind(this);
this.handlePickReply = this.handlePickReply.bind(this);
this.handleCancelReply = this.handleCancelReply.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
@@ -145,7 +145,7 @@ class MessagesView extends React.Component {
// or vertical shrinking.
componentDidUpdate(prevProps, prevState) {
if (this.messagesScroller) {
if (prevState.topic != this.state.topic || prevState.latestMsgSeq != this.state.latestMsgSeq) {
if (prevState.topic != this.state.topic || prevState.messageCount != this.state.messageCount) {
// New message
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
@@ -178,49 +178,8 @@ class MessagesView extends React.Component {
this.postReadNotification(0);
}
if (topic && !topic.isSubscribed() && this.props.ready &&
((this.state.topic != prevState.topic) || !prevProps.ready)) {
// Is this a new topic?
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
if (this.state.isReader || newTopic) {
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
// And show "loading" spinner.
this.setState({ fetchingMessages: true });
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
return;
}
if (this.state.topic != ctrl.topic) {
this.setState({topic: ctrl.topic});
}
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
topic.publishMessage(pub);
}
});
})
.catch((err) => {
console.log("Failed subscription to", this.state.topic);
this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
if ((this.state.topic != prevState.topic) || !prevProps.ready) {
this.subscribe(topic);
}
}
@@ -229,7 +188,7 @@ class MessagesView extends React.Component {
if (!nextProps.topic) {
// Default state: no topic.
nextState = {
latestMsgSeq: -1,
messageCount: 0,
latestClearId: -1,
onlineSubs: [],
topic: null,
@@ -300,14 +259,14 @@ class MessagesView extends React.Component {
});
}
Object.assign(nextState, {
latestMsgSeq: topic.maxMsgSeq(),
messageCount: topic.messageCount(),
latestClearId: topic.maxClearId(),
channel: topic.isChannelType()
});
} else {
// Invalid topic.
Object.assign(nextState, {
latestMsgSeq: -1,
messageCount: 0,
latestClearId: -1,
onlineSubs: [],
title: '',
@@ -358,6 +317,55 @@ class MessagesView extends React.Component {
return nextState;
}
subscribe(topic) {
if (!topic || topic.isSubscribed() || !this.props.ready) {
return;
}
// Is this a new topic?
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
if (this.state.isReader || newTopic) {
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
// And show "loading" spinner.
this.setState({ fetchingMessages: true });
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
return;
}
if (this.state.topic != ctrl.topic) {
this.setState({topic: ctrl.topic});
}
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
this.sendMessage(pub);
}
});
})
.catch((err) => {
console.log("Failed subscription to", this.state.topic);
this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
}
leave(oldTopicName) {
if (!oldTopicName || !this.props.tinode.isTopicCached(oldTopicName)) {
return;
@@ -391,23 +399,19 @@ class MessagesView extends React.Component {
// Get older messages
handleScrollEvent(event) {
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
if (this.state.fetchingMessages) {
return;
}
if (event.target.scrollTop <= 0) {
this.setState((prevState, props) => {
const newState = {};
if (!prevState.fetchingMessages) {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
newState.fetchingMessages = true;
topic.getMessagesPage(MESSAGES_PAGE)
.then(() => this.setState({fetchingMessages: false}))
.catch((err) => {
this.setState({fetchingMessages: false});
this.props.onError(err.message, 'err');
});
}
}
return newState;
});
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
this.setState({fetchingMessages: true}, () => {
topic.getMessagesPage(MESSAGES_PAGE)
.catch((err) => this.props.onError(err.message, 'err'))
.finally(() => this.setState({fetchingMessages: false}));
});
}
}
}
@@ -530,10 +534,11 @@ class MessagesView extends React.Component {
return;
}
this.setState({latestMsgSeq: topic.maxMsgSeq()});
clearTimeout(this.keyPressTimer)
this.setState({messageCount: topic.messageCount(), typingIndicator: false});
// If the message is added to the end of the message list,
// scroll to the bottom.
// Scroll to the bottom if the message is added to the end of the message list.
// TODO: This should be replaced by showing a "scroll to bottom" button.
if (topic.isNewMessage(msg.seq)) {
this.setState({scrollPosition: 0});
}
@@ -560,9 +565,8 @@ class MessagesView extends React.Component {
switch (info.what) {
case 'kp': {
clearTimeout(this.keyPressTimer);
var instance = this;
this.keyPressTimer = setTimeout(function() {
instance.setState({typingIndicator: false});
this.keyPressTimer = setTimeout(() => {
this.setState({typingIndicator: false});
}, KEYPRESS_DELAY + 1000);
if (!this.state.typingIndicator) {
this.setState({typingIndicator: true});
@@ -592,7 +596,7 @@ class MessagesView extends React.Component {
handleFormResponse(action, text, data) {
if (action == 'pub') {
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
this.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
} else if (action == 'url') {
const url = new URL(data.ref);
const params = url.searchParams;
@@ -653,15 +657,19 @@ class MessagesView extends React.Component {
}
}
doSendMessage(msg, uploadCompletionPromise, uploader) {
let head = null;
// sendMessage sends the message with an optional subscription to topic first.
sendMessage(msg, uploadCompletionPromise, uploader) {
let head;
if (this.state.reply && this.state.reply.content) {
head = {replyToSeq: this.state.reply.seq};
let quote = this.state.reply.content;
msg = Drafty.attachQuote(msg, quote);
// Turn it into Drafty so we can make a quoted Drafty object later.
if (typeof msg == 'string') {
msg = Drafty.parse(msg);
}
msg = Drafty.attachQuote(msg, this.state.reply.content);
this.setState({reply: null});
}
this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
this.setState({reply: null});
}
// Send attachment as Drafty message:
@@ -683,13 +691,12 @@ class MessagesView extends React.Component {
urlPromise: uploadCompletionPromise
});
// Pass data and the uploader to the TinodeWeb.
this.doSendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader);
} else {
// Small enough to send inband.
fileToBase64(file,
(mime, bits, fname) => {
const msg = Drafty.attachFile(null, {mime: mime, data: bits, filename: fname});
this.doSendMessage(msg, undefined, undefined);
this.sendMessage(Drafty.attachFile(null, {mime: mime, data: bits, filename: fname}));
},
this.props.onError
);
@@ -753,7 +760,7 @@ class MessagesView extends React.Component {
msg = Drafty.append(msg, Drafty.parse(caption));
}
// Pass data and the uploader to the TinodeWeb.
this.doSendMessage(msg, uploadCompletionPromise, uploader);
this.sendMessage(msg, uploadCompletionPromise, uploader);
}
)},
// Failure
@@ -778,7 +785,7 @@ class MessagesView extends React.Component {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.parse(caption));
}
this.doSendMessage(msg, undefined, undefined);
this.sendMessage(msg);
});
}
@@ -920,14 +927,6 @@ class MessagesView extends React.Component {
}
}
handleSendMessage(msg) {
if (this.state.reply && this.state.reply.content && typeof msg == 'string') {
// Turn it into Drafty so we can make a quoted Drafty object later.
msg = Drafty.parse(msg);
}
this.doSendMessage(msg, undefined, undefined);
}
handleCancelReply() {
this.setState({reply: null})
}
@@ -1159,16 +1158,15 @@ class MessagesView extends React.Component {
<Invitation onAction={this.handleNewChatAcceptance} />
:
<SendMessage
tinode={this.props.tinode}
disabled={!this.state.isWriter}
onKeyPress={this.sendKeyPress}
onSendMessage={this.sendMessage}
onAttachFile={this.handleAttachFile}
onAttachImage={this.handleAttachImage}
onError={this.props.onError}
tinode={this.props.tinode}
replyTo={this.state.reply}
onQuoteClick={this.handleQuoteClick}
onSendMessage={this.handleSendMessage}
onFormatQuote={draftyFormatter}
onCancelReply={this.handleCancelReply} />}
</>
+6 -4
View File
@@ -56,16 +56,17 @@ class NewTopicView extends React.Component {
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
}
handleContactSelected(sel) {
if (this.state.tabSelected === 'p2p') {
handleContactSelected(uid) {
if (this.state.tabSelected == 'p2p') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(sel);
this.props.onCreateTopic(uid);
}
}
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(undefined, theCard(name, dataUrl, null, description), priv, tags, isChannel);
this.props.onCreateTopic(undefined,
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
}
handleGroupByID(topicName) {
@@ -112,6 +113,7 @@ class NewTopicView extends React.Component {
type="p2p"
onSearchContacts={this.handleSearchContacts} />
<ContactList
tinode={this.props.tinode}
contacts={this.props.searchResults}
myUserId={this.props.myUserId}
emptyListMessage={no_contacts_placeholder}
+15 -5
View File
@@ -917,10 +917,20 @@ class TinodeWeb extends React.Component {
}
if (!topic.isSubscribed()) {
// Topic is not subscribed yet. Subscribe.
if (!promise) {
promise = Promise.resolve();
}
promise = promise.then(() => { return topic.subscribe(); });
promise = promise
.then(() => topic.subscribe())
.then(() => {
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
this.sendMessage(pub);
}
});
});
}
if (promise) {
@@ -1184,7 +1194,7 @@ class TinodeWeb extends React.Component {
}
// Request to start a topic, new or selected from search results, or "by ID".
handleStartTopicRequest(topicName, pub, priv, tags, isChannel) {
handleStartTopicRequest(topicName, newTopicParams, isChannel) {
// Check if topic is indeed new. If not, launch it.
if (topicName && this.tinode.isTopicCached(topicName)) {
this.handleTopicSelected(topicName);
@@ -1199,14 +1209,14 @@ class TinodeWeb extends React.Component {
params.desc = {defacs: {auth: DEFAULT_P2P_ACCESS_MODE}};
} else {
topicName = topicName || this.tinode.newGroupTopicName(isChannel);
params.desc = {public: pub, private: {comment: priv}};
params.tags = tags;
params.desc = {public: newTopicParams.public, private: {comment: newTopicParams.private}};
params.tags = newTopicParams.tags;
}
params._topicName = topicName;
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
}
// New topic was creted, here is the new topic name.
// New topic was created, here is the new topic name.
handleNewTopicCreated(oldName, newName) {
if (this.state.topicSelected == oldName && oldName != newName) {
// If the current URl contains the old topic name, replace it with new.
+3 -3
View File
@@ -354,7 +354,7 @@ class ContextMenu extends React.Component {
}
if (!item) {
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
console.error("Invalid menu item ID", e.currentTarget.dataset.id);
} else {
this.props.onAction(
item.id,
@@ -425,12 +425,12 @@ class ContextMenu extends React.Component {
replyToMessage(params, errorHandler) {
console.log("Reply to message", params);
params.pickReply({ seq: params.seq, content: params.content })
params.pickReply({ seq: params.seq, content: params.content });
}
render() {
const menu = [];
let count = 0;
let menu = [];
this.props.items.map((item) => {
if (typeof item == 'string') {
item = this.MenuItems[item];
+3 -3
View File
@@ -35,7 +35,7 @@ export default class InPlaceEdit extends React.Component {
}
handeTextChange(e) {
this.setState({value: e.target.value});
this.setState({value: e.target.value || ''});
}
handleKeyDown(e) {
@@ -59,13 +59,13 @@ export default class InPlaceEdit extends React.Component {
}
handleEditingFinished(event) {
if (this.props.required && !event.target.checkValidity()) {
const value = this.state.value.trim();
if (this.props.required && (!event.target.checkValidity() || !value)) {
// Empty input
this.setState({value: this.props.value, active: false});
return;
}
this.setState({active: false});
const value = this.state.value.trim();
if ((value || this.props.value) && (value !== this.props.value)) {
this.props.onFinished(value);
}
+4 -4
View File
@@ -34,7 +34,7 @@ export default class NewTopicGroup extends React.PureComponent {
}
handleFieldEdit(name, e) {
this.setState({[name]: e.target.value});
this.setState({[name]: e.target.value || ''});
}
handleImageChanged(img) {
@@ -82,7 +82,7 @@ export default class NewTopicGroup extends React.PureComponent {
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
description="Prompt for entering topic name">{
(placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder}
ref={this.fullName} value={this.state.fullName} onChange={this.handleFieldEdit.bind('fullName')}
ref={this.fullName} value={this.state.fullName} onChange={this.handleFieldEdit.bind(this, 'fullName')}
autoFocus required tabIndex={0} />
}</FormattedMessage>
</div>
@@ -94,7 +94,7 @@ export default class NewTopicGroup extends React.PureComponent {
<FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only"
description="Placeholder for editing 'private'">{
(placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder}
value={this.state.private} onChange={this.handleFieldEdit.bind('private')} tabIndex={1} />
value={this.state.private} onChange={this.handleFieldEdit.bind(this, 'private')} tabIndex={1} />
}</FormattedMessage>
</div>
<div className="group">
@@ -105,7 +105,7 @@ export default class NewTopicGroup extends React.PureComponent {
<FormattedMessage id="description_editing_placeholder" defaultMessage="Description (optional)"
description="Placeholder for editing topic description">{
(placeholder) => <input type="text" id="new-topic-desc" placeholder={placeholder}
value={this.state.description} onChange={this.handleFieldEdit.bind('description')} tabIndex={2} />
value={this.state.description} onChange={this.handleFieldEdit.bind(this, 'description')} tabIndex={2} />
}</FormattedMessage>
</div>
</div>
+15 -13
View File
@@ -196,11 +196,11 @@ export default class TopicDescEdit extends React.Component {
<div>
<FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
description="Input placeholder for person's full name">{
(full_name_placeholder) => <InPlaceEdit
placeholder={full_name_placeholder}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} />
(full_name_placeholder) => <InPlaceEdit
placeholder={full_name_placeholder}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} />
}</FormattedMessage>
</div>
</div>
@@ -212,12 +212,15 @@ export default class TopicDescEdit extends React.Component {
description="Label for editing topic name" />
</label></div>
<div>
<InPlaceEdit
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
readOnly={!editable}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} />
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
description="Prompt for entering topic name">{
(group_name_placeholder) => <InPlaceEdit
placeholder={group_name_placeholder}
readOnly={!editable}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} />
}</FormattedMessage>
</div>
</div>
<div className="group">
@@ -228,8 +231,7 @@ export default class TopicDescEdit extends React.Component {
</label>
</div>
<div>
<FormattedMessage id="private_editing_placeholder"
defaultMessage="Visible to you only"
<FormattedMessage id="private_editing_placeholder" defaultMessage="Visible to you only"
description="Placeholder for editing 'private'">{
(private_placeholder) => <InPlaceEdit
placeholder={private_placeholder}