Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -86,6 +87,7 @@ export {
createStream,
getStreams,
updateStream,
sendSubmessage,
getSubscriptions,
setTopicMute,
subscriptionAdd,
Expand Down
45 changes: 44 additions & 1 deletion src/api/modelTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> |},
|}
// 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
Expand All @@ -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,
|}>;

/**
Expand Down
13 changes: 13 additions & 0 deletions src/api/submessages/sendSubmessage.js
Original file line number Diff line number Diff line change
@@ -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<ApiResponse> =>
apiPost(auth, 'submessage', {
message_id: messageId,
msg_type: 'widget',
content,
});
3 changes: 3 additions & 0 deletions src/webview/css/cssNight.js
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
}
Expand Down
23 changes: 22 additions & 1 deletion src/webview/handleOutboundEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ type WebViewOutboundEventTimeDetails = {|
originalText: string,
|};

type WebViewOutboundEventVote = {|
type: 'vote',
messageId: number,
key: string,
vote: number,
|};

export type WebViewOutboundEvent =
| WebViewOutboundEventReady
| WebViewOutboundEventScroll
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
103 changes: 101 additions & 2 deletions src/webview/html/messageAsHtml.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = [];
Expand Down Expand Up @@ -72,14 +79,106 @@ $!${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}
<div class="special-message"
><p>Interactive message</p
><p>To use, open on web or desktop</p
></div>
`;

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);
Comment on lines +153 to +154
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hadn't realized that PollData was robust to receiving the initial widget submessage in addition to the "event" submessages, but it seems that it is.

Hmm yeah, it looks like it is. It looks for type and drops anything that doesn't have that, and the initial submessage doesn't have it.

I'm not sure that's really part of its interface, though -- it seems like a bit of an odd thing to do. The web caller doesn't re-pass it that first submessage; in activate in widgetize.js the flow looks like this:

    events.shift();

    // …

    // Replay any events that already happened.  (This is common
    // when you narrow to a message after other users have already
    // interacted with it.)
    if (events.length > 0) {
        widget_elem.handle_events(events);
    }

So that shift drops the first element so that it doesn't go to the handle_events call. (Which in turn is basically a loop with handle_event.)

It's pretty easy to similarly skip the first element from what we use for handle_event, and that makes it a bit clearer to understand what's going on, so let's do that.

}

const parsedPollData = pollData.get_widget_data();

return template`
<div class="poll-widget">
<p class="poll-question">${parsedPollData.question}</p>
<ul>
$!${parsedPollData.options
.map(
option =>
template`
<li>
<button
class="poll-vote"
data-voted="${option.current_user_vote}"
data-key="${option.key}"
>${option.count}</button>
<span class="poll-option">${option.option}</span>
</li>`,
)
.join('')}
</ul>
</div>
`;
};

export const flagsStateToStringList = (flags: FlagsState, id: number): string[] =>
Object.keys(flags).filter(key => flags[key][id]);

Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions src/webview/js/generatedEs3.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions src/webview/js/js.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading