diff --git a/src/api/index.js b/src/api/index.js index 2936fb3ca1a..fcd468359a6 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -23,6 +23,7 @@ import getMessages from './messages/getMessages'; import getMessageHistory from './messages/getMessageHistory'; import messagesFlags from './messages/messagesFlags'; import sendMessage from './messages/sendMessage'; +import sendSubmessage from './submessages/sendSubmessage'; import updateMessage from './messages/updateMessage'; import savePushToken from './notifications/savePushToken'; import forgetPushToken from './notifications/forgetPushToken'; @@ -77,6 +78,7 @@ export { getMessageHistory, messagesFlags, sendMessage, + sendSubmessage, updateMessage, savePushToken, forgetPushToken, @@ -132,6 +134,7 @@ export default { getMessageHistory, messagesFlags, sendMessage, + sendSubmessage, updateMessage, savePushToken, forgetPushToken, diff --git a/src/api/submessages/sendSubmessage.js b/src/api/submessages/sendSubmessage.js new file mode 100644 index 00000000000..a6085bc29ef --- /dev/null +++ b/src/api/submessages/sendSubmessage.js @@ -0,0 +1,13 @@ +/* @flow strict-local */ + +import type { ApiResponse, Auth } from '../transportTypes'; +import { apiPost } from '../apiFetch'; + +/** See https://zulip.readthedocs.io/en/latest/subsystems/widgets.html#poll-todo-lists-and-games */ +// `msg_type` only exists as widget at the moment, see #3205. +export default async (auth: Auth, messageId: number, content: string): Promise => + apiPost(auth, 'submessage', { + message_id: messageId, + msg_type: 'widget', + content, + }); diff --git a/src/types.js b/src/types.js index 95d637666db..2934c9cf534 100644 --- a/src/types.js +++ b/src/types.js @@ -2,7 +2,16 @@ import type { IntlShape } from 'react-intl'; import type { DangerouslyImpreciseStyleProp } from 'react-native/Libraries/StyleSheet/StyleSheet'; -import type { Auth, Topic, Message, Reaction, Narrow, CrossRealmBot, User } from './api/apiTypes'; +import type { + Auth, + Topic, + Message, + Submessage, + Reaction, + Narrow, + CrossRealmBot, + User, +} from './api/apiTypes'; import type { AppStyles } from './styles/theme'; export type * from './reduxTypes'; @@ -157,6 +166,7 @@ export type Outbox = {| sender_email: string, sender_full_name: string, subject: string, + submessages?: $ReadOnlyArray, timestamp: number, type: 'stream' | 'private', diff --git a/src/webview/MessageList.js b/src/webview/MessageList.js index 0f06b21dab1..595e2ed23c0 100644 --- a/src/webview/MessageList.js +++ b/src/webview/MessageList.js @@ -29,6 +29,7 @@ import { getAllImageEmojiById, getCurrentTypingUsers, getDebug, + getOwnUser, getRenderedMessages, getFlags, getAnchorForNarrow, @@ -69,6 +70,7 @@ export type BackgroundData = $ReadOnly<{ flags: FlagsState, mute: MuteState, ownEmail: string, + ownUser: User, allImageEmojiById: $ReadOnly<{ [id: string]: ImageEmojiType }>, twentyFourHourTime: boolean, subscriptions: Subscription[], @@ -226,6 +228,7 @@ export default connect((state, props: OuterProps): SelectorProps => { flags: getFlags(state), mute: getMute(state), ownEmail: getOwnEmail(state), + ownUser: getOwnUser(state), allImageEmojiById: getAllImageEmojiById(state), subscriptions: getSubscriptions(state), twentyFourHourTime: getRealm(state).twentyFourHourTime, diff --git a/src/webview/css/css.js b/src/webview/css/css.js index ba62740b84e..85cf0a15e7d 100644 --- a/src/webview/css/css.js +++ b/src/webview/css/css.js @@ -183,7 +183,8 @@ hr { } .avatar, .header-wrapper, -.message-brief { +.message-brief, +.poll-button { cursor: pointer; } .stream-header { @@ -362,11 +363,7 @@ blockquote { margin: 8px 0; } .reaction { - color: hsl(0, 0%, 50%); - display: inline-block; padding: 5px 6.5px; - border-radius: 3px; - border: 1px solid hsla(0, 0%, 50%, 0.75); line-height: 1rem; height: 1rem; margin: 4px 8px 4px 0; @@ -378,11 +375,6 @@ blockquote { max-width: 1rem; vertical-align: top; } -.self-voted { - color: ${BRAND_COLOR}; - border: 1px solid ${BRAND_COLOR}; - background: hsla(177.1, 69.7%, 46.7%, 0.1); -} .hidden { display: none; } @@ -398,14 +390,50 @@ blockquote { color: white; } .widget { + padding: 1rem; + border: 1px solid hsl(0, 0%, 60%); + border-radius: 0.5rem; +} +.dummy-widget { display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 1rem; - background: hsla(0, 0%, 50%, 0.1); - border: 1px dashed hsla(0, 0%, 50%, 0.5); - border-radius: 0.5rem; +} +.poll-question { + text-overflow: ellipsis; + font-size: 20px; + margin-bottom: 4px; + border-bottom: 1px solid hsla(0, 0%, 60%, 0.2); +} +.poll-button { + height: 24px; + width: 32px; + margin: 4px 4px 4px 0; + text-align: center; +} +.poll-button, .reaction { + border-radius: 3px; + border: 1px solid hsla(0, 0%, 50%, 0.75); + color: hsl(0, 0%, 50%); + display: inline-block; +} +.reaction.self-voted, .poll-button.self-voted { + color: ${BRAND_COLOR}; + border-color: ${BRAND_COLOR}; + background: hsla(177, 70%, 47%, 0.1); +} +.poll-option { + display: inline; +} +.poll:after { + content: "Poll"; + display: block; + font-size: 12px; + font-style: italic; + color: hsl(0, 0%, 60%); + text-align: right; + margin: 0 -8px -12px 0; } #typing { display: flex; diff --git a/src/webview/html/messageAsHtml.js b/src/webview/html/messageAsHtml.js index e4fc73f056a..a8de659563d 100644 --- a/src/webview/html/messageAsHtml.js +++ b/src/webview/html/messageAsHtml.js @@ -15,6 +15,7 @@ import { shortTime } from '../../utils/date'; import aggregateReactions from '../../reactions/aggregateReactions'; import { codeToEmojiMap } from '../../emoji/data'; import processAlertWords from './processAlertWords'; +import widgetAsHtml from './widgetAsHtml'; const messageTagsAsHtml = (isStarred: boolean, timeEdited: number | void): string => { const pieces = []; @@ -69,14 +70,6 @@ $!${messageReactionListAsHtml(reactions, ownEmail, allImageEmojiById)} `; }; -const widgetBody = (message: Message | Outbox) => template` -$!${message.content} -

Interactive message

To use, open on web or desktop

-`; - export const flagsStateToStringList = (flags: FlagsState, id: number): string[] => Object.keys(flags).filter(key => flags[key][id]); @@ -99,9 +92,11 @@ export default (backgroundData: BackgroundData, message: Message | Outbox, isBri `; + + const { ownUser } = backgroundData; const bodyHtml = message.submessages && message.submessages.length > 0 - ? widgetBody(message) + ? widgetAsHtml(ownUser.user_id, message.submessages, message.content) : messageBody(backgroundData, message); if (isBrief) { diff --git a/src/webview/html/widgetAsHtml.js b/src/webview/html/widgetAsHtml.js new file mode 100644 index 00000000000..79411d9e3c0 --- /dev/null +++ b/src/webview/html/widgetAsHtml.js @@ -0,0 +1,105 @@ +/* @flow strict-local */ +import type { Submessage } from '../../types'; +import template from './template'; + +/** + * Option submessages are of type - + * { + * type: 'new_option', + * idx: number, + * option: string (Question String), + * } + */ +const extractPollOptions = submessages => { + const pollOptions = submessages + .filter(submessage => JSON.parse(submessage.content).type === 'new_option') + .reduce((options, option) => { + const rawOption = JSON.parse(option.content); + const optionId = rawOption.idx; + options.set(optionId.toString(), { + id: optionId, + option: rawOption.option, + voters: [], + }); + return options; + }, new Map()); + + /** + * Vote submessages are of type - + * { + * type: 'vote', + * key: string ((Voter's user ID) + , + (IDX of option)), + * vote: number, + * } + */ + const pollVotes = submessages + .filter(submessage => JSON.parse(submessage.content).type === 'vote') + .map(vote => JSON.parse(vote.content)); + + pollVotes.forEach(vote => { + const voterUserId = vote.key.split(',')[0]; + const votedOption = vote.key.split(',')[1]; + const optionFromVote = pollOptions.get(votedOption); + if (!optionFromVote) { + return; + } + + if (vote.vote === -1) { + optionFromVote.voters = optionFromVote.voters.filter(voter => voter !== voterUserId); + return; + } + optionFromVote.voters.push(voterUserId); + }); + return pollOptions; +}; + +const pollWidgetAsHtml = (ownUserId, submessages): string => { + const htmlPieces: string[] = []; + let pollOptions = new Map(); + + const widgetMetadata = JSON.parse(submessages[0].content); + + htmlPieces.push('
'); + htmlPieces.push(template`
${widgetMetadata.extra_data.question}
`); + + pollOptions = extractPollOptions(submessages); + + if (pollOptions.size === 0) { + htmlPieces.push('No options have yet been added to this widget.'); + } + + pollOptions.forEach(option => { + htmlPieces.push(template` +
+
+ ${option.voters.length}
+
${option.option}
+
+`); + }); + + htmlPieces.push('
'); + return htmlPieces.join(''); +}; + +export default ( + ownUserId: number, + submessages: $ReadOnlyArray, + contentHtml: string, +) => { + const widgetMetadata = JSON.parse(submessages[0].content); + + if (widgetMetadata.widget_type === 'poll') { + return pollWidgetAsHtml(ownUserId, submessages); + } + + return template` +$!${contentHtml} +
+

Interactive message

+

To use, open on web or desktop

+
+`; +}; diff --git a/src/webview/js/generatedEs3.js b/src/webview/js/generatedEs3.js index 3efbafee84c..64d96f9b684 100644 --- a/src/webview/js/generatedEs3.js +++ b/src/webview/js/generatedEs3.js @@ -541,6 +541,22 @@ documentBody.addEventListener('click', function (e) { return; } + if (target.matches('.poll-button')) { + var optionElement = target.closest('.poll-option-container'); + + if (!optionElement) { + throw new Error('Corresponding option element not found.'); + } + + sendMessage({ + type: 'vote', + messageId: getMessageIdFromNode(target), + optionId: parseInt(requireAttribute(optionElement, 'data-option-id'), 10), + vote: target.classList.contains('self-voted') ? -1 : 1 + }); + return; + } + var messageElement = target.closest('.message-brief'); if (messageElement) { diff --git a/src/webview/js/js.js b/src/webview/js/js.js index 37643e015d5..2131ddc297a 100644 --- a/src/webview/js/js.js +++ b/src/webview/js/js.js @@ -699,6 +699,20 @@ documentBody.addEventListener('click', (e: MouseEvent) => { return; } + if (target.matches('.poll-button')) { + const optionElement = target.closest('.poll-option-container'); + if (!optionElement) { + throw new Error('Corresponding option element not found.'); + } + sendMessage({ + type: 'vote', + messageId: getMessageIdFromNode(target), + optionId: parseInt(requireAttribute(optionElement, 'data-option-id'), 10), + vote: target.classList.contains('self-voted') ? -1 : 1, + }); + return; + } + const messageElement = target.closest('.message-brief'); if (messageElement) { messageElement.getElementsByClassName('timestamp')[0].classList.toggle('show'); diff --git a/src/webview/webViewEventHandlers.js b/src/webview/webViewEventHandlers.js index 77799a23582..5e100adc685 100644 --- a/src/webview/webViewEventHandlers.js +++ b/src/webview/webViewEventHandlers.js @@ -1,6 +1,6 @@ /* @flow strict-local */ import { Clipboard } from 'react-native'; -import { emojiReactionAdd, emojiReactionRemove, queueMarkAsRead } from '../api'; +import { emojiReactionAdd, emojiReactionRemove, sendSubmessage, queueMarkAsRead } from '../api'; import config from '../config'; import type { Dispatch, GetText, Message, Narrow, Outbox } from '../types'; import type { BackgroundData } from './MessageList'; @@ -73,6 +73,13 @@ type MessageListEventReaction = {| voted: boolean, |}; +type MessageListEventVote = {| + type: 'vote', + messageId: number, + optionId: number, + vote: number, +|}; + type MessageListEventUrl = {| type: 'url', href: string, @@ -108,6 +115,7 @@ export type MessageListEvent = | MessageListEventNarrow | MessageListEventImage | MessageListEventReaction + | MessageListEventVote | MessageListEventUrl | MessageListEventLongPress | MessageListEventDebug @@ -226,6 +234,22 @@ export const handleMessageListEvent = (props: Props, _: GetText, event: MessageL } break; + case 'vote': + { + const { auth, ownUser } = props.backgroundData; + + sendSubmessage( + auth, + event.messageId, + JSON.stringify({ + type: 'vote', + key: [ownUser.user_id, event.optionId].join(), + vote: event.vote, + }), + ); + } + break; + case 'debug': console.debug(props, event); // eslint-disable-line break;