diff --git a/package.json b/package.json index f421ae94c6f..ed99d130cfd 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@react-navigation/stack": "^5.9.3", "@sentry/react-native": "^2.4.3", "@unimodules/core": "~5.3.0", - "@zulip/shared": "^0.0.5", + "@zulip/shared": "^0.0.6", "base-64": "^0.1.0", "blueimp-md5": "^2.10.0", "color": "^3.0.0", diff --git a/src/api/index.js b/src/api/index.js index de74fa53178..a397ce800dd 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -40,6 +40,7 @@ import toggleMobilePushSettings from './settings/toggleMobilePushSettings'; import createStream from './streams/createStream'; import getStreams from './streams/getStreams'; import updateStream from './streams/updateStream'; +import sendSubmessage from './submessages/sendSubmessage'; import getSubscriptions from './subscriptions/getSubscriptions'; import subscriptionAdd from './subscriptions/subscriptionAdd'; import subscriptionRemove from './subscriptions/subscriptionRemove'; @@ -86,6 +87,7 @@ export { createStream, getStreams, updateStream, + sendSubmessage, getSubscriptions, setTopicMute, subscriptionAdd, diff --git a/src/api/modelTypes.js b/src/api/modelTypes.js index 5dbe4a30d9f..a1da2ef66bb 100644 --- a/src/api/modelTypes.js +++ b/src/api/modelTypes.js @@ -456,6 +456,47 @@ export type PmRecipientUser = $ReadOnly<{| is_mirror_dummy?: boolean, |}>; +/** + * The data encoded in a submessage to make the message a widget. + * + * Note that future server versions might introduce new types of widgets, so + * `widget_type` could be a value not included here. But when it is one of + * these values, the rest of the object will follow this type. + */ +// Ideally we'd be able to express both the known and the unknown widget +// types: we'd have another branch of this union which looked like +// | {| +widget_type: (string *other than* those above), +extra_data?: { ... } |} +// But there doesn't seem to be a way to express that in Flow. +export type WidgetData = + | {| + +widget_type: 'poll', + +extra_data?: {| +question?: string, +options?: $ReadOnlyArray |}, + |} + // We can write these down more specifically when we implement these widgets. + | {| +widget_type: 'todo', +extra_data?: { ... } |} + | {| +widget_type: 'zform', +extra_data?: { ... } |}; + +/** + * The data encoded in a submessage that acts on a widget. + * + * The interpretation of this data, including the meaning of the `type` + * field, is specific to each widget type. + * + * We delegate actually processing these to shared code, so we don't specify + * the details further. + */ +export type WidgetEventData = { +type: string, ... }; + +/** + * The data encoded in a `Submessage`. + * + * For widgets (the only existing use of submessages), the submessages array + * consists of: + * * One submessage with `WidgetData`; then + * * Zero or more submessages with `WidgetEventData`. + */ +export type SubmessageData = WidgetData | WidgetEventData; + /** * Submessages are items containing extra data that can be added to a * message. Despite what their name might suggest, they are not a subtype @@ -473,7 +514,9 @@ export type Submessage = $ReadOnly<{| message_id: number, sender_id: UserId, msg_type: 'widget', // only this type is currently available - content: string, // JSON string + + /** A `SubmessageData` object, JSON-encoded. */ + content: string, |}>; /** 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/webview/css/cssNight.js b/src/webview/css/cssNight.js index cacf4a5974d..b6af2a9b092 100644 --- a/src/webview/css/cssNight.js +++ b/src/webview/css/cssNight.js @@ -5,6 +5,9 @@ body { color: hsl(210, 11%, 85%); background: hsl(212, 28%, 18%); } +.poll-vote { + color: hsl(210, 11%, 85%); +} .topic-header { background: hsl(212, 13%, 38%); } diff --git a/src/webview/handleOutboundEvents.js b/src/webview/handleOutboundEvents.js index b84ea950d65..64fff9142a6 100644 --- a/src/webview/handleOutboundEvents.js +++ b/src/webview/handleOutboundEvents.js @@ -132,6 +132,13 @@ type WebViewOutboundEventTimeDetails = {| originalText: string, |}; +type WebViewOutboundEventVote = {| + type: 'vote', + messageId: number, + key: string, + vote: number, +|}; + export type WebViewOutboundEvent = | WebViewOutboundEventReady | WebViewOutboundEventScroll @@ -146,7 +153,8 @@ export type WebViewOutboundEvent = | WebViewOutboundEventWarn | WebViewOutboundEventError | WebViewOutboundEventMention - | WebViewOutboundEventTimeDetails; + | WebViewOutboundEventTimeDetails + | WebViewOutboundEventVote; // TODO: Consider completing this and making it exact, once // `MessageList`'s props are type-checked. @@ -313,6 +321,19 @@ export const handleWebViewOutboundEvent = ( break; } + case 'vote': { + api.sendSubmessage( + props.backgroundData.auth, + event.messageId, + JSON.stringify({ + type: 'vote', + key: event.key, + vote: event.vote, + }), + ); + break; + } + case 'debug': console.debug(props, event); // eslint-disable-line break; diff --git a/src/webview/html/messageAsHtml.js b/src/webview/html/messageAsHtml.js index a39ca7bc4ea..cc06957b96a 100644 --- a/src/webview/html/messageAsHtml.js +++ b/src/webview/html/messageAsHtml.js @@ -1,6 +1,10 @@ /* @flow strict-local */ import { PixelRatio } from 'react-native'; +import invariant from 'invariant'; import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +// $FlowFixMe[untyped-import] +import { PollData } from '@zulip/shared/js/poll_data'; + import template from './template'; import type { AggregatedReaction, @@ -10,14 +14,17 @@ import type { MessageLike, Outbox, Reaction, + SubmessageData, ImageEmojiType, UserId, + WidgetData, } from '../../types'; import type { BackgroundData } from '../MessageList'; import { shortTime } from '../../utils/date'; import aggregateReactions from '../../reactions/aggregateReactions'; import { codeToEmojiMap } from '../../emoji/data'; import processAlertWords from './processAlertWords'; +import * as logging from '../../utils/logging'; const messageTagsAsHtml = (isStarred: boolean, timeEdited: number | void): string => { const pieces = []; @@ -72,7 +79,30 @@ $!${messageReactionListAsHtml(reactions, ownUser.user_id, allImageEmojiById)} `; }; -const widgetBody = (message: Message | Outbox) => template` +/** + * Render the body of a message that has submessages. + * + * Must not be called on a message without any submessages. + */ +const widgetBody = (message: Message, ownUserId: UserId) => { + invariant( + message.submessages !== undefined && message.submessages.length > 0, + 'should have submessages', + ); + + const widgetSubmessages: Array<{ + sender_id: number, + content: SubmessageData, + ... + }> = message.submessages + .filter(submessage => submessage.msg_type === 'widget') + .sort((m1, m2) => m1.id - m2.id) + .map(submessage => ({ + sender_id: submessage.sender_id, + content: JSON.parse(submessage.content), + })); + + const errorMessage = template` $!${message.content}

Interactive message

`; + const pollWidget = widgetSubmessages.shift(); + if (!pollWidget || !pollWidget.content) { + return errorMessage; + } + + /* $FlowFixMe[incompatible-type]: The first widget submessage should be + a `WidgetData`; see jsdoc on `SubmessageData`. */ + const pollWidgetContent: WidgetData = pollWidget.content; + + if (pollWidgetContent.widget_type !== 'poll') { + return errorMessage; + } + + if (pollWidgetContent.extra_data == null) { + // We don't expect this to happen in general, but there are some malformed + // messages lying around that will trigger this [1]. The code here is slightly + // different the webapp code, but mostly because the current webapp + // behaviour seems accidental: an error is printed to the console, and the + // code that is written to handle the situation is never reached. Instead + // of doing that, we've opted to catch this case here, and print out the + // message (which matches the behaviour of the webapp, minus the console + // error, although it gets to that behaviour in a different way). The bug + // tracking fixing this on the webapp side is zulip/zulip#19145. + // [1]: https://chat.zulip.org/#narrow/streams/public/near/582872 + return template`$!${message.content}`; + } + + const pollData = new PollData({ + message_sender_id: message.sender_id, + current_user_id: ownUserId, + is_my_poll: message.sender_id === ownUserId, + question: pollWidgetContent.extra_data.question ?? '', + options: pollWidgetContent.extra_data.options ?? [], + // TODO: Implement this. + comma_separated_names: () => '', + report_error_function: (msg: string) => { + logging.error(msg); + }, + }); + + for (const pollEvent of widgetSubmessages) { + pollData.handle_event(pollEvent.sender_id, pollEvent.content); + } + + const parsedPollData = pollData.get_widget_data(); + + return template` +
+

${parsedPollData.question}

+
    + $!${parsedPollData.options + .map( + option => + template` +
  • + + ${option.option} +
  • `, + ) + .join('')} +
+
+ `; +}; + export const flagsStateToStringList = (flags: FlagsState, id: number): string[] => Object.keys(flags).filter(key => flags[key][id]); @@ -112,7 +211,7 @@ export default ( `; const bodyHtml = message.submessages && message.submessages.length > 0 - ? widgetBody(message) + ? widgetBody(message, backgroundData.ownUser.user_id) : messageBody(backgroundData, message); if (isBrief) { diff --git a/src/webview/js/generatedEs3.js b/src/webview/js/generatedEs3.js index 78ba40d8a0e..3d1b2bba42e 100644 --- a/src/webview/js/generatedEs3.js +++ b/src/webview/js/generatedEs3.js @@ -993,6 +993,26 @@ var compiledWebviewJs = (function (exports) { return; } + if (target.matches('.poll-vote')) { + var _messageElement = target.closest('.message'); + + if (!_messageElement) { + throw new Error('Message element not found'); + } + + var current_vote = requireAttribute(target, 'data-voted') === 'true'; + var vote = current_vote ? -1 : 1; + sendMessage({ + type: 'vote', + messageId: requireNumericAttribute(_messageElement, 'data-msg-id'), + key: requireAttribute(target, 'data-key'), + vote: vote + }); + target.setAttribute('data-voted', (!current_vote).toString()); + target.innerText = (parseInt(target.innerText, 10) + vote).toString(); + return; + } + if (target.matches('time')) { var originalText = requireAttribute(target, 'original-text'); sendMessage({ diff --git a/src/webview/js/js.js b/src/webview/js/js.js index 24494e1da19..428edc2c166 100644 --- a/src/webview/js/js.js +++ b/src/webview/js/js.js @@ -918,6 +918,27 @@ documentBody.addEventListener('click', (e: MouseEvent) => { return; } + if (target.matches('.poll-vote')) { + const messageElement = target.closest('.message'); + if (!messageElement) { + throw new Error('Message element not found'); + } + // This duplicates some logic from PollData.handle.vote.outbound in + // @zulip/shared/js/poll_data.js, but it's much simpler to just duplicate + // it than it is to thread a callback all the way over here. + const current_vote = requireAttribute(target, 'data-voted') === 'true'; + const vote = current_vote ? -1 : 1; + sendMessage({ + type: 'vote', + messageId: requireNumericAttribute(messageElement, 'data-msg-id'), + key: requireAttribute(target, 'data-key'), + vote, + }); + target.setAttribute('data-voted', (!current_vote).toString()); + target.innerText = (parseInt(target.innerText, 10) + vote).toString(); + return; + } + if (target.matches('time')) { const originalText = requireAttribute(target, 'original-text'); sendMessage({ diff --git a/src/webview/static/base.css b/src/webview/static/base.css index 2a018b99c89..3e5249362eb 100644 --- a/src/webview/static/base.css +++ b/src/webview/static/base.css @@ -696,3 +696,50 @@ h1, h2, h3, h4, h5, h6 { .spoiler-block .spoiler-arrow.spoiler-button-open::after { transform: rotate(90deg) translate(10px, 0); } + +/* Poll styling */ + +.poll-widget { + border: hsl(0, 0%, 50%) 1px solid; + padding: 2px 8px 2px 10px; + border-radius: 10px; +} + +.poll-question { + font-size: 1.2rem; + margin-bottom: 8px; + border-bottom: 1px solid hsla(0, 0%, 60%, 0.2); +} + +.poll-widget > ul { + padding: 0px; +} + +.poll-widget > ul > li { + list-style: none; + margin-bottom: 4px; + display: flex; + align-items: center; +} + +.poll-vote { + background-color: hsla(0, 0%, 0%, 0); + border: 1.5px solid hsl(0, 0%, 50%); + border-radius: 8px; + height: 36px; + min-width: 36px; + padding: 1px 8px; + font-weight: bold; + font-size: 18px; + flex-shrink: 0; +} + +.poll-vote[data-voted="true"] { + border: 1.5px solid hsl(222, 99%, 69%); + background-color: hsla(222, 99%, 69%, 25%); +} + +.poll-option { + margin-left: 8px; + width: 100%; +} diff --git a/yarn.lock b/yarn.lock index 31d946cc2a8..04443ec816b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2857,10 +2857,10 @@ resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== -"@zulip/shared@^0.0.5": - version "0.0.5" - resolved "https://registry.yarnpkg.com/@zulip/shared/-/shared-0.0.5.tgz#6c5984cecb3b56b415ebfa82d4f17eab14ef75e3" - integrity sha512-mQNuFO4IvsvCRXTppLAAarBTdtObHE9cpqlGIqJD5rsQSv1wq3PTFxUgQuEAA+9p2L9ND2ucHzFFbMIku587JQ== +"@zulip/shared@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@zulip/shared/-/shared-0.0.6.tgz#024450692670b572b6eb095845a1203d3001890e" + integrity sha512-17Shi/YJMln4h/B1CE3I2JiEO99tZDClTDEEXEk/XI2SGYCi1Tdb4hB48QKnlV0cy9k93KAX3oC6cydZyZUwiQ== dependencies: katex "^0.12.0" lodash "^4.17.19"