Merge branch 'develop' into matthew/e2e_backups
This commit is contained in:
commit
25216d4660
824 changed files with 43369 additions and 3899 deletions
107
src/Analytics.js
107
src/Analytics.js
|
@ -16,51 +16,75 @@
|
|||
|
||||
import { getCurrentLanguage, _t, _td } from './languageHandler';
|
||||
import PlatformPeg from './PlatformPeg';
|
||||
import SdkConfig, { DEFAULTS } from './SdkConfig';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import Modal from './Modal';
|
||||
import sdk from './index';
|
||||
|
||||
function getRedactedHash() {
|
||||
return window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/<redacted>");
|
||||
const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password|home|directory)/;
|
||||
const hashVarRegex = /#\/(group|room|user)\/.*$/;
|
||||
|
||||
// Remove all but the first item in the hash path. Redact unexpected hashes.
|
||||
function getRedactedHash(hash) {
|
||||
// Don't leak URLs we aren't expecting - they could contain tokens/PII
|
||||
const match = hashRegex.exec(hash);
|
||||
if (!match) {
|
||||
console.warn(`Unexpected hash location "${hash}"`);
|
||||
return '#/<unexpected hash location>';
|
||||
}
|
||||
|
||||
if (hashVarRegex.test(hash)) {
|
||||
return hash.replace(hashVarRegex, "#/$1/<redacted>");
|
||||
}
|
||||
|
||||
return hash.replace(hashRegex, "#/$1");
|
||||
}
|
||||
|
||||
// Return the current origin and hash separated with a `/`. This does not include query parameters.
|
||||
function getRedactedUrl() {
|
||||
// hardcoded url to make piwik happy
|
||||
return 'https://riot.im/app/' + getRedactedHash();
|
||||
const { origin, pathname, hash } = window.location;
|
||||
return origin + pathname + getRedactedHash(hash);
|
||||
}
|
||||
|
||||
const customVariables = {
|
||||
'App Platform': {
|
||||
id: 1,
|
||||
expl: _td('The platform you\'re on'),
|
||||
example: 'Electron Platform',
|
||||
},
|
||||
'App Version': {
|
||||
id: 2,
|
||||
expl: _td('The version of Riot.im'),
|
||||
example: '15.0.0',
|
||||
},
|
||||
'User Type': {
|
||||
id: 3,
|
||||
expl: _td('Whether or not you\'re logged in (we don\'t record your user name)'),
|
||||
example: 'Logged In',
|
||||
},
|
||||
'Chosen Language': {
|
||||
id: 4,
|
||||
expl: _td('Your language of choice'),
|
||||
example: 'en',
|
||||
},
|
||||
'Instance': {
|
||||
id: 5,
|
||||
expl: _td('Which officially provided instance you are using, if any'),
|
||||
example: 'app',
|
||||
},
|
||||
'RTE: Uses Richtext Mode': {
|
||||
id: 6,
|
||||
expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'),
|
||||
example: 'off',
|
||||
},
|
||||
'Homeserver URL': {
|
||||
id: 7,
|
||||
expl: _td('Your homeserver\'s URL'),
|
||||
example: 'https://matrix.org',
|
||||
},
|
||||
'Identity Server URL': {
|
||||
id: 8,
|
||||
expl: _td('Your identity server\'s URL'),
|
||||
example: 'https://vector.im',
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -92,6 +116,10 @@ class Analytics {
|
|||
*/
|
||||
disable() {
|
||||
this.trackEvent('Analytics', 'opt-out');
|
||||
// disableHeartBeatTimer is undocumented but exists in the piwik code
|
||||
// the _paq.push method will result in an error being printed in the console
|
||||
// if an unknown method signature is passed
|
||||
this._paq.push(['disableHeartBeatTimer']);
|
||||
this.disabled = true;
|
||||
}
|
||||
|
||||
|
@ -143,7 +171,7 @@ class Analytics {
|
|||
return true;
|
||||
}
|
||||
|
||||
trackPageChange() {
|
||||
trackPageChange(generationTimeMs) {
|
||||
if (this.disabled) return;
|
||||
if (this.firstPage) {
|
||||
// De-duplicate first page
|
||||
|
@ -151,6 +179,14 @@ class Analytics {
|
|||
this.firstPage = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof generationTimeMs === 'number') {
|
||||
this._paq.push(['setGenerationTimeMs', generationTimeMs]);
|
||||
} else {
|
||||
console.warn('Analytics.trackPageChange: expected generationTimeMs to be a number');
|
||||
// But continue anyway because we still want to track the change
|
||||
}
|
||||
|
||||
this._paq.push(['setCustomUrl', getRedactedUrl()]);
|
||||
this._paq.push(['trackPageView']);
|
||||
}
|
||||
|
@ -166,6 +202,7 @@ class Analytics {
|
|||
}
|
||||
|
||||
_setVisitVariable(key, value) {
|
||||
if (this.disabled) return;
|
||||
this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']);
|
||||
}
|
||||
|
||||
|
@ -173,8 +210,10 @@ class Analytics {
|
|||
if (this.disabled) return;
|
||||
|
||||
const config = SdkConfig.get();
|
||||
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || DEFAULTS.piwik.whitelistedHSUrls;
|
||||
const whitelistedISUrls = config.piwik.whitelistedISUrls || DEFAULTS.piwik.whitelistedISUrls;
|
||||
if (!config.piwik) return;
|
||||
|
||||
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
|
||||
const whitelistedISUrls = config.piwik.whitelistedISUrls || [];
|
||||
|
||||
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
|
||||
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
|
||||
|
@ -187,36 +226,56 @@ class Analytics {
|
|||
}
|
||||
|
||||
showDetailsModal() {
|
||||
const Tracker = window.Piwik.getAsyncTracker();
|
||||
const rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean);
|
||||
let rows = [];
|
||||
if (window.Piwik) {
|
||||
const Tracker = window.Piwik.getAsyncTracker();
|
||||
rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean);
|
||||
} else {
|
||||
// Piwik may not have been enabled, so show example values
|
||||
rows = Object.keys(customVariables).map(
|
||||
(k) => [
|
||||
k,
|
||||
_t('e.g. %(exampleValue)s', { exampleValue: customVariables[k].example }),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const resolution = `${window.screen.width}x${window.screen.height}`;
|
||||
const otherVariables = [
|
||||
{
|
||||
expl: _td('Every page you use in the app'),
|
||||
value: _t(
|
||||
'e.g. <CurrentPageURL>',
|
||||
{},
|
||||
{
|
||||
CurrentPageURL: getRedactedUrl(),
|
||||
},
|
||||
),
|
||||
},
|
||||
{ expl: _td('Your User Agent'), value: navigator.userAgent },
|
||||
{ expl: _td('Your device resolution'), value: resolution },
|
||||
];
|
||||
|
||||
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
|
||||
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
|
||||
title: _t('Analytics'),
|
||||
description: <div>
|
||||
description: <div className="mx_UserSettings_analyticsModal">
|
||||
<div>
|
||||
{ _t('The information being sent to us to help make Riot.im better includes:') }
|
||||
</div>
|
||||
<table>
|
||||
{ rows.map((row) => <tr key={row[0]}>
|
||||
<td>{ _t(customVariables[row[0]].expl) }</td>
|
||||
<td><code>{ row[1] }</code></td>
|
||||
{ row[1] !== undefined && <td><code>{ row[1] }</code></td> }
|
||||
</tr>) }
|
||||
</table>
|
||||
<br />
|
||||
<div>
|
||||
{ _t('We also record each page you use in the app (currently <CurrentPageHash>), your User Agent'
|
||||
+ ' (<CurrentUserAgent>) and your device resolution (<CurrentDeviceResolution>).',
|
||||
{},
|
||||
{
|
||||
CurrentPageHash: <code>{ getRedactedHash() }</code>,
|
||||
CurrentUserAgent: <code>{ navigator.userAgent }</code>,
|
||||
CurrentDeviceResolution: <code>{ resolution }</code>,
|
||||
},
|
||||
{ otherVariables.map((item, index) =>
|
||||
<tr key={index}>
|
||||
<td>{ _t(item.expl) }</td>
|
||||
<td><code>{ item.value }</code></td>
|
||||
</tr>,
|
||||
) }
|
||||
|
||||
</table>
|
||||
<div>
|
||||
{ _t('Where this page includes identifiable information, such as a room, '
|
||||
+ 'user or group ID, that data is removed before being sent to the server.') }
|
||||
</div>
|
||||
|
|
|
@ -275,6 +275,13 @@ class ContentMessages {
|
|||
this.nextId = 0;
|
||||
}
|
||||
|
||||
sendStickerContentToRoom(url, roomId, info, text, matrixClient) {
|
||||
return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
|
||||
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
sendContentToRoom(file, roomId, matrixClient) {
|
||||
const content = {
|
||||
body: file.name || 'Attachment',
|
||||
|
|
|
@ -50,11 +50,15 @@ function pad(n) {
|
|||
return (n < 10 ? '0' : '') + n;
|
||||
}
|
||||
|
||||
function twelveHourTime(date) {
|
||||
function twelveHourTime(date, showSeconds=false) {
|
||||
let hours = date.getHours() % 12;
|
||||
const minutes = pad(date.getMinutes());
|
||||
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
|
||||
hours = hours ? hours : 12; // convert 0 -> 12
|
||||
if (showSeconds) {
|
||||
const seconds = pad(date.getSeconds());
|
||||
return `${hours}:${minutes}:${seconds}${ampm}`;
|
||||
}
|
||||
return `${hours}:${minutes}${ampm}`;
|
||||
}
|
||||
|
||||
|
@ -82,6 +86,17 @@ export function formatDate(date, showTwelveHour=false) {
|
|||
return formatFullDate(date, showTwelveHour);
|
||||
}
|
||||
|
||||
export function formatFullDateNoTime(date) {
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
|
||||
weekDayName: days[date.getDay()],
|
||||
monthName: months[date.getMonth()],
|
||||
day: date.getDate(),
|
||||
fullYear: date.getFullYear(),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFullDate(date, showTwelveHour=false) {
|
||||
const days = getDaysArray();
|
||||
const months = getMonthsArray();
|
||||
|
@ -90,10 +105,17 @@ export function formatFullDate(date, showTwelveHour=false) {
|
|||
monthName: months[date.getMonth()],
|
||||
day: date.getDate(),
|
||||
fullYear: date.getFullYear(),
|
||||
time: formatTime(date, showTwelveHour),
|
||||
time: formatFullTime(date, showTwelveHour),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFullTime(date, showTwelveHour=false) {
|
||||
if (showTwelveHour) {
|
||||
return twelveHourTime(date, true);
|
||||
}
|
||||
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
|
||||
}
|
||||
|
||||
export function formatTime(date, showTwelveHour=false) {
|
||||
if (showTwelveHour) {
|
||||
return twelveHourTime(date);
|
||||
|
|
210
src/FromWidgetPostMessageApi.js
Normal file
210
src/FromWidgetPostMessageApi.js
Normal file
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import URL from 'url';
|
||||
import dis from './dispatcher';
|
||||
import IntegrationManager from './IntegrationManager';
|
||||
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
|
||||
|
||||
const WIDGET_API_VERSION = '0.0.1'; // Current API version
|
||||
const SUPPORTED_WIDGET_API_VERSIONS = [
|
||||
'0.0.1',
|
||||
];
|
||||
const INBOUND_API_NAME = 'fromWidget';
|
||||
|
||||
// Listen for and handle incomming requests using the 'fromWidget' postMessage
|
||||
// API and initiate responses
|
||||
export default class FromWidgetPostMessageApi {
|
||||
constructor() {
|
||||
this.widgetMessagingEndpoints = [];
|
||||
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.onPostMessage = this.onPostMessage.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
window.addEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a widget endpoint for trusted postMessage communication
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
*/
|
||||
addEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
const endpoint = new WidgetMessagingEndpoint(widgetId, origin);
|
||||
if (this.widgetMessagingEndpoints.some(function(ep) {
|
||||
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
|
||||
})) {
|
||||
// Message endpoint already registered
|
||||
console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
|
||||
return;
|
||||
} else {
|
||||
console.warn(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
|
||||
this.widgetMessagingEndpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* De-register a widget endpoint from trusted communication sources
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
* @return {boolean} True if endpoint was successfully removed
|
||||
*/
|
||||
removeEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn('Remove widget messaging endpoint - Invalid origin');
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) {
|
||||
const length = this.widgetMessagingEndpoints.length;
|
||||
this.widgetMessagingEndpoints = this.widgetMessagingEndpoints.
|
||||
filter(function(endpoint) {
|
||||
return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
|
||||
});
|
||||
return (length > this.widgetMessagingEndpoints.length);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle widget postMessage events
|
||||
* Messages are only handled where a valid, registered messaging endpoints
|
||||
* @param {Event} event Event to handle
|
||||
* @return {undefined}
|
||||
*/
|
||||
onPostMessage(event) {
|
||||
if (!event.origin) { // Handle chrome
|
||||
event.origin = event.originalEvent.origin;
|
||||
}
|
||||
|
||||
// Event origin is empty string if undefined
|
||||
if (
|
||||
event.origin.length === 0 ||
|
||||
!this.trustedEndpoint(event.origin) ||
|
||||
event.data.api !== INBOUND_API_NAME ||
|
||||
!event.data.widgetId
|
||||
) {
|
||||
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
|
||||
}
|
||||
|
||||
// Although the requestId is required, we don't use it. We'll be nice and process the message
|
||||
// if the property is missing, but with a warning for widget developers.
|
||||
if (!event.data.requestId) {
|
||||
console.warn("fromWidget action '" + event.data.action + "' does not have a requestId");
|
||||
}
|
||||
|
||||
const action = event.data.action;
|
||||
const widgetId = event.data.widgetId;
|
||||
if (action === 'content_loaded') {
|
||||
console.warn('Widget reported content loaded for', widgetId);
|
||||
dis.dispatch({
|
||||
action: 'widget_content_loaded',
|
||||
widgetId: widgetId,
|
||||
});
|
||||
this.sendResponse(event, {success: true});
|
||||
} else if (action === 'supported_api_versions') {
|
||||
this.sendResponse(event, {
|
||||
api: INBOUND_API_NAME,
|
||||
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
|
||||
});
|
||||
} else if (action === 'api_version') {
|
||||
this.sendResponse(event, {
|
||||
api: INBOUND_API_NAME,
|
||||
version: WIDGET_API_VERSION,
|
||||
});
|
||||
} else if (action === 'm.sticker') {
|
||||
// console.warn('Got sticker message from widget', widgetId);
|
||||
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
|
||||
const data = event.data.data || event.data.widgetData;
|
||||
dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId});
|
||||
} else if (action === 'integration_manager_open') {
|
||||
// Close the stickerpicker
|
||||
dis.dispatch({action: 'stickerpicker_close'});
|
||||
// Open the integration manager
|
||||
// NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
|
||||
const data = event.data.data || event.data.widgetData;
|
||||
const integType = (data && data.integType) ? data.integType : null;
|
||||
const integId = (data && data.integId) ? data.integId : null;
|
||||
IntegrationManager.open(integType, integId);
|
||||
} else {
|
||||
console.warn('Widget postMessage event unhandled');
|
||||
this.sendError(event, {message: 'The postMessage was unhandled'});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message origin is registered as trusted
|
||||
* @param {string} origin PostMessage origin to check
|
||||
* @return {boolean} True if trusted
|
||||
*/
|
||||
trustedEndpoint(origin) {
|
||||
if (!origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.widgetMessagingEndpoints.some((endpoint) => {
|
||||
// TODO / FIXME -- Should this also check the widgetId?
|
||||
return endpoint.endpointUrl === origin;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a postmessage response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {Object} res Response data
|
||||
*/
|
||||
sendResponse(event, res) {
|
||||
const data = JSON.parse(JSON.stringify(event.data));
|
||||
data.response = res;
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {string} msg Error message
|
||||
* @param {Error} nestedError Nested error event (optional)
|
||||
*/
|
||||
sendError(event, msg, nestedError) {
|
||||
console.error('Action:' + event.data.action + ' failed with message: ' + msg);
|
||||
const data = JSON.parse(JSON.stringify(event.data));
|
||||
data.response = {
|
||||
error: {
|
||||
message: msg,
|
||||
},
|
||||
};
|
||||
if (nestedError) {
|
||||
data.response.error._error = nestedError;
|
||||
}
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
}
|
|
@ -19,31 +19,33 @@ import sdk from './';
|
|||
import MultiInviter from './utils/MultiInviter';
|
||||
import { _t } from './languageHandler';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import GroupStoreCache from './stores/GroupStoreCache';
|
||||
import GroupStore from './stores/GroupStore';
|
||||
|
||||
export function showGroupInviteDialog(groupId) {
|
||||
const description = <div>
|
||||
<div>{ _t("Who would you like to add to this community?") }</div>
|
||||
<div className="warning">
|
||||
{ _t(
|
||||
"Warning: any person you add to a community will be publicly "+
|
||||
"visible to anyone who knows the community ID",
|
||||
) }
|
||||
</div>
|
||||
</div>;
|
||||
return new Promise((resolve, reject) => {
|
||||
const description = <div>
|
||||
<div>{ _t("Who would you like to add to this community?") }</div>
|
||||
<div className="warning">
|
||||
{ _t(
|
||||
"Warning: any person you add to a community will be publicly "+
|
||||
"visible to anyone who knows the community ID",
|
||||
) }
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, {
|
||||
title: _t("Invite new community members"),
|
||||
description: description,
|
||||
placeholder: _t("Name or matrix ID"),
|
||||
button: _t("Invite to Community"),
|
||||
validAddressTypes: ['mx-user-id'],
|
||||
onFinished: (success, addrs) => {
|
||||
if (!success) return;
|
||||
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
|
||||
Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, {
|
||||
title: _t("Invite new community members"),
|
||||
description: description,
|
||||
placeholder: _t("Name or matrix ID"),
|
||||
button: _t("Invite to Community"),
|
||||
validAddressTypes: ['mx-user-id'],
|
||||
onFinished: (success, addrs) => {
|
||||
if (!success) return;
|
||||
|
||||
_onGroupInviteFinished(groupId, addrs);
|
||||
},
|
||||
_onGroupInviteFinished(groupId, addrs).then(resolve, reject);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -87,7 +89,7 @@ function _onGroupInviteFinished(groupId, addrs) {
|
|||
|
||||
const addrTexts = addrs.map((addr) => addr.address);
|
||||
|
||||
multiInviter.invite(addrTexts).then((completionStates) => {
|
||||
return multiInviter.invite(addrTexts).then((completionStates) => {
|
||||
// Show user any errors
|
||||
const errorList = [];
|
||||
for (const addr of Object.keys(completionStates)) {
|
||||
|
@ -114,11 +116,10 @@ function _onGroupInviteFinished(groupId, addrs) {
|
|||
|
||||
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
|
||||
const matrixClient = MatrixClientPeg.get();
|
||||
const groupStore = GroupStoreCache.getGroupStore(groupId);
|
||||
const errorList = [];
|
||||
return Promise.all(addrs.map((addr) => {
|
||||
return groupStore
|
||||
.addRoomToGroup(addr.address, addRoomsPublicly)
|
||||
return GroupStore
|
||||
.addRoomToGroup(groupId, addr.address, addRoomsPublicly)
|
||||
.catch(() => { errorList.push(addr.address); })
|
||||
.then(() => {
|
||||
const roomId = addr.address;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,6 +17,8 @@ limitations under the License.
|
|||
|
||||
'use strict';
|
||||
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
|
||||
const React = require('react');
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
const highlight = require('highlight.js');
|
||||
|
@ -25,6 +27,7 @@ import escape from 'lodash/escape';
|
|||
import emojione from 'emojione';
|
||||
import classNames from 'classnames';
|
||||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
import url from 'url';
|
||||
|
||||
emojione.imagePathSVG = 'emojione/svg/';
|
||||
// Store PNG path for displaying many flags at once (for increased performance over SVG)
|
||||
|
@ -44,6 +47,8 @@ const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
|
|||
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
|
||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||
|
||||
/*
|
||||
* Return true if the given string contains emoji
|
||||
* Uses a much, much simpler regex than emojione's so will give false
|
||||
|
@ -152,6 +157,25 @@ export function sanitizedHtmlNode(insaneHtml) {
|
|||
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a URL from an untrusted source may be safely put into the DOM
|
||||
* The biggest threat here is javascript: URIs.
|
||||
* Note that the HTML sanitiser library has its own internal logic for
|
||||
* doing this, to which we pass the same list of schemes. This is used in
|
||||
* other places we need to sanitise URLs.
|
||||
* @return true if permitted, otherwise false
|
||||
*/
|
||||
export function isUrlPermitted(inputUrl) {
|
||||
try {
|
||||
const parsed = url.parse(inputUrl);
|
||||
if (!parsed.protocol) return false;
|
||||
// URL parser protocol includes the trailing colon
|
||||
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeHtmlParams = {
|
||||
allowedTags: [
|
||||
'font', // custom to matrix for IRC-style font coloring
|
||||
|
@ -172,7 +196,7 @@ const sanitizeHtmlParams = {
|
|||
// Lots of these won't come up by default because we don't allow them
|
||||
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
||||
// URL schemes we permit
|
||||
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'],
|
||||
allowedSchemes: PERMITTED_URL_SCHEMES,
|
||||
|
||||
allowProtocolRelative: false,
|
||||
|
||||
|
@ -386,14 +410,16 @@ class TextHighlighter extends BaseHighlighter {
|
|||
*
|
||||
* opts.highlightLink: optional href to add to highlighted words
|
||||
* opts.disableBigEmoji: optional argument to disable the big emoji class.
|
||||
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
|
||||
*/
|
||||
export function bodyToHtml(content, highlights, opts={}) {
|
||||
const isHtml = (content.format === "org.matrix.custom.html");
|
||||
const body = isHtml ? content.formatted_body : escape(content.body);
|
||||
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
||||
|
||||
let bodyHasEmoji = false;
|
||||
|
||||
let strippedBody;
|
||||
let safeBody;
|
||||
let isDisplayedWithHtml;
|
||||
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
|
||||
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
|
||||
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
|
||||
|
@ -409,9 +435,33 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
return highlighter.applyHighlights(safeText, safeHighlights).join('');
|
||||
};
|
||||
}
|
||||
safeBody = sanitizeHtml(body, sanitizeHtmlParams);
|
||||
bodyHasEmoji = containsEmoji(body);
|
||||
if (bodyHasEmoji) safeBody = unicodeToImage(safeBody);
|
||||
|
||||
let formattedBody = content.formatted_body;
|
||||
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
|
||||
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body;
|
||||
|
||||
bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body);
|
||||
|
||||
|
||||
// Only generate safeBody if the message was sent as org.matrix.custom.html
|
||||
if (isHtmlMessage) {
|
||||
isDisplayedWithHtml = true;
|
||||
safeBody = sanitizeHtml(formattedBody, sanitizeHtmlParams);
|
||||
} else {
|
||||
// ... or if there are emoji, which we insert as HTML alongside the
|
||||
// escaped plaintext body.
|
||||
if (bodyHasEmoji) {
|
||||
isDisplayedWithHtml = true;
|
||||
safeBody = sanitizeHtml(escape(strippedBody), sanitizeHtmlParams);
|
||||
}
|
||||
}
|
||||
|
||||
// An HTML message with emoji
|
||||
// or a plaintext message with emoji that was escaped and sanitized into
|
||||
// HTML.
|
||||
if (bodyHasEmoji) {
|
||||
safeBody = unicodeToImage(safeBody);
|
||||
}
|
||||
} finally {
|
||||
delete sanitizeHtmlParams.textFilter;
|
||||
}
|
||||
|
@ -419,7 +469,7 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
let emojiBody = false;
|
||||
if (!opts.disableBigEmoji && bodyHasEmoji) {
|
||||
EMOJI_REGEX.lastIndex = 0;
|
||||
const contentBodyTrimmed = content.body !== undefined ? content.body.trim() : '';
|
||||
const contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : '';
|
||||
const match = EMOJI_REGEX.exec(contentBodyTrimmed);
|
||||
emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
|
||||
}
|
||||
|
@ -427,9 +477,12 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
const className = classNames({
|
||||
'mx_EventTile_body': true,
|
||||
'mx_EventTile_bigEmoji': emojiBody,
|
||||
'markdown-body': isHtml,
|
||||
'markdown-body': isHtmlMessage,
|
||||
});
|
||||
return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" />;
|
||||
|
||||
return isDisplayedWithHtml ?
|
||||
<span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
|
||||
<span className={className} dir="auto">{ strippedBody }</span>;
|
||||
}
|
||||
|
||||
export function emojifyText(text) {
|
||||
|
|
73
src/IntegrationManager.js
Normal file
73
src/IntegrationManager.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import Modal from './Modal';
|
||||
import sdk from './index';
|
||||
import SdkConfig from './SdkConfig';
|
||||
import ScalarMessaging from './ScalarMessaging';
|
||||
import ScalarAuthClient from './ScalarAuthClient';
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
|
||||
if (!global.mxIntegrationManager) {
|
||||
global.mxIntegrationManager = {};
|
||||
}
|
||||
|
||||
export default class IntegrationManager {
|
||||
static _init() {
|
||||
if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) {
|
||||
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
|
||||
ScalarMessaging.startListening();
|
||||
global.mxIntegrationManager.client = new ScalarAuthClient();
|
||||
|
||||
return global.mxIntegrationManager.client.connect().then(() => {
|
||||
global.mxIntegrationManager.connected = true;
|
||||
}).catch((e) => {
|
||||
console.error("Failed to connect to integrations server", e);
|
||||
global.mxIntegrationManager.error = e;
|
||||
});
|
||||
} else {
|
||||
console.error('Invalid integration manager config', SdkConfig.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the integrations manager on the stickers integration page
|
||||
* @param {string} integName integration / widget type
|
||||
* @param {string} integId integration / widget ID
|
||||
* @param {function} onFinished Callback to invoke on integration manager close
|
||||
*/
|
||||
static async open(integName, integId, onFinished) {
|
||||
await IntegrationManager._init();
|
||||
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
|
||||
if (global.mxIntegrationManager.error ||
|
||||
!(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) {
|
||||
console.error("Scalar error", global.mxIntegrationManager);
|
||||
return;
|
||||
}
|
||||
const integType = 'type_' + integName;
|
||||
const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ?
|
||||
global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom(
|
||||
{roomId: RoomViewStore.getRoomId()},
|
||||
integType,
|
||||
integId,
|
||||
) :
|
||||
null;
|
||||
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
|
||||
src: src,
|
||||
onFinished: onFinished,
|
||||
}, "mx_IntegrationsManager");
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -64,33 +65,33 @@ import sdk from './index';
|
|||
* Resolves to `true` if we ended up starting a session, or `false` if we
|
||||
* failed.
|
||||
*/
|
||||
export function loadSession(opts) {
|
||||
const fragmentQueryParams = opts.fragmentQueryParams || {};
|
||||
let enableGuest = opts.enableGuest || false;
|
||||
const guestHsUrl = opts.guestHsUrl;
|
||||
const guestIsUrl = opts.guestIsUrl;
|
||||
const defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||
export async function loadSession(opts) {
|
||||
try {
|
||||
let enableGuest = opts.enableGuest || false;
|
||||
const guestHsUrl = opts.guestHsUrl;
|
||||
const guestIsUrl = opts.guestIsUrl;
|
||||
const fragmentQueryParams = opts.fragmentQueryParams || {};
|
||||
const defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
|
||||
|
||||
if (!guestHsUrl) {
|
||||
console.warn("Cannot enable guest access: can't determine HS URL to use");
|
||||
enableGuest = false;
|
||||
}
|
||||
if (!guestHsUrl) {
|
||||
console.warn("Cannot enable guest access: can't determine HS URL to use");
|
||||
enableGuest = false;
|
||||
}
|
||||
|
||||
if (enableGuest &&
|
||||
fragmentQueryParams.guest_user_id &&
|
||||
fragmentQueryParams.guest_access_token
|
||||
) {
|
||||
console.log("Using guest access credentials");
|
||||
return _doSetLoggedIn({
|
||||
userId: fragmentQueryParams.guest_user_id,
|
||||
accessToken: fragmentQueryParams.guest_access_token,
|
||||
homeserverUrl: guestHsUrl,
|
||||
identityServerUrl: guestIsUrl,
|
||||
guest: true,
|
||||
}, true).then(() => true);
|
||||
}
|
||||
|
||||
return _restoreFromLocalStorage().then((success) => {
|
||||
if (enableGuest &&
|
||||
fragmentQueryParams.guest_user_id &&
|
||||
fragmentQueryParams.guest_access_token
|
||||
) {
|
||||
console.log("Using guest access credentials");
|
||||
return _doSetLoggedIn({
|
||||
userId: fragmentQueryParams.guest_user_id,
|
||||
accessToken: fragmentQueryParams.guest_access_token,
|
||||
homeserverUrl: guestHsUrl,
|
||||
identityServerUrl: guestIsUrl,
|
||||
guest: true,
|
||||
}, true).then(() => true);
|
||||
}
|
||||
const success = await _restoreFromLocalStorage();
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
|
@ -101,7 +102,9 @@ export function loadSession(opts) {
|
|||
|
||||
// fall back to login screen
|
||||
return false;
|
||||
});
|
||||
} catch (e) {
|
||||
return _handleLoadSessionFailure(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -195,9 +198,9 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
|
|||
// The plan is to gradually move the localStorage access done here into
|
||||
// SessionStore to avoid bugs where the view becomes out-of-sync with
|
||||
// localStorage (e.g. teamToken, isGuest etc.)
|
||||
function _restoreFromLocalStorage() {
|
||||
async function _restoreFromLocalStorage() {
|
||||
if (!localStorage) {
|
||||
return Promise.resolve(false);
|
||||
return false;
|
||||
}
|
||||
const hsUrl = localStorage.getItem("mx_hs_url");
|
||||
const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
|
||||
|
@ -215,26 +218,23 @@ function _restoreFromLocalStorage() {
|
|||
|
||||
if (accessToken && userId && hsUrl) {
|
||||
console.log(`Restoring session for ${userId}`);
|
||||
try {
|
||||
return _doSetLoggedIn({
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
accessToken: accessToken,
|
||||
homeserverUrl: hsUrl,
|
||||
identityServerUrl: isUrl,
|
||||
guest: isGuest,
|
||||
}, false).then(() => true);
|
||||
} catch (e) {
|
||||
return _handleRestoreFailure(e);
|
||||
}
|
||||
await _doSetLoggedIn({
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
accessToken: accessToken,
|
||||
homeserverUrl: hsUrl,
|
||||
identityServerUrl: isUrl,
|
||||
guest: isGuest,
|
||||
}, false);
|
||||
return true;
|
||||
} else {
|
||||
console.log("No previous session found.");
|
||||
return Promise.resolve(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _handleRestoreFailure(e) {
|
||||
console.log("Unable to restore session", e);
|
||||
function _handleLoadSessionFailure(e) {
|
||||
console.log("Unable to load session", e);
|
||||
|
||||
const def = Promise.defer();
|
||||
const SessionRestoreErrorDialog =
|
||||
|
@ -255,7 +255,7 @@ function _handleRestoreFailure(e) {
|
|||
}
|
||||
|
||||
// try, try again
|
||||
return _restoreFromLocalStorage();
|
||||
return loadSession();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -362,7 +362,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
|
|||
dis.dispatch({action: 'on_logged_in', teamToken: teamToken});
|
||||
});
|
||||
|
||||
startMatrixClient();
|
||||
await startMatrixClient();
|
||||
return MatrixClientPeg.get();
|
||||
}
|
||||
|
||||
|
@ -423,7 +423,7 @@ export function logout() {
|
|||
* Starts the matrix client and all other react-sdk services that
|
||||
* listen for events while a session is logged in.
|
||||
*/
|
||||
function startMatrixClient() {
|
||||
async function startMatrixClient() {
|
||||
console.log(`Lifecycle: Starting MatrixClient`);
|
||||
|
||||
// dispatch this before starting the matrix client: it's used
|
||||
|
@ -437,7 +437,7 @@ function startMatrixClient() {
|
|||
Presence.start();
|
||||
DMRoomMap.makeShared().start();
|
||||
|
||||
MatrixClientPeg.start();
|
||||
await MatrixClientPeg.start();
|
||||
|
||||
// dispatch that we finished starting up to wire up any other bits
|
||||
// of the matrix client that cannot be set prior to starting up.
|
||||
|
|
|
@ -98,7 +98,6 @@ class MatrixClientPeg {
|
|||
const opts = utils.deepCopy(this.opts);
|
||||
// the react sdk doesn't work without this, so don't allow
|
||||
opts.pendingEventOrdering = "detached";
|
||||
opts.disablePresence = true; // we do this manually
|
||||
|
||||
try {
|
||||
const promise = this.matrixClient.store.startup();
|
||||
|
@ -175,4 +174,4 @@ class MatrixClientPeg {
|
|||
if (!global.mxMatrixClientPeg) {
|
||||
global.mxMatrixClientPeg = new MatrixClientPeg();
|
||||
}
|
||||
module.exports = global.mxMatrixClientPeg;
|
||||
export default global.mxMatrixClientPeg;
|
||||
|
|
57
src/Modal.js
57
src/Modal.js
|
@ -22,6 +22,7 @@ const ReactDOM = require('react-dom');
|
|||
import PropTypes from 'prop-types';
|
||||
import Analytics from './Analytics';
|
||||
import sdk from './index';
|
||||
import dis from './dispatcher';
|
||||
|
||||
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
|
||||
|
||||
|
@ -80,7 +81,11 @@ class ModalManager {
|
|||
constructor() {
|
||||
this._counter = 0;
|
||||
|
||||
/** list of the modals we have stacked up, with the most recent at [0] */
|
||||
// The modal to prioritise over all others. If this is set, only show
|
||||
// this modal. Remove all other modals from the stack when this modal
|
||||
// is closed.
|
||||
this._priorityModal = null;
|
||||
// A list of the modals we have stacked up, with the most recent at [0]
|
||||
this._modals = [
|
||||
/* {
|
||||
elem: React component for this dialog
|
||||
|
@ -104,18 +109,18 @@ class ModalManager {
|
|||
return container;
|
||||
}
|
||||
|
||||
createTrackedDialog(analyticsAction, analyticsInfo, Element, props, className) {
|
||||
createTrackedDialog(analyticsAction, analyticsInfo, ...rest) {
|
||||
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
|
||||
return this.createDialog(Element, props, className);
|
||||
return this.createDialog(...rest);
|
||||
}
|
||||
|
||||
createDialog(Element, props, className) {
|
||||
return this.createDialogAsync((cb) => {cb(Element);}, props, className);
|
||||
createDialog(Element, ...rest) {
|
||||
return this.createDialogAsync((cb) => {cb(Element);}, ...rest);
|
||||
}
|
||||
|
||||
createTrackedDialogAsync(analyticsAction, analyticsInfo, loader, props, className) {
|
||||
createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) {
|
||||
Analytics.trackEvent('Modal', analyticsAction, analyticsInfo);
|
||||
return this.createDialogAsync(loader, props, className);
|
||||
return this.createDialogAsync(...rest);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -136,8 +141,13 @@ class ModalManager {
|
|||
* component. (We will also pass an 'onFinished' property.)
|
||||
*
|
||||
* @param {String} className CSS class to apply to the modal wrapper
|
||||
*
|
||||
* @param {boolean} isPriorityModal if true, this modal will be displayed regardless
|
||||
* of other modals that are currently in the stack.
|
||||
* Also, when closed, all modals will be removed
|
||||
* from the stack.
|
||||
*/
|
||||
createDialogAsync(loader, props, className) {
|
||||
createDialogAsync(loader, props, className, isPriorityModal) {
|
||||
const self = this;
|
||||
const modal = {};
|
||||
|
||||
|
@ -150,6 +160,14 @@ class ModalManager {
|
|||
if (i >= 0) {
|
||||
self._modals.splice(i, 1);
|
||||
}
|
||||
|
||||
if (self._priorityModal === modal) {
|
||||
self._priorityModal = null;
|
||||
|
||||
// XXX: This is destructive
|
||||
self._modals = [];
|
||||
}
|
||||
|
||||
self._reRender();
|
||||
};
|
||||
|
||||
|
@ -166,7 +184,12 @@ class ModalManager {
|
|||
modal.onFinished = props ? props.onFinished : null;
|
||||
modal.className = className;
|
||||
|
||||
this._modals.unshift(modal);
|
||||
if (isPriorityModal) {
|
||||
// XXX: This is destructive
|
||||
this._priorityModal = modal;
|
||||
} else {
|
||||
this._modals.unshift(modal);
|
||||
}
|
||||
|
||||
this._reRender();
|
||||
return {close: closeDialog};
|
||||
|
@ -187,12 +210,24 @@ class ModalManager {
|
|||
}
|
||||
|
||||
_reRender() {
|
||||
if (this._modals.length == 0) {
|
||||
if (this._modals.length == 0 && !this._priorityModal) {
|
||||
// If there is no modal to render, make all of Riot available
|
||||
// to screen reader users again
|
||||
dis.dispatch({
|
||||
action: 'aria_unhide_main_app',
|
||||
});
|
||||
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = this._modals[0];
|
||||
// Hide the content outside the modal to screen reader users
|
||||
// so they won't be able to navigate into it and act on it using
|
||||
// screen reader specific features
|
||||
dis.dispatch({
|
||||
action: 'aria_hide_main_app',
|
||||
});
|
||||
|
||||
const modal = this._priorityModal ? this._priorityModal : this._modals[0];
|
||||
const dialog = (
|
||||
<div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}>
|
||||
<div className="mx_Dialog">
|
||||
|
|
|
@ -256,6 +256,10 @@ const Notifier = {
|
|||
},
|
||||
|
||||
onEventDecrypted: function(ev) {
|
||||
// 'decrypted' means the decryption process has finished: it may have failed,
|
||||
// in which case it might decrypt soon if the keys arrive
|
||||
if (ev.isDecryptionFailure()) return;
|
||||
|
||||
const idx = this.pendingEncryptedEventIds.indexOf(ev.getId());
|
||||
if (idx === -1) return;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -31,7 +32,7 @@ class Presence {
|
|||
this.running = true;
|
||||
if (undefined === this.state) {
|
||||
this._resetTimer();
|
||||
this.dispatcherRef = dis.register(this._onUserActivity.bind(this));
|
||||
this.dispatcherRef = dis.register(this._onAction.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -56,27 +57,13 @@ class Presence {
|
|||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status message.
|
||||
* @returns {String} the status message, may be null
|
||||
*/
|
||||
getStatusMessage() {
|
||||
return this.statusMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the presence state.
|
||||
* If the state has changed, the Home Server will be notified.
|
||||
* @param {string} newState the new presence state (see PRESENCE enum)
|
||||
* @param {String} statusMessage an optional status message for the presence
|
||||
* @param {boolean} maintain true to have this status maintained by this tracker
|
||||
*/
|
||||
setState(newState, statusMessage=null, maintain=false) {
|
||||
if (this.maintain) {
|
||||
// Don't update presence if we're maintaining a particular status
|
||||
return;
|
||||
}
|
||||
if (newState === this.state && statusMessage === this.statusMessage) {
|
||||
setState(newState) {
|
||||
if (newState === this.state) {
|
||||
return;
|
||||
}
|
||||
if (PRESENCE_STATES.indexOf(newState) === -1) {
|
||||
|
@ -86,37 +73,21 @@ class Presence {
|
|||
return;
|
||||
}
|
||||
const old_state = this.state;
|
||||
const old_message = this.statusMessage;
|
||||
this.state = newState;
|
||||
this.statusMessage = statusMessage;
|
||||
this.maintain = maintain;
|
||||
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
return; // don't try to set presence when a guest; it won't work.
|
||||
}
|
||||
|
||||
const updateContent = {
|
||||
presence: this.state,
|
||||
status_msg: this.statusMessage ? this.statusMessage : '',
|
||||
};
|
||||
|
||||
const self = this;
|
||||
MatrixClientPeg.get().setPresence(updateContent).done(function() {
|
||||
MatrixClientPeg.get().setPresence(this.state).done(function() {
|
||||
console.log("Presence: %s", newState);
|
||||
|
||||
// We have to dispatch because the js-sdk is unreliable at telling us about our own presence
|
||||
dis.dispatch({action: "self_presence_updated", statusInfo: updateContent});
|
||||
}, function(err) {
|
||||
console.error("Failed to set presence: %s", err);
|
||||
self.state = old_state;
|
||||
self.statusMessage = old_message;
|
||||
});
|
||||
}
|
||||
|
||||
stopMaintainingStatus() {
|
||||
this.maintain = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
|
||||
* @private
|
||||
|
@ -125,9 +96,10 @@ class Presence {
|
|||
this.setState("unavailable");
|
||||
}
|
||||
|
||||
_onUserActivity(payload) {
|
||||
if (payload.action === "sync_state" || payload.action === "self_presence_updated") return;
|
||||
this._resetTimer();
|
||||
_onAction(payload) {
|
||||
if (payload.action === "user_activity") {
|
||||
this._resetTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -57,6 +57,7 @@ export function showStartChatInviteDialog() {
|
|||
title: _t('Start a chat'),
|
||||
description: _t("Who would you like to communicate with?"),
|
||||
placeholder: _t("Email, name or matrix ID"),
|
||||
validAddressTypes: ['mx-user-id', 'email'],
|
||||
button: _t("Start Chat"),
|
||||
onFinished: _onStartChatFinished,
|
||||
});
|
||||
|
@ -85,9 +86,7 @@ function _onStartChatFinished(shouldInvite, addrs) {
|
|||
if (rooms.length > 0) {
|
||||
// A Direct Message room already exists for this user, so select a
|
||||
// room from a list that is similar to the one in MemberInfo panel
|
||||
const ChatCreateOrReuseDialog = sdk.getComponent(
|
||||
"views.dialogs.ChatCreateOrReuseDialog",
|
||||
);
|
||||
const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog");
|
||||
const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, {
|
||||
userId: addrTexts[0],
|
||||
onNewDMClick: () => {
|
||||
|
@ -115,6 +114,15 @@ function _onStartChatFinished(shouldInvite, addrs) {
|
|||
});
|
||||
});
|
||||
}
|
||||
} else if (addrTexts.length === 1) {
|
||||
// Start a new DM chat
|
||||
createRoom({dmUserId: addrTexts[0]}).catch((err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, {
|
||||
title: _t("Failed to invite user"),
|
||||
description: ((err && err.message) ? err.message : _t("Operation failed")),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Start multi user chat
|
||||
let room;
|
||||
|
|
|
@ -34,7 +34,14 @@ export function getRoomNotifsState(roomId) {
|
|||
}
|
||||
|
||||
// for everything else, look at the room rule.
|
||||
const roomRule = MatrixClientPeg.get().getRoomPushRule('global', roomId);
|
||||
let roomRule = null;
|
||||
try {
|
||||
roomRule = MatrixClientPeg.get().getRoomPushRule('global', roomId);
|
||||
} catch (err) {
|
||||
// Possible that the client doesn't have pushRules yet. If so, it
|
||||
// hasn't started eiher, so indicate that this room is not notifying.
|
||||
return null;
|
||||
}
|
||||
|
||||
// XXX: We have to assume the default is to notify for all messages
|
||||
// (in particular this will be 'wrong' for one to one rooms because
|
||||
|
@ -130,6 +137,11 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
|
|||
}
|
||||
|
||||
function findOverrideMuteRule(roomId) {
|
||||
if (!MatrixClientPeg.get().pushRules ||
|
||||
!MatrixClientPeg.get().pushRules['global'] ||
|
||||
!MatrixClientPeg.get().pushRules['global'].override) {
|
||||
return null;
|
||||
}
|
||||
for (const rule of MatrixClientPeg.get().pushRules['global'].override) {
|
||||
if (isRuleForRoom(roomId, rule)) {
|
||||
if (isMuteRule(rule) && rule.enabled) {
|
||||
|
|
|
@ -148,10 +148,48 @@ class ScalarAuthClient {
|
|||
return defer.promise;
|
||||
}
|
||||
|
||||
getScalarInterfaceUrlForRoom(roomId, screen, id) {
|
||||
/**
|
||||
* Mark all assets associated with the specified widget as "disabled" in the
|
||||
* integration manager database.
|
||||
* This can be useful to temporarily prevent purchased assets from being displayed.
|
||||
* @param {string} widgetType [description]
|
||||
* @param {string} widgetId [description]
|
||||
* @return {Promise} Resolves on completion
|
||||
*/
|
||||
disableWidgetAssets(widgetType, widgetId) {
|
||||
let url = SdkConfig.get().integrations_rest_url + '/widgets/set_assets_state';
|
||||
url = this.getStarterLink(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
request({
|
||||
method: 'GET',
|
||||
uri: url,
|
||||
json: true,
|
||||
qs: {
|
||||
'widget_type': widgetType,
|
||||
'widget_id': widgetId,
|
||||
'state': 'disable',
|
||||
},
|
||||
}, (err, response, body) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (response.statusCode / 100 !== 2) {
|
||||
reject({statusCode: response.statusCode});
|
||||
} else if (!body) {
|
||||
reject(new Error("Failed to set widget assets state"));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getScalarInterfaceUrlForRoom(room, screen, id) {
|
||||
const roomId = room.roomId;
|
||||
const roomName = room.name;
|
||||
let url = SdkConfig.get().integrations_ui_url;
|
||||
url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
|
||||
url += "&room_id=" + encodeURIComponent(roomId);
|
||||
url += "&room_name=" + encodeURIComponent(roomName);
|
||||
url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme"));
|
||||
if (id) {
|
||||
url += '&integ_id=' + encodeURIComponent(id);
|
||||
|
|
|
@ -235,6 +235,7 @@ const SdkConfig = require('./SdkConfig');
|
|||
const MatrixClientPeg = require("./MatrixClientPeg");
|
||||
const MatrixEvent = require("matrix-js-sdk").MatrixEvent;
|
||||
const dis = require("./dispatcher");
|
||||
const Widgets = require('./utils/widgets');
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
function sendResponse(event, res) {
|
||||
|
@ -285,12 +286,58 @@ function inviteUser(event, roomId, userId) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when a widget with the given
|
||||
* ID has been added as a user widget (ie. the accountData event
|
||||
* arrives) or rejects after a timeout
|
||||
*
|
||||
* @param {string} widgetId The ID of the widget to wait for
|
||||
* @param {boolean} add True to wait for the widget to be added,
|
||||
* false to wait for it to be deleted.
|
||||
* @returns {Promise} that resolves when the widget is available
|
||||
*/
|
||||
function waitForUserWidget(widgetId, add) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const currentAccountDataEvent = MatrixClientPeg.get().getAccountData('m.widgets');
|
||||
|
||||
// Tests an account data event, returning true if it's in the state
|
||||
// we're waiting for it to be in
|
||||
function eventInIntendedState(ev) {
|
||||
if (!ev || !currentAccountDataEvent.getContent()) return false;
|
||||
if (add) {
|
||||
return ev.getContent()[widgetId] !== undefined;
|
||||
} else {
|
||||
return ev.getContent()[widgetId] === undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (eventInIntendedState(currentAccountDataEvent)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
function onAccountData(ev) {
|
||||
if (eventInIntendedState(currentAccountDataEvent)) {
|
||||
MatrixClientPeg.get().removeListener('accountData', onAccountData);
|
||||
clearTimeout(timerId);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
const timerId = setTimeout(() => {
|
||||
MatrixClientPeg.get().removeListener('accountData', onAccountData);
|
||||
reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear"));
|
||||
}, 10000);
|
||||
MatrixClientPeg.get().on('accountData', onAccountData);
|
||||
});
|
||||
}
|
||||
|
||||
function setWidget(event, roomId) {
|
||||
const widgetId = event.data.widget_id;
|
||||
const widgetType = event.data.type;
|
||||
const widgetUrl = event.data.url;
|
||||
const widgetName = event.data.name; // optional
|
||||
const widgetData = event.data.data; // optional
|
||||
const userWidget = event.data.userWidget;
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
|
@ -330,17 +377,64 @@ function setWidget(event, roomId) {
|
|||
name: widgetName,
|
||||
data: widgetData,
|
||||
};
|
||||
if (widgetUrl === null) { // widget is being deleted
|
||||
content = {};
|
||||
}
|
||||
|
||||
client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => {
|
||||
sendResponse(event, {
|
||||
success: true,
|
||||
if (userWidget) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const userWidgets = Widgets.getUserWidgets();
|
||||
|
||||
// Delete existing widget with ID
|
||||
try {
|
||||
delete userWidgets[widgetId];
|
||||
} catch (e) {
|
||||
console.error(`$widgetId is non-configurable`);
|
||||
}
|
||||
|
||||
// Add new widget / update
|
||||
if (widgetUrl !== null) {
|
||||
userWidgets[widgetId] = {
|
||||
content: content,
|
||||
sender: client.getUserId(),
|
||||
state_key: widgetId,
|
||||
type: 'm.widget',
|
||||
id: widgetId,
|
||||
};
|
||||
}
|
||||
|
||||
// This starts listening for when the echo comes back from the server
|
||||
// since the widget won't appear added until this happens. If we don't
|
||||
// wait for this, the action will complete but if the user is fast enough,
|
||||
// the widget still won't actually be there.
|
||||
client.setAccountData('m.widgets', userWidgets).then(() => {
|
||||
return waitForUserWidget(widgetId, widgetUrl !== null);
|
||||
}).then(() => {
|
||||
sendResponse(event, {
|
||||
success: true,
|
||||
});
|
||||
|
||||
dis.dispatch({ action: "user_widget_updated" });
|
||||
}).catch((e) => {
|
||||
sendError(event, _t('Unable to create widget.'), e);
|
||||
});
|
||||
}, (err) => {
|
||||
sendError(event, _t('Failed to send request.'), err);
|
||||
});
|
||||
} else { // Room widget
|
||||
if (!roomId) {
|
||||
sendError(event, _t('Missing roomId.'), null);
|
||||
}
|
||||
|
||||
if (widgetUrl === null) { // widget is being deleted
|
||||
content = {};
|
||||
}
|
||||
// TODO - Room widgets need to be moved to 'm.widget' state events
|
||||
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
|
||||
client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => {
|
||||
// XXX: We should probably wait for the echo of the state event to come back from the server,
|
||||
// as we do with user widgets.
|
||||
sendResponse(event, {
|
||||
success: true,
|
||||
});
|
||||
}, (err) => {
|
||||
sendError(event, _t('Failed to send request.'), err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgets(event, roomId) {
|
||||
|
@ -349,19 +443,30 @@ function getWidgets(event, roomId) {
|
|||
sendError(event, _t('You need to be logged in.'));
|
||||
return;
|
||||
}
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
sendError(event, _t('This room is not recognised.'));
|
||||
return;
|
||||
}
|
||||
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets");
|
||||
// Only return widgets which have required fields
|
||||
const widgetStateEvents = [];
|
||||
stateEvents.forEach((ev) => {
|
||||
if (ev.getContent().type && ev.getContent().url) {
|
||||
widgetStateEvents.push(ev.event); // return the raw event
|
||||
let widgetStateEvents = [];
|
||||
|
||||
if (roomId) {
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
sendError(event, _t('This room is not recognised.'));
|
||||
return;
|
||||
}
|
||||
});
|
||||
// TODO - Room widgets need to be moved to 'm.widget' state events
|
||||
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
|
||||
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets");
|
||||
// Only return widgets which have required fields
|
||||
if (room) {
|
||||
stateEvents.forEach((ev) => {
|
||||
if (ev.getContent().type && ev.getContent().url) {
|
||||
widgetStateEvents.push(ev.event); // return the raw event
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add user widgets (not linked to a specific room)
|
||||
const userWidgets = Widgets.getUserWidgetsArray();
|
||||
widgetStateEvents = widgetStateEvents.concat(userWidgets);
|
||||
|
||||
sendResponse(event, widgetStateEvents);
|
||||
}
|
||||
|
@ -563,7 +668,7 @@ const onMessage = function(event) {
|
|||
const url = SdkConfig.get().integrations_ui_url;
|
||||
if (
|
||||
event.origin.length === 0 ||
|
||||
!url.startsWith(event.origin) ||
|
||||
!url.startsWith(event.origin + '/') ||
|
||||
!event.data.action ||
|
||||
event.data.api // Ignore messages with specific API set
|
||||
) {
|
||||
|
@ -578,9 +683,22 @@ const onMessage = function(event) {
|
|||
|
||||
const roomId = event.data.room_id;
|
||||
const userId = event.data.user_id;
|
||||
|
||||
if (!roomId) {
|
||||
sendError(event, _t('Missing room_id in request'));
|
||||
return;
|
||||
// These APIs don't require roomId
|
||||
// Get and set user widgets (not associated with a specific room)
|
||||
// If roomId is specified, it must be validated, so room-based widgets agreed
|
||||
// handled further down.
|
||||
if (event.data.action === "get_widgets") {
|
||||
getWidgets(event, null);
|
||||
return;
|
||||
} else if (event.data.action === "set_widget") {
|
||||
setWidget(event, null);
|
||||
return;
|
||||
} else {
|
||||
sendError(event, _t('Missing room_id in request'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let promise = Promise.resolve(currentRoomId);
|
||||
if (!currentRoomId) {
|
||||
|
@ -601,6 +719,15 @@ const onMessage = function(event) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Get and set room-based widgets
|
||||
if (event.data.action === "get_widgets") {
|
||||
getWidgets(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "set_widget") {
|
||||
setWidget(event, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// These APIs don't require userId
|
||||
if (event.data.action === "join_rules_state") {
|
||||
getJoinRules(event, roomId);
|
||||
|
@ -611,12 +738,6 @@ const onMessage = function(event) {
|
|||
} else if (event.data.action === "get_membership_count") {
|
||||
getMembershipCount(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "set_widget") {
|
||||
setWidget(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "get_widgets") {
|
||||
getWidgets(event, roomId);
|
||||
return;
|
||||
} else if (event.data.action === "get_room_enc_state") {
|
||||
getRoomEncState(event, roomId);
|
||||
return;
|
||||
|
|
|
@ -21,13 +21,6 @@ const DEFAULTS = {
|
|||
integrations_rest_url: "https://scalar.vector.im/api",
|
||||
// Where to send bug reports. If not specified, bugs cannot be sent.
|
||||
bug_report_endpoint_url: null,
|
||||
|
||||
piwik: {
|
||||
url: "https://piwik.riot.im/",
|
||||
whitelistedHSUrls: ["https://matrix.org"],
|
||||
whitelistedISUrls: ["https://vector.im", "https://matrix.org"],
|
||||
siteId: 1,
|
||||
},
|
||||
};
|
||||
|
||||
class SdkConfig {
|
||||
|
@ -52,4 +45,3 @@ class SdkConfig {
|
|||
}
|
||||
|
||||
module.exports = SdkConfig;
|
||||
module.exports.DEFAULTS = DEFAULTS;
|
||||
|
|
|
@ -52,14 +52,13 @@ function textForMemberEvent(ev) {
|
|||
case 'join':
|
||||
if (prevContent && prevContent.membership === 'join') {
|
||||
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
|
||||
return _t('%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.', {
|
||||
senderName,
|
||||
return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', {
|
||||
oldDisplayName: prevContent.displayname,
|
||||
displayName: content.displayname,
|
||||
});
|
||||
} else if (!prevContent.displayname && content.displayname) {
|
||||
return _t('%(senderName)s set their display name to %(displayName)s.', {
|
||||
senderName,
|
||||
senderName: ev.getSender(),
|
||||
displayName: content.displayname,
|
||||
});
|
||||
} else if (prevContent.displayname && !content.displayname) {
|
||||
|
|
|
@ -252,7 +252,6 @@ class Tinter {
|
|||
|
||||
|
||||
setTheme(theme) {
|
||||
console.trace("setTheme " + theme);
|
||||
this.theme = theme;
|
||||
|
||||
// update keyRgb from the current theme CSS itself, if it defines it
|
||||
|
@ -299,56 +298,66 @@ class Tinter {
|
|||
|
||||
for (let i = 0; i < document.styleSheets.length; i++) {
|
||||
const ss = document.styleSheets[i];
|
||||
if (!ss) continue; // well done safari >:(
|
||||
// Chromium apparently sometimes returns null here; unsure why.
|
||||
// see $14534907369972FRXBx:matrix.org in HQ
|
||||
// ...ah, it's because there's a third party extension like
|
||||
// privacybadger inserting its own stylesheet in there with a
|
||||
// resource:// URI or something which results in a XSS error.
|
||||
// See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
|
||||
// ...except some browsers apparently return stylesheets without
|
||||
// hrefs, which we have no choice but ignore right now
|
||||
try {
|
||||
if (!ss) continue; // well done safari >:(
|
||||
// Chromium apparently sometimes returns null here; unsure why.
|
||||
// see $14534907369972FRXBx:matrix.org in HQ
|
||||
// ...ah, it's because there's a third party extension like
|
||||
// privacybadger inserting its own stylesheet in there with a
|
||||
// resource:// URI or something which results in a XSS error.
|
||||
// See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
|
||||
// ...except some browsers apparently return stylesheets without
|
||||
// hrefs, which we have no choice but ignore right now
|
||||
|
||||
// XXX seriously? we are hardcoding the name of vector's CSS file in
|
||||
// here?
|
||||
//
|
||||
// Why do we need to limit it to vector's CSS file anyway - if there
|
||||
// are other CSS files affecting the doc don't we want to apply the
|
||||
// same transformations to them?
|
||||
//
|
||||
// Iterating through the CSS looking for matches to hack on feels
|
||||
// pretty horrible anyway. And what if the application skin doesn't use
|
||||
// Vector Green as its primary color?
|
||||
// --richvdh
|
||||
// XXX seriously? we are hardcoding the name of vector's CSS file in
|
||||
// here?
|
||||
//
|
||||
// Why do we need to limit it to vector's CSS file anyway - if there
|
||||
// are other CSS files affecting the doc don't we want to apply the
|
||||
// same transformations to them?
|
||||
//
|
||||
// Iterating through the CSS looking for matches to hack on feels
|
||||
// pretty horrible anyway. And what if the application skin doesn't use
|
||||
// Vector Green as its primary color?
|
||||
// --richvdh
|
||||
|
||||
// Yes, tinting assumes that you are using the Riot skin for now.
|
||||
// The right solution will be to move the CSS over to react-sdk.
|
||||
// And yes, the default assets for the base skin might as well use
|
||||
// Vector Green as any other colour.
|
||||
// --matthew
|
||||
// Yes, tinting assumes that you are using the Riot skin for now.
|
||||
// The right solution will be to move the CSS over to react-sdk.
|
||||
// And yes, the default assets for the base skin might as well use
|
||||
// Vector Green as any other colour.
|
||||
// --matthew
|
||||
|
||||
if (ss.href && !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
|
||||
if (ss.disabled) continue;
|
||||
if (!ss.cssRules) continue;
|
||||
// stylesheets we don't have permission to access (eg. ones from extensions) have a null
|
||||
// href and will throw exceptions if we try to access their rules.
|
||||
if (!ss.href || !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
|
||||
if (ss.disabled) continue;
|
||||
if (!ss.cssRules) continue;
|
||||
|
||||
if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href);
|
||||
if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href);
|
||||
|
||||
for (let j = 0; j < ss.cssRules.length; j++) {
|
||||
const rule = ss.cssRules[j];
|
||||
if (!rule.style) continue;
|
||||
if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue;
|
||||
for (let k = 0; k < this.cssAttrs.length; k++) {
|
||||
const attr = this.cssAttrs[k];
|
||||
for (let l = 0; l < this.keyRgb.length; l++) {
|
||||
if (rule.style[attr] === this.keyRgb[l]) {
|
||||
this.cssFixups[this.theme].push({
|
||||
style: rule.style,
|
||||
attr: attr,
|
||||
index: l,
|
||||
});
|
||||
for (let j = 0; j < ss.cssRules.length; j++) {
|
||||
const rule = ss.cssRules[j];
|
||||
if (!rule.style) continue;
|
||||
if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue;
|
||||
for (let k = 0; k < this.cssAttrs.length; k++) {
|
||||
const attr = this.cssAttrs[k];
|
||||
for (let l = 0; l < this.keyRgb.length; l++) {
|
||||
if (rule.style[attr] === this.keyRgb[l]) {
|
||||
this.cssFixups[this.theme].push({
|
||||
style: rule.style,
|
||||
attr: attr,
|
||||
index: l,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Catch any random exceptions that happen here: all sorts of things can go
|
||||
// wrong with this (nulls, SecurityErrors) and mostly it's for other
|
||||
// stylesheets that we don't want to proces anyway. We should not propagate an
|
||||
// exception out since this will cause the app to fail to start.
|
||||
console.log("Failed to calculate CSS fixups for a stylesheet: " + ss.href, e);
|
||||
}
|
||||
}
|
||||
if (DEBUG) {
|
||||
|
|
86
src/ToWidgetPostMessageApi.js
Normal file
86
src/ToWidgetPostMessageApi.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import Promise from "bluebird";
|
||||
|
||||
// const OUTBOUND_API_NAME = 'toWidget';
|
||||
|
||||
// Initiate requests using the "toWidget" postMessage API and handle responses
|
||||
// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a
|
||||
// response field
|
||||
export default class ToWidgetPostMessageApi {
|
||||
constructor(timeoutMs) {
|
||||
this._timeoutMs = timeoutMs || 5000; // default to 5s timer
|
||||
this._counter = 0;
|
||||
this._requestMap = {
|
||||
// $ID: {resolve, reject}
|
||||
};
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.onPostMessage = this.onPostMessage.bind(this);
|
||||
}
|
||||
|
||||
start() {
|
||||
window.addEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
stop() {
|
||||
window.removeEventListener('message', this.onPostMessage);
|
||||
}
|
||||
|
||||
onPostMessage(ev) {
|
||||
// THIS IS ALL UNSAFE EXECUTION.
|
||||
// We do not verify who the sender of `ev` is!
|
||||
const payload = ev.data;
|
||||
// NOTE: Workaround for running in a mobile WebView where a
|
||||
// postMessage immediately triggers this callback even though it is
|
||||
// not the response.
|
||||
if (payload.response === undefined) {
|
||||
return;
|
||||
}
|
||||
const promise = this._requestMap[payload.requestId];
|
||||
if (!promise) {
|
||||
return;
|
||||
}
|
||||
delete this._requestMap[payload.requestId];
|
||||
promise.resolve(payload);
|
||||
}
|
||||
|
||||
// Initiate outbound requests (toWidget)
|
||||
exec(action, targetWindow, targetOrigin) {
|
||||
targetWindow = targetWindow || window.parent; // default to parent window
|
||||
targetOrigin = targetOrigin || "*";
|
||||
this._counter += 1;
|
||||
action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._requestMap[action.requestId] = {resolve, reject};
|
||||
targetWindow.postMessage(action, targetOrigin);
|
||||
|
||||
if (this._timeoutMs > 0) {
|
||||
setTimeout(() => {
|
||||
if (!this._requestMap[action.requestId]) {
|
||||
return;
|
||||
}
|
||||
console.error("postMessage request timed out. Sent object: " + JSON.stringify(action),
|
||||
this._requestMap);
|
||||
this._requestMap[action.requestId].reject(new Error("Timed out"));
|
||||
delete this._requestMap[action.requestId];
|
||||
}, this._timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -28,6 +28,8 @@ module.exports = {
|
|||
return false;
|
||||
} else if (ev.getType() == 'm.room.member') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.room.third_party_invite') {
|
||||
return false;
|
||||
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
|
||||
return false;
|
||||
} else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {
|
||||
|
|
138
src/VectorConferenceHandler.js
Normal file
138
src/VectorConferenceHandler.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import Promise from 'bluebird';
|
||||
var Matrix = require("matrix-js-sdk");
|
||||
var Room = Matrix.Room;
|
||||
var CallHandler = require('./CallHandler');
|
||||
|
||||
// FIXME: this is Riot (Vector) specific code, but will be removed shortly when
|
||||
// we switch over to jitsi entirely for video conferencing.
|
||||
|
||||
// FIXME: This currently forces Vector to try to hit the matrix.org AS for conferencing.
|
||||
// This is bad because it prevents people running their own ASes from being used.
|
||||
// This isn't permanent and will be customisable in the future: see the proposal
|
||||
// at docs/conferencing.md for more info.
|
||||
var USER_PREFIX = "fs_";
|
||||
var DOMAIN = "matrix.org";
|
||||
|
||||
function ConferenceCall(matrixClient, groupChatRoomId) {
|
||||
this.client = matrixClient;
|
||||
this.groupRoomId = groupChatRoomId;
|
||||
this.confUserId = module.exports.getConferenceUserIdForRoom(this.groupRoomId);
|
||||
}
|
||||
|
||||
ConferenceCall.prototype.setup = function() {
|
||||
var self = this;
|
||||
return this._joinConferenceUser().then(function() {
|
||||
return self._getConferenceUserRoom();
|
||||
}).then(function(room) {
|
||||
// return a call for *this* room to be placed. We also tack on
|
||||
// confUserId to speed up lookups (else we'd need to loop every room
|
||||
// looking for a 1:1 room with this conf user ID!)
|
||||
var call = Matrix.createNewMatrixCall(self.client, room.roomId);
|
||||
call.confUserId = self.confUserId;
|
||||
call.groupRoomId = self.groupRoomId;
|
||||
return call;
|
||||
});
|
||||
};
|
||||
|
||||
ConferenceCall.prototype._joinConferenceUser = function() {
|
||||
// Make sure the conference user is in the group chat room
|
||||
var groupRoom = this.client.getRoom(this.groupRoomId);
|
||||
if (!groupRoom) {
|
||||
return Promise.reject("Bad group room ID");
|
||||
}
|
||||
var member = groupRoom.getMember(this.confUserId);
|
||||
if (member && member.membership === "join") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.client.invite(this.groupRoomId, this.confUserId);
|
||||
};
|
||||
|
||||
ConferenceCall.prototype._getConferenceUserRoom = function() {
|
||||
// Use an existing 1:1 with the conference user; else make one
|
||||
var rooms = this.client.getRooms();
|
||||
var confRoom = null;
|
||||
for (var i = 0; i < rooms.length; i++) {
|
||||
var confUser = rooms[i].getMember(this.confUserId);
|
||||
if (confUser && confUser.membership === "join" &&
|
||||
rooms[i].getJoinedMembers().length === 2) {
|
||||
confRoom = rooms[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (confRoom) {
|
||||
return Promise.resolve(confRoom);
|
||||
}
|
||||
return this.client.createRoom({
|
||||
preset: "private_chat",
|
||||
invite: [this.confUserId]
|
||||
}).then(function(res) {
|
||||
return new Room(res.room_id);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if this user ID is in fact a conference bot.
|
||||
* @param {string} userId The user ID to check.
|
||||
* @return {boolean} True if it is a conference bot.
|
||||
*/
|
||||
module.exports.isConferenceUser = function(userId) {
|
||||
if (userId.indexOf("@" + USER_PREFIX) !== 0) {
|
||||
return false;
|
||||
}
|
||||
var base64part = userId.split(":")[0].substring(1 + USER_PREFIX.length);
|
||||
if (base64part) {
|
||||
var decoded = new Buffer(base64part, "base64").toString();
|
||||
// ! $STUFF : $STUFF
|
||||
return /^!.+:.+/.test(decoded);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
module.exports.getConferenceUserIdForRoom = function(roomId) {
|
||||
// abuse browserify's core node Buffer support (strip padding ='s)
|
||||
var base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, "");
|
||||
return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN;
|
||||
};
|
||||
|
||||
module.exports.createNewMatrixCall = function(client, roomId) {
|
||||
var confCall = new ConferenceCall(
|
||||
client, roomId
|
||||
);
|
||||
return confCall.setup();
|
||||
};
|
||||
|
||||
module.exports.getConferenceCallForRoom = function(roomId) {
|
||||
// search for a conference 1:1 call for this group chat room ID
|
||||
var activeCall = CallHandler.getAnyActiveCall();
|
||||
if (activeCall && activeCall.confUserId) {
|
||||
var thisRoomConfUserId = module.exports.getConferenceUserIdForRoom(
|
||||
roomId
|
||||
);
|
||||
if (thisRoomConfUserId === activeCall.confUserId) {
|
||||
return activeCall;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports.ConferenceCall = ConferenceCall;
|
||||
|
||||
module.exports.slot = 'conference';
|
|
@ -147,7 +147,7 @@ module.exports = React.createClass({
|
|||
// creating/destroying large numbers of elements"
|
||||
// (https://github.com/julianshapiro/velocity/issues/47)
|
||||
const domNode = ReactDom.findDOMNode(this.nodes[k]);
|
||||
Velocity.Utilities.removeData(domNode);
|
||||
if (domNode) Velocity.Utilities.removeData(domNode);
|
||||
}
|
||||
this.nodes[k] = node;
|
||||
},
|
||||
|
|
|
@ -15,312 +15,103 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
/*
|
||||
Listens for incoming postMessage requests from embedded widgets. The following API is exposed:
|
||||
{
|
||||
api: "widget",
|
||||
action: "content_loaded",
|
||||
widgetId: $WIDGET_ID,
|
||||
data: {}
|
||||
// additional request fields
|
||||
}
|
||||
|
||||
The complete request object is returned to the caller with an additional "response" key like so:
|
||||
{
|
||||
api: "widget",
|
||||
action: "content_loaded",
|
||||
widgetId: $WIDGET_ID,
|
||||
data: {},
|
||||
// additional request fields
|
||||
response: { ... }
|
||||
}
|
||||
|
||||
The "api" field is required to use this API, and must be set to "widget" in all requests.
|
||||
|
||||
The "action" determines the format of the request and response. All actions can return an error response.
|
||||
|
||||
Additional data can be sent as additional, abritrary fields. However, typically the data object should be used.
|
||||
|
||||
A success response is an object with zero or more keys.
|
||||
|
||||
An error response is a "response" object which consists of a sole "error" key to indicate an error.
|
||||
They look like:
|
||||
{
|
||||
error: {
|
||||
message: "Unable to invite user into room.",
|
||||
_error: <Original Error Object>
|
||||
}
|
||||
}
|
||||
The "message" key should be a human-friendly string.
|
||||
|
||||
ACTIONS
|
||||
=======
|
||||
** All actions must include an "api" field with valie "widget".**
|
||||
All actions can return an error response instead of the response outlined below.
|
||||
|
||||
content_loaded
|
||||
--------------
|
||||
Indicates that widget contet has fully loaded
|
||||
|
||||
Request:
|
||||
- widgetId is the unique ID of the widget instance in riot / matrix state.
|
||||
- No additional fields.
|
||||
Response:
|
||||
{
|
||||
success: true
|
||||
}
|
||||
Example:
|
||||
{
|
||||
api: "widget",
|
||||
action: "content_loaded",
|
||||
widgetId: $WIDGET_ID
|
||||
}
|
||||
|
||||
|
||||
api_version
|
||||
-----------
|
||||
Get the current version of the widget postMessage API
|
||||
|
||||
Request:
|
||||
- No additional fields.
|
||||
Response:
|
||||
{
|
||||
api_version: "0.0.1"
|
||||
}
|
||||
Example:
|
||||
{
|
||||
api: "widget",
|
||||
action: "api_version",
|
||||
}
|
||||
|
||||
supported_api_versions
|
||||
----------------------
|
||||
Get versions of the widget postMessage API that are currently supported
|
||||
|
||||
Request:
|
||||
- No additional fields.
|
||||
Response:
|
||||
{
|
||||
api: "widget"
|
||||
supported_versions: ["0.0.1"]
|
||||
}
|
||||
Example:
|
||||
{
|
||||
api: "widget",
|
||||
action: "supported_api_versions",
|
||||
}
|
||||
|
||||
* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for
|
||||
* spec. details / documentation.
|
||||
*/
|
||||
|
||||
import URL from 'url';
|
||||
import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
|
||||
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
|
||||
|
||||
const WIDGET_API_VERSION = '0.0.1'; // Current API version
|
||||
const SUPPORTED_WIDGET_API_VERSIONS = [
|
||||
'0.0.1',
|
||||
];
|
||||
|
||||
import dis from './dispatcher';
|
||||
|
||||
if (!global.mxWidgetMessagingListenerCount) {
|
||||
global.mxWidgetMessagingListenerCount = 0;
|
||||
if (!global.mxFromWidgetMessaging) {
|
||||
global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
|
||||
global.mxFromWidgetMessaging.start();
|
||||
}
|
||||
if (!global.mxWidgetMessagingMessageEndpoints) {
|
||||
global.mxWidgetMessagingMessageEndpoints = [];
|
||||
if (!global.mxToWidgetMessaging) {
|
||||
global.mxToWidgetMessaging = new ToWidgetPostMessageApi();
|
||||
global.mxToWidgetMessaging.start();
|
||||
}
|
||||
|
||||
const OUTBOUND_API_NAME = 'toWidget';
|
||||
|
||||
/**
|
||||
* Register widget message event listeners
|
||||
*/
|
||||
function startListening() {
|
||||
if (global.mxWidgetMessagingListenerCount === 0) {
|
||||
window.addEventListener("message", onMessage, false);
|
||||
}
|
||||
global.mxWidgetMessagingListenerCount += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* De-register widget message event listeners
|
||||
*/
|
||||
function stopListening() {
|
||||
global.mxWidgetMessagingListenerCount -= 1;
|
||||
if (global.mxWidgetMessagingListenerCount === 0) {
|
||||
window.removeEventListener("message", onMessage);
|
||||
}
|
||||
if (global.mxWidgetMessagingListenerCount < 0) {
|
||||
// Make an error so we get a stack trace
|
||||
const e = new Error(
|
||||
"WidgetMessaging: mismatched startListening / stopListening detected." +
|
||||
" Negative count",
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a widget endpoint for trusted postMessage communication
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
*/
|
||||
function addEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn("Invalid origin:", endpointUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
const endpoint = new WidgetMessageEndpoint(widgetId, origin);
|
||||
if (global.mxWidgetMessagingMessageEndpoints) {
|
||||
if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) {
|
||||
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
|
||||
})) {
|
||||
// Message endpoint already registered
|
||||
console.warn("Endpoint already registered");
|
||||
return;
|
||||
}
|
||||
global.mxWidgetMessagingMessageEndpoints.push(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* De-register a widget endpoint from trusted communication sources
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||
* @return {boolean} True if endpoint was successfully removed
|
||||
*/
|
||||
function removeEndpoint(widgetId, endpointUrl) {
|
||||
const u = URL.parse(endpointUrl);
|
||||
if (!u || !u.protocol || !u.host) {
|
||||
console.warn("Invalid origin");
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = u.protocol + '//' + u.host;
|
||||
if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) {
|
||||
const length = global.mxWidgetMessagingMessageEndpoints.length;
|
||||
global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints.filter(function(endpoint) {
|
||||
return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
|
||||
});
|
||||
return (length > global.mxWidgetMessagingMessageEndpoints.length);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle widget postMessage events
|
||||
* @param {Event} event Event to handle
|
||||
* @return {undefined}
|
||||
*/
|
||||
function onMessage(event) {
|
||||
if (!event.origin) { // Handle chrome
|
||||
event.origin = event.originalEvent.origin;
|
||||
}
|
||||
|
||||
// Event origin is empty string if undefined
|
||||
if (
|
||||
event.origin.length === 0 ||
|
||||
!trustedEndpoint(event.origin) ||
|
||||
event.data.api !== "widget" ||
|
||||
!event.data.widgetId
|
||||
) {
|
||||
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
|
||||
}
|
||||
|
||||
const action = event.data.action;
|
||||
const widgetId = event.data.widgetId;
|
||||
if (action === 'content_loaded') {
|
||||
dis.dispatch({
|
||||
action: 'widget_content_loaded',
|
||||
widgetId: widgetId,
|
||||
});
|
||||
sendResponse(event, {success: true});
|
||||
} else if (action === 'supported_api_versions') {
|
||||
sendResponse(event, {
|
||||
api: "widget",
|
||||
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
|
||||
});
|
||||
} else if (action === 'api_version') {
|
||||
sendResponse(event, {
|
||||
api: "widget",
|
||||
version: WIDGET_API_VERSION,
|
||||
});
|
||||
} else {
|
||||
console.warn("Widget postMessage event unhandled");
|
||||
sendError(event, {message: "The postMessage was unhandled"});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message origin is registered as trusted
|
||||
* @param {string} origin PostMessage origin to check
|
||||
* @return {boolean} True if trusted
|
||||
*/
|
||||
function trustedEndpoint(origin) {
|
||||
if (!origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => {
|
||||
return endpoint.endpointUrl === origin;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a postmessage response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {Object} res Response data
|
||||
*/
|
||||
function sendResponse(event, res) {
|
||||
const data = JSON.parse(JSON.stringify(event.data));
|
||||
data.response = res;
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response to a postMessage request
|
||||
* @param {Event} event The original postMessage request event
|
||||
* @param {string} msg Error message
|
||||
* @param {Error} nestedError Nested error event (optional)
|
||||
*/
|
||||
function sendError(event, msg, nestedError) {
|
||||
console.error("Action:" + event.data.action + " failed with message: " + msg);
|
||||
const data = JSON.parse(JSON.stringify(event.data));
|
||||
data.response = {
|
||||
error: {
|
||||
message: msg,
|
||||
},
|
||||
};
|
||||
if (nestedError) {
|
||||
data.response.error._error = nestedError;
|
||||
}
|
||||
event.source.postMessage(data, event.origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents mapping of widget instance to URLs for trusted postMessage communication.
|
||||
*/
|
||||
class WidgetMessageEndpoint {
|
||||
/**
|
||||
* Mapping of widget instance to URL for trusted postMessage communication.
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin.
|
||||
*/
|
||||
constructor(widgetId, endpointUrl) {
|
||||
if (!widgetId) {
|
||||
throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
if (!endpointUrl) {
|
||||
throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
export default class WidgetMessaging {
|
||||
constructor(widgetId, widgetUrl, target) {
|
||||
this.widgetId = widgetId;
|
||||
this.endpointUrl = endpointUrl;
|
||||
this.widgetUrl = widgetUrl;
|
||||
this.target = target;
|
||||
this.fromWidget = global.mxFromWidgetMessaging;
|
||||
this.toWidget = global.mxToWidgetMessaging;
|
||||
this.start();
|
||||
}
|
||||
|
||||
messageToWidget(action) {
|
||||
action.widgetId = this.widgetId; // Required to be sent for all outbound requests
|
||||
|
||||
return this.toWidget.exec(action, this.target).then((data) => {
|
||||
// Check for errors and reject if found
|
||||
if (data.response === undefined) { // null is valid
|
||||
throw new Error("Missing 'response' field");
|
||||
}
|
||||
if (data.response && data.response.error) {
|
||||
const err = data.response.error;
|
||||
const msg = String(err.message ? err.message : "An error was returned");
|
||||
if (err._error) {
|
||||
console.error(err._error);
|
||||
}
|
||||
// Potential XSS attack if 'msg' is not appropriately sanitized,
|
||||
// as it is untrusted input by our parent window (which we assume is Riot).
|
||||
// We can't aggressively sanitize [A-z0-9] since it might be a translation.
|
||||
throw new Error(msg);
|
||||
}
|
||||
// Return the response field for the request
|
||||
return data.response;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a screenshot from a widget
|
||||
* @return {Promise} To be resolved with screenshot data when it has been generated
|
||||
*/
|
||||
getScreenshot() {
|
||||
console.warn('Requesting screenshot for', this.widgetId);
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "screenshot",
|
||||
})
|
||||
.catch((error) => new Error("Failed to get screenshot: " + error.message))
|
||||
.then((response) => response.screenshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request capabilities required by the widget
|
||||
* @return {Promise} To be resolved with an array of requested widget capabilities
|
||||
*/
|
||||
getCapabilities() {
|
||||
console.warn('Requesting capabilities for', this.widgetId);
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "capabilities",
|
||||
}).then((response) => {
|
||||
console.warn('Got capabilities for', this.widgetId, response.capabilities);
|
||||
return response.capabilities;
|
||||
});
|
||||
}
|
||||
|
||||
sendVisibility(visible) {
|
||||
return this.messageToWidget({
|
||||
api: OUTBOUND_API_NAME,
|
||||
action: "visibility",
|
||||
visible,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to send visibility: ", error);
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
startListening: startListening,
|
||||
stopListening: stopListening,
|
||||
addEndpoint: addEndpoint,
|
||||
removeEndpoint: removeEndpoint,
|
||||
};
|
||||
|
|
37
src/WidgetMessagingEndpoint.js
Normal file
37
src/WidgetMessagingEndpoint.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Represents mapping of widget instance to URLs for trusted postMessage communication.
|
||||
*/
|
||||
export default class WidgetMessageEndpoint {
|
||||
/**
|
||||
* Mapping of widget instance to URL for trusted postMessage communication.
|
||||
* @param {string} widgetId Unique widget identifier
|
||||
* @param {string} endpointUrl Widget wurl origin.
|
||||
*/
|
||||
constructor(widgetId, endpointUrl) {
|
||||
if (!widgetId) {
|
||||
throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
if (!endpointUrl) {
|
||||
throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
|
||||
}
|
||||
this.widgetId = widgetId;
|
||||
this.endpointUrl = endpointUrl;
|
||||
}
|
||||
}
|
|
@ -17,8 +17,8 @@ limitations under the License.
|
|||
import MatrixClientPeg from './MatrixClientPeg';
|
||||
|
||||
export default class WidgetUtils {
|
||||
|
||||
/* Returns true if user is able to send state events to modify widgets in this room
|
||||
* (Does not apply to non-room-based / user widgets)
|
||||
* @param roomId -- The ID of the room to check
|
||||
* @return Boolean -- true if the user can modify widgets in this room
|
||||
* @throws Error -- specifies the error reason
|
||||
|
|
|
@ -62,6 +62,127 @@ function createAccountDataAction(matrixClient, accountDataEvent) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef RoomAction
|
||||
* @type {Object}
|
||||
* @property {string} action 'MatrixActions.Room'.
|
||||
* @property {Room} room the Room that was stored.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a MatrixActions.Room action that represents a MatrixClient `Room`
|
||||
* matrix event, emitted when a Room is stored in the client.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client.
|
||||
* @param {Room} room the Room that was stored.
|
||||
* @returns {RoomAction} an action of type `MatrixActions.Room`.
|
||||
*/
|
||||
function createRoomAction(matrixClient, room) {
|
||||
return { action: 'MatrixActions.Room', room };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef RoomTagsAction
|
||||
* @type {Object}
|
||||
* @property {string} action 'MatrixActions.Room.tags'.
|
||||
* @property {Room} room the Room whose tags changed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a MatrixActions.Room.tags action that represents a MatrixClient
|
||||
* `Room.tags` matrix event, emitted when the m.tag room account data
|
||||
* event is updated.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client.
|
||||
* @param {MatrixEvent} roomTagsEvent the m.tag event.
|
||||
* @param {Room} room the Room whose tags were changed.
|
||||
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
|
||||
*/
|
||||
function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
|
||||
return { action: 'MatrixActions.Room.tags', room };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef RoomTimelineAction
|
||||
* @type {Object}
|
||||
* @property {string} action 'MatrixActions.Room.timeline'.
|
||||
* @property {boolean} isLiveEvent whether the event was attached to a
|
||||
* live timeline.
|
||||
* @property {boolean} isLiveUnfilteredRoomTimelineEvent whether the
|
||||
* event was attached to a timeline in the set of unfiltered timelines.
|
||||
* @property {Room} room the Room whose tags changed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a MatrixActions.Room.timeline action that represents a
|
||||
* MatrixClient `Room.timeline` matrix event, emitted when an event
|
||||
* is added to or removed from a timeline of a room.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client.
|
||||
* @param {MatrixEvent} timelineEvent the event that was added/removed.
|
||||
* @param {Room} room the Room that was stored.
|
||||
* @param {boolean} toStartOfTimeline whether the event is being added
|
||||
* to the start (and not the end) of the timeline.
|
||||
* @param {boolean} removed whether the event was removed from the
|
||||
* timeline.
|
||||
* @param {Object} data
|
||||
* @param {boolean} data.liveEvent whether the event is a live event,
|
||||
* belonging to a live timeline.
|
||||
* @param {EventTimeline} data.timeline the timeline being altered.
|
||||
* @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`.
|
||||
*/
|
||||
function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) {
|
||||
return {
|
||||
action: 'MatrixActions.Room.timeline',
|
||||
event: timelineEvent,
|
||||
isLiveEvent: data.liveEvent,
|
||||
isLiveUnfilteredRoomTimelineEvent:
|
||||
room && data.timeline.getTimelineSet() === room.getUnfilteredTimelineSet(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef RoomMembershipAction
|
||||
* @type {Object}
|
||||
* @property {string} action 'MatrixActions.RoomMember.membership'.
|
||||
* @property {RoomMember} member the member whose membership was updated.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a MatrixActions.RoomMember.membership action that represents
|
||||
* a MatrixClient `RoomMember.membership` matrix event, emitted when a
|
||||
* member's membership is updated.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client.
|
||||
* @param {MatrixEvent} membershipEvent the m.room.member event.
|
||||
* @param {RoomMember} member the member whose membership was updated.
|
||||
* @param {string} oldMembership the member's previous membership.
|
||||
* @returns {RoomMembershipAction} an action of type `MatrixActions.RoomMember.membership`.
|
||||
*/
|
||||
function createRoomMembershipAction(matrixClient, membershipEvent, member, oldMembership) {
|
||||
return { action: 'MatrixActions.RoomMember.membership', member };
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef EventDecryptedAction
|
||||
* @type {Object}
|
||||
* @property {string} action 'MatrixActions.Event.decrypted'.
|
||||
* @property {MatrixEvent} event the matrix event that was decrypted.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a MatrixActions.Event.decrypted action that represents
|
||||
* a MatrixClient `Event.decrypted` matrix event, emitted when a
|
||||
* matrix event is decrypted.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client.
|
||||
* @param {MatrixEvent} event the matrix event that was decrypted.
|
||||
* @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`.
|
||||
*/
|
||||
function createEventDecryptedAction(matrixClient, event) {
|
||||
return { action: 'MatrixActions.Event.decrypted', event };
|
||||
}
|
||||
|
||||
/**
|
||||
* This object is responsible for dispatching actions when certain events are emitted by
|
||||
* the given MatrixClient.
|
||||
|
@ -78,6 +199,11 @@ export default {
|
|||
start(matrixClient) {
|
||||
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
|
||||
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
|
||||
this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction);
|
||||
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -91,7 +217,7 @@ export default {
|
|||
*/
|
||||
_addMatrixClientListener(matrixClient, eventName, actionCreator) {
|
||||
const listener = (...args) => {
|
||||
dis.dispatch(actionCreator(matrixClient, ...args));
|
||||
dis.dispatch(actionCreator(matrixClient, ...args), true);
|
||||
};
|
||||
matrixClient.on(eventName, listener);
|
||||
this._matrixClientListenersStop.push(() => {
|
||||
|
|
146
src/actions/RoomListActions.js
Normal file
146
src/actions/RoomListActions.js
Normal file
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { asyncAction } from './actionCreators';
|
||||
import RoomListStore from '../stores/RoomListStore';
|
||||
|
||||
import Modal from '../Modal';
|
||||
import * as Rooms from '../Rooms';
|
||||
import { _t } from '../languageHandler';
|
||||
import sdk from '../index';
|
||||
|
||||
const RoomListActions = {};
|
||||
|
||||
/**
|
||||
* Creates an action thunk that will do an asynchronous request to
|
||||
* tag room.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client to set the
|
||||
* account data on.
|
||||
* @param {Room} room the room to tag.
|
||||
* @param {string} oldTag the tag to remove (unless oldTag ==== newTag)
|
||||
* @param {string} newTag the tag with which to tag the room.
|
||||
* @param {?number} oldIndex the previous position of the room in the
|
||||
* list of rooms.
|
||||
* @param {?number} newIndex the new position of the room in the list
|
||||
* of rooms.
|
||||
* @returns {function} an action thunk.
|
||||
* @see asyncAction
|
||||
*/
|
||||
RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, newIndex) {
|
||||
let metaData = null;
|
||||
|
||||
// Is the tag ordered manually?
|
||||
if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
|
||||
const lists = RoomListStore.getRoomLists();
|
||||
const newList = [...lists[newTag]];
|
||||
|
||||
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
|
||||
|
||||
// If the room was moved "down" (increasing index) in the same list we
|
||||
// need to use the orders of the tiles with indices shifted by +1
|
||||
const offset = (
|
||||
newTag === oldTag && oldIndex < newIndex
|
||||
) ? 1 : 0;
|
||||
|
||||
const indexBefore = offset + newIndex - 1;
|
||||
const indexAfter = offset + newIndex;
|
||||
|
||||
const prevOrder = indexBefore <= 0 ?
|
||||
0 : newList[indexBefore].tags[newTag].order;
|
||||
const nextOrder = indexAfter >= newList.length ?
|
||||
1 : newList[indexAfter].tags[newTag].order;
|
||||
|
||||
metaData = {
|
||||
order: (prevOrder + nextOrder) / 2.0,
|
||||
};
|
||||
}
|
||||
|
||||
return asyncAction('RoomListActions.tagRoom', () => {
|
||||
const promises = [];
|
||||
const roomId = room.roomId;
|
||||
|
||||
// Evil hack to get DMs behaving
|
||||
if ((oldTag === undefined && newTag === 'im.vector.fake.direct') ||
|
||||
(oldTag === 'im.vector.fake.direct' && newTag === undefined)
|
||||
) {
|
||||
return Rooms.guessAndSetDMRoom(
|
||||
room, newTag === 'im.vector.fake.direct',
|
||||
).catch((err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to set direct chat tag " + err);
|
||||
Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, {
|
||||
title: _t('Failed to set direct chat tag'),
|
||||
description: ((err && err.message) ? err.message : _t('Operation failed')),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const hasChangedSubLists = oldTag !== newTag;
|
||||
|
||||
// More evilness: We will still be dealing with moving to favourites/low prio,
|
||||
// but we avoid ever doing a request with 'im.vector.fake.direct`.
|
||||
//
|
||||
// if we moved lists, remove the old tag
|
||||
if (oldTag && oldTag !== 'im.vector.fake.direct' &&
|
||||
hasChangedSubLists
|
||||
) {
|
||||
const promiseToDelete = matrixClient.deleteRoomTag(
|
||||
roomId, oldTag,
|
||||
).catch(function(err) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to remove tag " + oldTag + " from room: " + err);
|
||||
Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, {
|
||||
title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}),
|
||||
description: ((err && err.message) ? err.message : _t('Operation failed')),
|
||||
});
|
||||
});
|
||||
|
||||
promises.push(promiseToDelete);
|
||||
}
|
||||
|
||||
// if we moved lists or the ordering changed, add the new tag
|
||||
if (newTag && newTag !== 'im.vector.fake.direct' &&
|
||||
(hasChangedSubLists || metaData)
|
||||
) {
|
||||
// metaData is the body of the PUT to set the tag, so it must
|
||||
// at least be an empty object.
|
||||
metaData = metaData || {};
|
||||
|
||||
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to add tag " + newTag + " to room: " + err);
|
||||
Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, {
|
||||
title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}),
|
||||
description: ((err && err.message) ? err.message : _t('Operation failed')),
|
||||
});
|
||||
|
||||
throw err;
|
||||
});
|
||||
|
||||
promises.push(promiseToAdd);
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}, () => {
|
||||
// For an optimistic update
|
||||
return {
|
||||
room, oldTag, newTag, metaData,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default RoomListActions;
|
|
@ -22,25 +22,87 @@ const TagOrderActions = {};
|
|||
|
||||
/**
|
||||
* Creates an action thunk that will do an asynchronous request to
|
||||
* commit TagOrderStore.getOrderedTags() to account data and dispatch
|
||||
* actions to indicate the status of the request.
|
||||
* move a tag in TagOrderStore to destinationIx.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client to set the
|
||||
* account data on.
|
||||
* @param {string} tag the tag to move.
|
||||
* @param {number} destinationIx the new position of the tag.
|
||||
* @returns {function} an action thunk that will dispatch actions
|
||||
* indicating the status of the request.
|
||||
* @see asyncAction
|
||||
*/
|
||||
TagOrderActions.commitTagOrdering = function(matrixClient) {
|
||||
return asyncAction('TagOrderActions.commitTagOrdering', () => {
|
||||
// Only commit tags if the state is ready, i.e. not null
|
||||
const tags = TagOrderStore.getOrderedTags();
|
||||
if (!tags) {
|
||||
return;
|
||||
}
|
||||
TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
|
||||
// Only commit tags if the state is ready, i.e. not null
|
||||
let tags = TagOrderStore.getOrderedTags();
|
||||
let removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
|
||||
if (!tags) {
|
||||
return;
|
||||
}
|
||||
|
||||
tags = tags.filter((t) => t !== tag);
|
||||
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
|
||||
|
||||
removedTags = removedTags.filter((t) => t !== tag);
|
||||
|
||||
const storeId = TagOrderStore.getStoreId();
|
||||
|
||||
return asyncAction('TagOrderActions.moveTag', () => {
|
||||
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
|
||||
return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags});
|
||||
return matrixClient.setAccountData(
|
||||
'im.vector.web.tag_ordering',
|
||||
{tags, removedTags, _storeId: storeId},
|
||||
);
|
||||
}, () => {
|
||||
// For an optimistic update
|
||||
return {tags, removedTags};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an action thunk that will do an asynchronous request to
|
||||
* label a tag as removed in im.vector.web.tag_ordering account data.
|
||||
*
|
||||
* The reason this is implemented with new state `removedTags` is that
|
||||
* we incrementally and initially populate `tags` with groups that
|
||||
* have been joined. If we remove a group from `tags`, it will just
|
||||
* get added (as it looks like a group we've recently joined).
|
||||
*
|
||||
* NB: If we ever support adding of tags (which is planned), we should
|
||||
* take special care to remove the tag from `removedTags` when we add
|
||||
* it.
|
||||
*
|
||||
* @param {MatrixClient} matrixClient the matrix client to set the
|
||||
* account data on.
|
||||
* @param {string} tag the tag to remove.
|
||||
* @returns {function} an action thunk that will dispatch actions
|
||||
* indicating the status of the request.
|
||||
* @see asyncAction
|
||||
*/
|
||||
TagOrderActions.removeTag = function(matrixClient, tag) {
|
||||
// Don't change tags, just removedTags
|
||||
const tags = TagOrderStore.getOrderedTags();
|
||||
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
|
||||
|
||||
if (removedTags.includes(tag)) {
|
||||
// Return a thunk that doesn't do anything, we don't even need
|
||||
// an asynchronous action here, the tag is already removed.
|
||||
return () => {};
|
||||
}
|
||||
|
||||
removedTags.push(tag);
|
||||
|
||||
const storeId = TagOrderStore.getStoreId();
|
||||
|
||||
return asyncAction('TagOrderActions.removeTag', () => {
|
||||
Analytics.trackEvent('TagOrderActions', 'removeTag');
|
||||
return matrixClient.setAccountData(
|
||||
'im.vector.web.tag_ordering',
|
||||
{tags, removedTags, _storeId: storeId},
|
||||
);
|
||||
}, () => {
|
||||
// For an optimistic update
|
||||
return {removedTags};
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -22,16 +22,32 @@ limitations under the License.
|
|||
* suffix determining whether it is pending, successful or
|
||||
* a failure.
|
||||
* @param {function} fn a function that returns a Promise.
|
||||
* @param {function?} pendingFn a function that returns an object to assign
|
||||
* to the `request` key of the ${id}.pending
|
||||
* payload.
|
||||
* @returns {function} an action thunk - a function that uses its single
|
||||
* argument as a dispatch function to dispatch the
|
||||
* following actions:
|
||||
* `${id}.pending` and either
|
||||
* `${id}.success` or
|
||||
* `${id}.failure`.
|
||||
*
|
||||
* The shape of each are:
|
||||
* { action: '${id}.pending', request }
|
||||
* { action: '${id}.success', result }
|
||||
* { action: '${id}.failure', err }
|
||||
*
|
||||
* where `request` is returned by `pendingFn` and
|
||||
* result is the result of the promise returned by
|
||||
* `fn`.
|
||||
*/
|
||||
export function asyncAction(id, fn) {
|
||||
export function asyncAction(id, fn, pendingFn) {
|
||||
return (dispatch) => {
|
||||
dispatch({action: id + '.pending'});
|
||||
dispatch({
|
||||
action: id + '.pending',
|
||||
request:
|
||||
typeof pendingFn === 'function' ? pendingFn() : undefined,
|
||||
});
|
||||
fn().then((result) => {
|
||||
dispatch({action: id + '.success', result});
|
||||
}).catch((err) => {
|
||||
|
|
|
@ -105,6 +105,11 @@ const COMMANDS = [
|
|||
args: '<user-id>',
|
||||
description: _td('Stops ignoring a user, showing their messages going forward'),
|
||||
},
|
||||
{
|
||||
command: '/devtools',
|
||||
args: '',
|
||||
description: _td('Opens the Developer Tools dialog'),
|
||||
},
|
||||
// Omitting `/markdown` as it only seems to apply to OldComposer
|
||||
];
|
||||
|
||||
|
|
|
@ -44,6 +44,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
this.matcher = new FuzzyMatcher([], {
|
||||
keys: ['name', 'userId'],
|
||||
shouldMatchPrefix: true,
|
||||
shouldMatchWordsOnly: false
|
||||
});
|
||||
|
||||
this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
|
||||
|
@ -72,6 +73,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
// updates from pagination will happen when the paginate completes.
|
||||
if (toStartOfTimeline || !data || !data.liveEvent) return;
|
||||
|
||||
// TODO: lazyload if we have no ev.sender room member?
|
||||
this.onUserSpoke(ev.sender);
|
||||
}
|
||||
|
||||
|
@ -99,8 +101,13 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
|
||||
let completions = [];
|
||||
const {command, range} = this.getCurrentCommand(query, selection, force);
|
||||
if (command) {
|
||||
completions = this.matcher.match(command[0]).map((user) => {
|
||||
|
||||
if (!command) return completions;
|
||||
|
||||
const fullMatch = command[0];
|
||||
// Don't search if the query is a single "@"
|
||||
if (fullMatch && fullMatch !== '@') {
|
||||
completions = this.matcher.match(fullMatch).map((user) => {
|
||||
const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
|
||||
return {
|
||||
// Length of completion should equal length of text in decorator. draft-js
|
||||
|
@ -147,6 +154,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
|
||||
onUserSpoke(user: RoomMember) {
|
||||
if (this.users === null) return;
|
||||
if (!user) return;
|
||||
if (user.userId === MatrixClientPeg.get().credentials.userId) return;
|
||||
|
||||
// Move the user that spoke to the front of the array
|
||||
|
@ -158,7 +166,7 @@ export default class UserProvider extends AutocompleteProvider {
|
|||
}
|
||||
|
||||
renderCompletions(completions: [React.Component]): ?React.Component {
|
||||
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
|
||||
return <div className="mx_Autocomplete_Completion_container_pill">
|
||||
{ completions }
|
||||
</div>;
|
||||
}
|
||||
|
|
197
src/components/structures/BottomLeftMenu.js
Normal file
197
src/components/structures/BottomLeftMenu.js
Normal file
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import Velocity from 'velocity-vector';
|
||||
import 'velocity-vector/velocity.ui';
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
|
||||
const CALLOUT_ANIM_DURATION = 1000;
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'BottomLeftMenu',
|
||||
|
||||
propTypes: {
|
||||
collapsed: React.PropTypes.bool.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return({
|
||||
directoryHover : false,
|
||||
roomsHover : false,
|
||||
homeHover: false,
|
||||
peopleHover : false,
|
||||
settingsHover : false,
|
||||
});
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._dispatcherRef = dis.register(this.onAction);
|
||||
this._peopleButton = null;
|
||||
this._directoryButton = null;
|
||||
this._createRoomButton = null;
|
||||
this._lastCallouts = {};
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
},
|
||||
|
||||
// Room events
|
||||
onDirectoryClick: function() {
|
||||
dis.dispatch({ action: 'view_room_directory' });
|
||||
},
|
||||
|
||||
onDirectoryMouseEnter: function() {
|
||||
this.setState({ directoryHover: true });
|
||||
},
|
||||
|
||||
onDirectoryMouseLeave: function() {
|
||||
this.setState({ directoryHover: false });
|
||||
},
|
||||
|
||||
onRoomsClick: function() {
|
||||
dis.dispatch({ action: 'view_create_room' });
|
||||
},
|
||||
|
||||
onRoomsMouseEnter: function() {
|
||||
this.setState({ roomsHover: true });
|
||||
},
|
||||
|
||||
onRoomsMouseLeave: function() {
|
||||
this.setState({ roomsHover: false });
|
||||
},
|
||||
|
||||
// Home button events
|
||||
onHomeClick: function() {
|
||||
dis.dispatch({ action: 'view_home_page' });
|
||||
},
|
||||
|
||||
onHomeMouseEnter: function() {
|
||||
this.setState({ homeHover: true });
|
||||
},
|
||||
|
||||
onHomeMouseLeave: function() {
|
||||
this.setState({ homeHover: false });
|
||||
},
|
||||
|
||||
// People events
|
||||
onPeopleClick: function() {
|
||||
dis.dispatch({ action: 'view_create_chat' });
|
||||
},
|
||||
|
||||
onPeopleMouseEnter: function() {
|
||||
this.setState({ peopleHover: true });
|
||||
},
|
||||
|
||||
onPeopleMouseLeave: function() {
|
||||
this.setState({ peopleHover: false });
|
||||
},
|
||||
|
||||
// Settings events
|
||||
onSettingsClick: function() {
|
||||
dis.dispatch({ action: 'view_user_settings' });
|
||||
},
|
||||
|
||||
onSettingsMouseEnter: function() {
|
||||
this.setState({ settingsHover: true });
|
||||
},
|
||||
|
||||
onSettingsMouseLeave: function() {
|
||||
this.setState({ settingsHover: false });
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
let calloutElement;
|
||||
switch (payload.action) {
|
||||
// Incoming instruction: dance!
|
||||
case 'callout_start_chat':
|
||||
calloutElement = this._peopleButton;
|
||||
break;
|
||||
case 'callout_room_directory':
|
||||
calloutElement = this._directoryButton;
|
||||
break;
|
||||
case 'callout_create_room':
|
||||
calloutElement = this._createRoomButton;
|
||||
break;
|
||||
}
|
||||
if (calloutElement) {
|
||||
const lastCallout = this._lastCallouts[payload.action];
|
||||
const now = Date.now();
|
||||
if (lastCallout == undefined || lastCallout < now - CALLOUT_ANIM_DURATION) {
|
||||
this._lastCallouts[payload.action] = now;
|
||||
Velocity(ReactDOM.findDOMNode(calloutElement), "callout.bounce", CALLOUT_ANIM_DURATION);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Get the label/tooltip to show
|
||||
getLabel: function(label, show) {
|
||||
if (show) {
|
||||
var RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
|
||||
return <RoomTooltip className="mx_BottomLeftMenu_tooltip" label={label} />;
|
||||
}
|
||||
},
|
||||
|
||||
_collectPeopleButton: function(e) {
|
||||
this._peopleButton = e;
|
||||
},
|
||||
|
||||
_collectDirectoryButton: function(e) {
|
||||
this._directoryButton = e;
|
||||
},
|
||||
|
||||
_collectCreateRoomButton: function(e) {
|
||||
this._createRoomButton = e;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const HomeButton = sdk.getComponent('elements.HomeButton');
|
||||
const StartChatButton = sdk.getComponent('elements.StartChatButton');
|
||||
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
|
||||
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
|
||||
const SettingsButton = sdk.getComponent('elements.SettingsButton');
|
||||
const GroupsButton = sdk.getComponent('elements.GroupsButton');
|
||||
|
||||
const groupsButton = SettingsStore.getValue("TagPanel.disableTagPanel") ?
|
||||
<GroupsButton tooltip={true} /> : null;
|
||||
|
||||
return (
|
||||
<div className="mx_BottomLeftMenu">
|
||||
<div className="mx_BottomLeftMenu_options">
|
||||
<HomeButton tooltip={true} />
|
||||
<div ref={this._collectPeopleButton}>
|
||||
<StartChatButton tooltip={true} />
|
||||
</div>
|
||||
<div ref={this._collectDirectoryButton}>
|
||||
<RoomDirectoryButton tooltip={true} />
|
||||
</div>
|
||||
<div ref={this._collectCreateRoomButton}>
|
||||
<CreateRoomButton tooltip={true} />
|
||||
</div>
|
||||
{ groupsButton }
|
||||
<span className="mx_BottomLeftMenu_settings">
|
||||
<SettingsButton tooltip={true} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
73
src/components/structures/CompatibilityPage.js
Normal file
73
src/components/structures/CompatibilityPage.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var React = require('react');
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'CompatibilityPage',
|
||||
propTypes: {
|
||||
onAccept: React.PropTypes.func
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onAccept: function() {} // NOP
|
||||
};
|
||||
},
|
||||
|
||||
onAccept: function() {
|
||||
this.props.onAccept();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
||||
return (
|
||||
<div className="mx_CompatibilityPage">
|
||||
<div className="mx_CompatibilityPage_box">
|
||||
<p>{ _t("Sorry, your browser is <b>not</b> able to run Riot.", {}, { 'b': (sub) => <b>{sub}</b> }) } </p>
|
||||
<p>
|
||||
{ _t("Riot uses many advanced browser features, some of which are not available or experimental in your current browser.") }
|
||||
</p>
|
||||
<p>
|
||||
{ _t('Please install <chromeLink>Chrome</chromeLink> or <firefoxLink>Firefox</firefoxLink> for the best experience.',
|
||||
{},
|
||||
{
|
||||
'chromeLink': (sub) => <a href="https://www.google.com/chrome">{sub}</a>,
|
||||
'firefoxLink': (sub) => <a href="https://getfirefox.com">{sub}</a>,
|
||||
},
|
||||
)}
|
||||
{ _t('<safariLink>Safari</safariLink> and <operaLink>Opera</operaLink> work too.',
|
||||
{},
|
||||
{
|
||||
'safariLink': (sub) => <a href="http://apple.com/safari">{sub}</a>,
|
||||
'operaLink': (sub) => <a href="http://opera.com">{sub}</a>,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{ _t("With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!") }
|
||||
</p>
|
||||
<button onClick={this.onAccept}>
|
||||
{ _t("I understand the risks and wish to continue") }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
|
@ -26,43 +26,49 @@ import PropTypes from 'prop-types';
|
|||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
// pass in a custom control as the actual body.
|
||||
|
||||
module.exports = {
|
||||
ContextualMenuContainerId: "mx_ContextualMenu_Container",
|
||||
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
|
||||
|
||||
function getOrCreateContainer() {
|
||||
let container = document.getElementById(ContextualMenuContainerId);
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = ContextualMenuContainerId;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export default class ContextualMenu extends React.Component {
|
||||
propTypes: {
|
||||
top: PropTypes.number,
|
||||
bottom: PropTypes.number,
|
||||
left: PropTypes.number,
|
||||
right: PropTypes.number,
|
||||
menuWidth: PropTypes.number,
|
||||
menuHeight: PropTypes.number,
|
||||
chevronOffset: PropTypes.number,
|
||||
menuColour: PropTypes.string,
|
||||
chevronFace: PropTypes.string, // top, bottom, left, right
|
||||
},
|
||||
// Function to be called on menu close
|
||||
onFinished: PropTypes.func,
|
||||
menuPaddingTop: PropTypes.number,
|
||||
menuPaddingRight: PropTypes.number,
|
||||
menuPaddingBottom: PropTypes.number,
|
||||
menuPaddingLeft: PropTypes.number,
|
||||
|
||||
getOrCreateContainer: function() {
|
||||
let container = document.getElementById(this.ContextualMenuContainerId);
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = this.ContextualMenuContainerId;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
createMenu: function(Element, props) {
|
||||
const self = this;
|
||||
|
||||
const closeMenu = function() {
|
||||
ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
|
||||
|
||||
if (props && props.onFinished) {
|
||||
props.onFinished.apply(null, arguments);
|
||||
}
|
||||
};
|
||||
// If true, insert an invisible screen-sized element behind the
|
||||
// menu that when clicked will close it.
|
||||
hasBackground: PropTypes.bool,
|
||||
}
|
||||
|
||||
render() {
|
||||
const position = {};
|
||||
let chevronFace = null;
|
||||
|
||||
const props = this.props;
|
||||
|
||||
if (props.top) {
|
||||
position.top = props.top;
|
||||
} else {
|
||||
|
@ -130,21 +136,53 @@ module.exports = {
|
|||
menuStyle["backgroundColor"] = props.menuColour;
|
||||
}
|
||||
|
||||
if (!isNaN(Number(props.menuPaddingTop))) {
|
||||
menuStyle["paddingTop"] = props.menuPaddingTop;
|
||||
}
|
||||
if (!isNaN(Number(props.menuPaddingLeft))) {
|
||||
menuStyle["paddingLeft"] = props.menuPaddingLeft;
|
||||
}
|
||||
if (!isNaN(Number(props.menuPaddingBottom))) {
|
||||
menuStyle["paddingBottom"] = props.menuPaddingBottom;
|
||||
}
|
||||
if (!isNaN(Number(props.menuPaddingRight))) {
|
||||
menuStyle["paddingRight"] = props.menuPaddingRight;
|
||||
}
|
||||
|
||||
const ElementClass = props.elementClass;
|
||||
|
||||
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
|
||||
// property set here so you can't close the menu from a button click!
|
||||
const menu = (
|
||||
<div className={className} style={position}>
|
||||
<div className={menuClasses} style={menuStyle}>
|
||||
{ chevron }
|
||||
<Element {...props} onFinished={closeMenu} />
|
||||
</div>
|
||||
<div className="mx_ContextualMenu_background" onClick={closeMenu}></div>
|
||||
<style>{ chevronCSS }</style>
|
||||
return <div className={className} style={position}>
|
||||
<div className={menuClasses} style={menuStyle}>
|
||||
{ chevron }
|
||||
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
|
||||
</div>
|
||||
);
|
||||
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu}></div> }
|
||||
<style>{ chevronCSS }</style>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.render(menu, this.getOrCreateContainer());
|
||||
export function createMenu(ElementClass, props) {
|
||||
const closeMenu = function(...args) {
|
||||
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
|
||||
|
||||
return {close: closeMenu};
|
||||
},
|
||||
};
|
||||
if (props && props.onFinished) {
|
||||
props.onFinished.apply(null, args);
|
||||
}
|
||||
};
|
||||
|
||||
// We only reference closeMenu once per call to createMenu
|
||||
const menu = <ContextualMenu
|
||||
{...props}
|
||||
hasBackground={true}
|
||||
elementClass={ElementClass}
|
||||
closeMenu={closeMenu} // eslint-disable-line react/jsx-no-bind
|
||||
windowResize={closeMenu} // eslint-disable-line react/jsx-no-bind
|
||||
/>;
|
||||
|
||||
ReactDOM.render(menu, getOrCreateContainer());
|
||||
|
||||
return {close: closeMenu};
|
||||
}
|
||||
|
|
|
@ -68,6 +68,9 @@ const FilePanel = React.createClass({
|
|||
"room": {
|
||||
"timeline": {
|
||||
"contains_url": true,
|
||||
"not_types": [
|
||||
"m.sticker",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd.
|
||||
Copyright 2017 New Vector Ltd.
|
||||
Copyright 2017, 2018 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -27,10 +27,9 @@ import AccessibleButton from '../views/elements/AccessibleButton';
|
|||
import Modal from '../../Modal';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import GroupStoreCache from '../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
import FlairStore from '../../stores/FlairStore';
|
||||
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
|
@ -93,8 +92,8 @@ const CategoryRoomList = React.createClass({
|
|||
if (!success) return;
|
||||
const errorList = [];
|
||||
Promise.all(addrs.map((addr) => {
|
||||
return this.context.groupStore
|
||||
.addRoomToGroupSummary(addr.address)
|
||||
return GroupStore
|
||||
.addRoomToGroupSummary(this.props.groupId, addr.address)
|
||||
.catch(() => { errorList.push(addr.address); })
|
||||
.reflect();
|
||||
})).then(() => {
|
||||
|
@ -174,7 +173,8 @@ const FeaturedRoom = React.createClass({
|
|||
onDeleteClicked: function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.context.groupStore.removeRoomFromGroupSummary(
|
||||
GroupStore.removeRoomFromGroupSummary(
|
||||
this.props.groupId,
|
||||
this.props.summaryInfo.room_id,
|
||||
).catch((err) => {
|
||||
console.error('Error whilst removing room from group summary', err);
|
||||
|
@ -269,7 +269,7 @@ const RoleUserList = React.createClass({
|
|||
if (!success) return;
|
||||
const errorList = [];
|
||||
Promise.all(addrs.map((addr) => {
|
||||
return this.context.groupStore
|
||||
return GroupStore
|
||||
.addUserToGroupSummary(addr.address)
|
||||
.catch(() => { errorList.push(addr.address); })
|
||||
.reflect();
|
||||
|
@ -344,7 +344,8 @@ const FeaturedUser = React.createClass({
|
|||
onDeleteClicked: function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.context.groupStore.removeUserFromGroupSummary(
|
||||
GroupStore.removeUserFromGroupSummary(
|
||||
this.props.groupId,
|
||||
this.props.summaryInfo.user_id,
|
||||
).catch((err) => {
|
||||
console.error('Error whilst removing user from group summary', err);
|
||||
|
@ -390,14 +391,8 @@ const FeaturedUser = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
const GroupContext = {
|
||||
groupStore: PropTypes.instanceOf(GroupStore).isRequired,
|
||||
};
|
||||
|
||||
CategoryRoomList.contextTypes = GroupContext;
|
||||
FeaturedRoom.contextTypes = GroupContext;
|
||||
RoleUserList.contextTypes = GroupContext;
|
||||
FeaturedUser.contextTypes = GroupContext;
|
||||
const GROUP_JOINPOLICY_OPEN = "open";
|
||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||
|
||||
export default React.createClass({
|
||||
displayName: 'GroupView',
|
||||
|
@ -412,12 +407,6 @@ export default React.createClass({
|
|||
groupStore: PropTypes.instanceOf(GroupStore),
|
||||
},
|
||||
|
||||
getChildContext: function() {
|
||||
return {
|
||||
groupStore: this._groupStore,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
summary: null,
|
||||
|
@ -429,6 +418,7 @@ export default React.createClass({
|
|||
editing: false,
|
||||
saving: false,
|
||||
uploadingAvatar: false,
|
||||
avatarChanged: false,
|
||||
membershipBusy: false,
|
||||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
|
@ -436,6 +426,7 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this._matrixClient = MatrixClientPeg.get();
|
||||
this._matrixClient.on("Group.myMembership", this._onGroupMyMembership);
|
||||
|
||||
|
@ -444,8 +435,8 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this._unmounted = true;
|
||||
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this._groupStore.removeAllListeners();
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(newProps) {
|
||||
|
@ -460,8 +451,11 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
_onGroupMyMembership: function(group) {
|
||||
if (group.groupId !== this.props.groupId) return;
|
||||
|
||||
if (this._unmounted || group.groupId !== this.props.groupId) return;
|
||||
if (group.myMembership === 'leave') {
|
||||
// Leave settings - the user might have clicked the "Leave" button
|
||||
this._closeSettings();
|
||||
}
|
||||
this.setState({membershipBusy: false});
|
||||
},
|
||||
|
||||
|
@ -470,34 +464,11 @@ export default React.createClass({
|
|||
if (group && group.inviter && group.inviter.userId) {
|
||||
this._fetchInviterProfile(group.inviter.userId);
|
||||
}
|
||||
this._groupStore = GroupStoreCache.getGroupStore(groupId);
|
||||
this._groupStore.registerListener(() => {
|
||||
const summary = this._groupStore.getSummary();
|
||||
if (summary.profile) {
|
||||
// Default profile fields should be "" for later sending to the server (which
|
||||
// requires that the fields are strings, not null)
|
||||
["avatar_url", "long_description", "name", "short_description"].forEach((k) => {
|
||||
summary.profile[k] = summary.profile[k] || "";
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
summary,
|
||||
summaryLoading: !this._groupStore.isStateReady(GroupStore.STATE_KEY.Summary),
|
||||
isGroupPublicised: this._groupStore.getGroupPublicity(),
|
||||
isUserPrivileged: this._groupStore.isUserPrivileged(),
|
||||
groupRooms: this._groupStore.getGroupRooms(),
|
||||
groupRoomsLoading: !this._groupStore.isStateReady(GroupStore.STATE_KEY.GroupRooms),
|
||||
isUserMember: this._groupStore.getGroupMembers().some(
|
||||
(m) => m.userId === this._matrixClient.credentials.userId,
|
||||
),
|
||||
error: null,
|
||||
});
|
||||
if (this.props.groupIsNew && firstInit) {
|
||||
this._onEditClick();
|
||||
}
|
||||
});
|
||||
GroupStore.registerListener(groupId, this.onGroupStoreUpdated.bind(this, firstInit));
|
||||
let willDoOnboarding = false;
|
||||
this._groupStore.on('error', (err) => {
|
||||
// XXX: This should be more fluxy - let's get the error from GroupStore .getError or something
|
||||
GroupStore.on('error', (err, errorGroupId) => {
|
||||
if (this._unmounted || groupId !== errorGroupId) return;
|
||||
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN' && !willDoOnboarding) {
|
||||
dis.dispatch({
|
||||
action: 'do_after_sync_prepared',
|
||||
|
@ -512,15 +483,45 @@ export default React.createClass({
|
|||
this.setState({
|
||||
summary: null,
|
||||
error: err,
|
||||
editing: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
onGroupStoreUpdated(firstInit) {
|
||||
if (this._unmounted) return;
|
||||
const summary = GroupStore.getSummary(this.props.groupId);
|
||||
if (summary.profile) {
|
||||
// Default profile fields should be "" for later sending to the server (which
|
||||
// requires that the fields are strings, not null)
|
||||
["avatar_url", "long_description", "name", "short_description"].forEach((k) => {
|
||||
summary.profile[k] = summary.profile[k] || "";
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
summary,
|
||||
summaryLoading: !GroupStore.isStateReady(this.props.groupId, GroupStore.STATE_KEY.Summary),
|
||||
isGroupPublicised: GroupStore.getGroupPublicity(this.props.groupId),
|
||||
isUserPrivileged: GroupStore.isUserPrivileged(this.props.groupId),
|
||||
groupRooms: GroupStore.getGroupRooms(this.props.groupId),
|
||||
groupRoomsLoading: !GroupStore.isStateReady(this.props.groupId, GroupStore.STATE_KEY.GroupRooms),
|
||||
isUserMember: GroupStore.getGroupMembers(this.props.groupId).some(
|
||||
(m) => m.userId === this._matrixClient.credentials.userId,
|
||||
),
|
||||
error: null,
|
||||
});
|
||||
// XXX: This might not work but this.props.groupIsNew unused anyway
|
||||
if (this.props.groupIsNew && firstInit) {
|
||||
this._onEditClick();
|
||||
}
|
||||
},
|
||||
|
||||
_fetchInviterProfile(userId) {
|
||||
this.setState({
|
||||
inviterProfileBusy: true,
|
||||
});
|
||||
this._matrixClient.getProfileInfo(userId).then((resp) => {
|
||||
if (this._unmounted) return;
|
||||
this.setState({
|
||||
inviterProfile: {
|
||||
avatarUrl: resp.avatar_url,
|
||||
|
@ -530,6 +531,7 @@ export default React.createClass({
|
|||
}).catch((e) => {
|
||||
console.error('Error getting group inviter profile', e);
|
||||
}).finally(() => {
|
||||
if (this._unmounted) return;
|
||||
this.setState({
|
||||
inviterProfileBusy: false,
|
||||
});
|
||||
|
@ -544,6 +546,12 @@ export default React.createClass({
|
|||
this.setState({
|
||||
editing: true,
|
||||
profileForm: Object.assign({}, this.state.summary.profile),
|
||||
joinableForm: {
|
||||
policyType:
|
||||
this.state.summary.profile.is_openly_joinable ?
|
||||
GROUP_JOINPOLICY_OPEN :
|
||||
GROUP_JOINPOLICY_INVITE,
|
||||
},
|
||||
});
|
||||
dis.dispatch({
|
||||
action: 'panel_disable',
|
||||
|
@ -552,6 +560,10 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
_onCancelClick: function() {
|
||||
this._closeSettings();
|
||||
},
|
||||
|
||||
_closeSettings() {
|
||||
this.setState({
|
||||
editing: false,
|
||||
profileForm: null,
|
||||
|
@ -590,6 +602,10 @@ export default React.createClass({
|
|||
this.setState({
|
||||
uploadingAvatar: false,
|
||||
profileForm: newProfileForm,
|
||||
|
||||
// Indicate that FlairStore needs to be poked to show this change
|
||||
// in TagTile (TagPanel), Flair and GroupTile (MyGroups).
|
||||
avatarChanged: true,
|
||||
});
|
||||
}).catch((e) => {
|
||||
this.setState({uploadingAvatar: false});
|
||||
|
@ -602,11 +618,15 @@ export default React.createClass({
|
|||
}).done();
|
||||
},
|
||||
|
||||
_onJoinableChange: function(ev) {
|
||||
this.setState({
|
||||
joinableForm: { policyType: ev.target.value },
|
||||
});
|
||||
},
|
||||
|
||||
_onSaveClick: function() {
|
||||
this.setState({saving: true});
|
||||
const savePromise = this.state.isUserPrivileged ?
|
||||
this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm) :
|
||||
Promise.resolve();
|
||||
const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve();
|
||||
savePromise.then((result) => {
|
||||
this.setState({
|
||||
saving: false,
|
||||
|
@ -615,6 +635,11 @@ export default React.createClass({
|
|||
});
|
||||
dis.dispatch({action: 'panel_disable'});
|
||||
this._initGroupStore(this.props.groupId);
|
||||
|
||||
if (this.state.avatarChanged) {
|
||||
// XXX: Evil - poking a store should be done from an async action
|
||||
FlairStore.refreshGroupProfile(this._matrixClient, this.props.groupId);
|
||||
}
|
||||
}).catch((e) => {
|
||||
this.setState({
|
||||
saving: false,
|
||||
|
@ -625,12 +650,28 @@ export default React.createClass({
|
|||
title: _t('Error'),
|
||||
description: _t('Failed to update community'),
|
||||
});
|
||||
}).finally(() => {
|
||||
this.setState({
|
||||
avatarChanged: false,
|
||||
});
|
||||
}).done();
|
||||
},
|
||||
|
||||
_onAcceptInviteClick: function() {
|
||||
_saveGroup: async function() {
|
||||
await this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm);
|
||||
await this._matrixClient.setGroupJoinPolicy(this.props.groupId, {
|
||||
type: this.state.joinableForm.policyType,
|
||||
});
|
||||
},
|
||||
|
||||
_onAcceptInviteClick: async function() {
|
||||
this.setState({membershipBusy: true});
|
||||
this._groupStore.acceptGroupInvite().then(() => {
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
|
||||
GroupStore.acceptGroupInvite(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
|
@ -642,9 +683,14 @@ export default React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_onRejectInviteClick: function() {
|
||||
_onRejectInviteClick: async function() {
|
||||
this.setState({membershipBusy: true});
|
||||
this._matrixClient.leaveGroup(this.props.groupId).then(() => {
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
|
||||
GroupStore.leaveGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
|
@ -656,6 +702,25 @@ export default React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_onJoinClick: async function() {
|
||||
this.setState({membershipBusy: true});
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
|
||||
GroupStore.joinGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error joining room', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Unable to join community"),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_onLeaveClick: function() {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Leave Group', '', QuestionDialog, {
|
||||
|
@ -663,18 +728,23 @@ export default React.createClass({
|
|||
description: _t("Leave %(groupName)s?", {groupName: this.props.groupId}),
|
||||
button: _t("Leave"),
|
||||
danger: true,
|
||||
onFinished: (confirmed) => {
|
||||
onFinished: async (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
|
||||
this.setState({membershipBusy: true});
|
||||
this._matrixClient.leaveGroup(this.props.groupId).then(() => {
|
||||
|
||||
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
|
||||
// spinner disappearing after we have fetched new group data.
|
||||
await Promise.delay(500);
|
||||
|
||||
GroupStore.leaveGroup(this.props.groupId).then(() => {
|
||||
// don't reset membershipBusy here: wait for the membership change to come down the sync
|
||||
}).catch((e) => {
|
||||
this.setState({membershipBusy: false});
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Error leaving room', '', ErrorDialog, {
|
||||
Modal.createTrackedDialog('Error leaving community', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Unable to leave room"),
|
||||
description: _t("Unable to leave community"),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
@ -692,8 +762,22 @@ export default React.createClass({
|
|||
});
|
||||
|
||||
const header = this.state.editing ? <h2> { _t('Community Settings') } </h2> : <div />;
|
||||
const changeDelayWarning = this.state.editing && this.state.isUserPrivileged ?
|
||||
<div className="mx_GroupView_changeDelayWarning">
|
||||
{ _t(
|
||||
'Changes made to your community <bold1>name</bold1> and <bold2>avatar</bold2> ' +
|
||||
'might not be seen by other users for up to 30 minutes.',
|
||||
{},
|
||||
{
|
||||
'bold1': (sub) => <b> { sub } </b>,
|
||||
'bold2': (sub) => <b> { sub } </b>,
|
||||
},
|
||||
) }
|
||||
</div> : <div />;
|
||||
return <div className={groupSettingsSectionClasses}>
|
||||
{ header }
|
||||
{ changeDelayWarning }
|
||||
{ this._getJoinableNode() }
|
||||
{ this._getLongDescriptionNode() }
|
||||
{ this._getRoomsNode() }
|
||||
</div>;
|
||||
|
@ -832,9 +916,8 @@ export default React.createClass({
|
|||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
|
||||
const group = this._matrixClient.getGroup(this.props.groupId);
|
||||
if (!group) return null;
|
||||
|
||||
if (group.myMembership === 'invite') {
|
||||
if (group && group.myMembership === 'invite') {
|
||||
if (this.state.membershipBusy || this.state.inviterProfileBusy) {
|
||||
return <div className="mx_GroupView_membershipSection">
|
||||
<Spinner />
|
||||
|
@ -875,33 +958,107 @@ export default React.createClass({
|
|||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
} else if (group.myMembership === 'join' && this.state.editing) {
|
||||
const leaveButtonTooltip = this.state.isUserPrivileged ?
|
||||
}
|
||||
|
||||
let membershipContainerExtraClasses;
|
||||
let membershipButtonExtraClasses;
|
||||
let membershipButtonTooltip;
|
||||
let membershipButtonText;
|
||||
let membershipButtonOnClick;
|
||||
|
||||
// User is not in the group
|
||||
if ((!group || group.myMembership === 'leave') &&
|
||||
this.state.summary &&
|
||||
this.state.summary.profile &&
|
||||
Boolean(this.state.summary.profile.is_openly_joinable)
|
||||
) {
|
||||
membershipButtonText = _t("Join this community");
|
||||
membershipButtonOnClick = this._onJoinClick;
|
||||
|
||||
membershipButtonExtraClasses = 'mx_GroupView_joinButton';
|
||||
membershipContainerExtraClasses = 'mx_GroupView_membershipSection_leave';
|
||||
} else if (
|
||||
group &&
|
||||
group.myMembership === 'join' &&
|
||||
this.state.editing
|
||||
) {
|
||||
membershipButtonText = _t("Leave this community");
|
||||
membershipButtonOnClick = this._onLeaveClick;
|
||||
membershipButtonTooltip = this.state.isUserPrivileged ?
|
||||
_t("You are an administrator of this community") :
|
||||
_t("You are a member of this community");
|
||||
const leaveButtonClasses = classnames({
|
||||
"mx_RoomHeader_textButton": true,
|
||||
"mx_GroupView_textButton": true,
|
||||
"mx_GroupView_leaveButton": true,
|
||||
"mx_RoomHeader_textButton_danger": this.state.isUserPrivileged,
|
||||
});
|
||||
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_joined">
|
||||
<div className="mx_GroupView_membershipSubSection">
|
||||
{ /* Empty div for flex alignment */ }
|
||||
<div />
|
||||
<div className="mx_GroupView_membership_buttonContainer">
|
||||
<AccessibleButton
|
||||
className={leaveButtonClasses}
|
||||
onClick={this._onLeaveClick}
|
||||
title={leaveButtonTooltip}
|
||||
>
|
||||
{ _t("Leave") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
membershipButtonExtraClasses = {
|
||||
'mx_GroupView_leaveButton': true,
|
||||
'mx_RoomHeader_textButton_danger': this.state.isUserPrivileged,
|
||||
};
|
||||
membershipContainerExtraClasses = 'mx_GroupView_membershipSection_joined';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
const membershipButtonClasses = classnames([
|
||||
'mx_RoomHeader_textButton',
|
||||
'mx_GroupView_textButton',
|
||||
],
|
||||
membershipButtonExtraClasses,
|
||||
);
|
||||
|
||||
const membershipContainerClasses = classnames(
|
||||
'mx_GroupView_membershipSection',
|
||||
membershipContainerExtraClasses,
|
||||
);
|
||||
|
||||
return <div className={membershipContainerClasses}>
|
||||
<div className="mx_GroupView_membershipSubSection">
|
||||
{ /* The <div /> is for flex alignment */ }
|
||||
{ this.state.membershipBusy ? <Spinner /> : <div /> }
|
||||
<div className="mx_GroupView_membership_buttonContainer">
|
||||
<AccessibleButton
|
||||
className={membershipButtonClasses}
|
||||
onClick={membershipButtonOnClick}
|
||||
title={membershipButtonTooltip}
|
||||
>
|
||||
{ membershipButtonText }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
_getJoinableNode: function() {
|
||||
return this.state.editing ? <div>
|
||||
<h3>
|
||||
{ _t('Who can join this community?') }
|
||||
{ this.state.groupJoinableLoading ?
|
||||
<InlineSpinner /> : <div />
|
||||
}
|
||||
</h3>
|
||||
<div>
|
||||
<label>
|
||||
<input type="radio"
|
||||
value={GROUP_JOINPOLICY_INVITE}
|
||||
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE}
|
||||
onClick={this._onJoinableChange}
|
||||
/>
|
||||
<div className="mx_GroupView_label_text">
|
||||
{ _t('Only people who have been invited') }
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="radio"
|
||||
value={GROUP_JOINPOLICY_OPEN}
|
||||
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN}
|
||||
onClick={this._onJoinableChange}
|
||||
/>
|
||||
<div className="mx_GroupView_label_text">
|
||||
{ _t('Everyone') }
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div> : null;
|
||||
},
|
||||
|
||||
_getLongDescriptionNode: function() {
|
||||
|
@ -947,6 +1104,7 @@ export default React.createClass({
|
|||
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
|
||||
return <Spinner />;
|
||||
|
@ -1097,9 +1255,9 @@ export default React.createClass({
|
|||
{ rightButtons }
|
||||
</div>
|
||||
</div>
|
||||
<GeminiScrollbar className="mx_GroupView_body">
|
||||
<GeminiScrollbarWrapper className="mx_GroupView_body">
|
||||
{ bodyNodes }
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.error) {
|
||||
|
|
109
src/components/structures/HomePage.js
Normal file
109
src/components/structures/HomePage.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import request from 'browser-request';
|
||||
import { _t } from '../../languageHandler';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import sdk from '../../index';
|
||||
|
||||
class HomePage extends React.Component {
|
||||
static displayName = 'HomePage';
|
||||
|
||||
static propTypes = {
|
||||
// URL base of the team server. Optional.
|
||||
teamServerUrl: PropTypes.string,
|
||||
// Team token. Optional. If set, used to get the static homepage of the team
|
||||
// associated. If unset, homePageUrl will be used.
|
||||
teamToken: PropTypes.string,
|
||||
// URL to use as the iFrame src. Defaults to /home.html.
|
||||
homePageUrl: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
iframeSrc: '',
|
||||
page: '',
|
||||
};
|
||||
|
||||
translate(s) {
|
||||
// default implementation - skins may wish to extend this
|
||||
return sanitizeHtml(_t(s));
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this._unmounted = false;
|
||||
|
||||
if (this.props.teamToken && this.props.teamServerUrl) {
|
||||
this.setState({
|
||||
iframeSrc: `${this.props.teamServerUrl}/static/${this.props.teamToken}/home.html`
|
||||
});
|
||||
}
|
||||
else {
|
||||
// we use request() to inline the homepage into the react component
|
||||
// so that it can inherit CSS and theming easily rather than mess around
|
||||
// with iframes and trying to synchronise document.stylesheets.
|
||||
|
||||
let src = this.props.homePageUrl || 'home.html';
|
||||
|
||||
request(
|
||||
{ method: "GET", url: src },
|
||||
(err, response, body) => {
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err || response.status < 200 || response.status >= 300) {
|
||||
console.warn(`Error loading home page: ${err}`);
|
||||
this.setState({ page: _t("Couldn't load home page") });
|
||||
return;
|
||||
}
|
||||
|
||||
body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1)=>this.translate(g1));
|
||||
this.setState({ page: body });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.iframeSrc) {
|
||||
return (
|
||||
<div className="mx_HomePage">
|
||||
<iframe src={ this.state.iframeSrc } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
return (
|
||||
<GeminiScrollbarWrapper autoshow={true} className="mx_HomePage">
|
||||
<div className="mx_HomePage_body" dangerouslySetInnerHTML={{ __html: this.state.page }}>
|
||||
</div>
|
||||
</GeminiScrollbarWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HomePage;
|
226
src/components/structures/LeftPanel.js
Normal file
226
src/components/structures/LeftPanel.js
Normal file
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||
|
||||
import SettingsStore from '../../settings/SettingsStore';
|
||||
|
||||
|
||||
var LeftPanel = React.createClass({
|
||||
displayName: 'LeftPanel',
|
||||
|
||||
// NB. If you add props, don't forget to update
|
||||
// shouldComponentUpdate!
|
||||
propTypes: {
|
||||
collapsed: PropTypes.bool.isRequired,
|
||||
},
|
||||
|
||||
contextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient),
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
searchFilter: '',
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this.focusedElement = null;
|
||||
},
|
||||
|
||||
shouldComponentUpdate: function(nextProps, nextState) {
|
||||
// MatrixChat will update whenever the user switches
|
||||
// rooms, but propagating this change all the way down
|
||||
// the react tree is quite slow, so we cut this off
|
||||
// here. The RoomTiles listen for the room change
|
||||
// events themselves to know when to update.
|
||||
// We just need to update if any of these things change.
|
||||
if (
|
||||
this.props.collapsed !== nextProps.collapsed ||
|
||||
this.props.disabled !== nextProps.disabled
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.state.searchFilter !== nextState.searchFilter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
_onFocus: function(ev) {
|
||||
this.focusedElement = ev.target;
|
||||
},
|
||||
|
||||
_onBlur: function(ev) {
|
||||
this.focusedElement = null;
|
||||
},
|
||||
|
||||
_onKeyDown: function(ev) {
|
||||
if (!this.focusedElement) return;
|
||||
let handled = false;
|
||||
|
||||
switch (ev.keyCode) {
|
||||
case KeyCode.UP:
|
||||
this._onMoveFocus(true);
|
||||
handled = true;
|
||||
break;
|
||||
case KeyCode.DOWN:
|
||||
this._onMoveFocus(false);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
_onMoveFocus: function(up) {
|
||||
var element = this.focusedElement;
|
||||
|
||||
// unclear why this isn't needed
|
||||
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
|
||||
// this.focusDirection = up;
|
||||
|
||||
var descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
var classes;
|
||||
|
||||
do {
|
||||
var child = up ? element.lastElementChild : element.firstElementChild;
|
||||
var sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||
|
||||
if (descending) {
|
||||
if (child) {
|
||||
element = child;
|
||||
}
|
||||
else if (sibling) {
|
||||
element = sibling;
|
||||
}
|
||||
else {
|
||||
descending = false;
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (sibling) {
|
||||
element = sibling;
|
||||
descending = true;
|
||||
}
|
||||
else {
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
classes = element.classList;
|
||||
if (classes.contains("mx_LeftPanel")) { // we hit the top
|
||||
element = up ? element.lastElementChild : element.firstElementChild;
|
||||
descending = true;
|
||||
}
|
||||
}
|
||||
|
||||
} while(element && !(
|
||||
classes.contains("mx_RoomTile") ||
|
||||
classes.contains("mx_SearchBox_search") ||
|
||||
classes.contains("mx_RoomSubList_ellipsis")));
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
this.focusedElement = element;
|
||||
this.focusedDescending = descending;
|
||||
}
|
||||
},
|
||||
|
||||
onHideClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'hide_left_panel',
|
||||
});
|
||||
},
|
||||
|
||||
onSearch: function(term) {
|
||||
this.setState({ searchFilter: term });
|
||||
},
|
||||
|
||||
collectRoomList: function(ref) {
|
||||
this._roomList = ref;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const RoomList = sdk.getComponent('rooms.RoomList');
|
||||
const TagPanel = sdk.getComponent('structures.TagPanel');
|
||||
const BottomLeftMenu = sdk.getComponent('structures.BottomLeftMenu');
|
||||
const CallPreview = sdk.getComponent('voip.CallPreview');
|
||||
|
||||
let topBox;
|
||||
if (this.context.matrixClient.isGuest()) {
|
||||
const LoginBox = sdk.getComponent('structures.LoginBox');
|
||||
topBox = <LoginBox collapsed={ this.props.collapsed }/>;
|
||||
} else {
|
||||
const SearchBox = sdk.getComponent('structures.SearchBox');
|
||||
topBox = <SearchBox collapsed={ this.props.collapsed } onSearch={ this.onSearch } />;
|
||||
}
|
||||
|
||||
const classes = classNames(
|
||||
"mx_LeftPanel",
|
||||
{
|
||||
"collapsed": this.props.collapsed,
|
||||
},
|
||||
);
|
||||
|
||||
const tagPanelEnabled = !SettingsStore.getValue("TagPanel.disableTagPanel");
|
||||
const tagPanel = tagPanelEnabled ? <TagPanel /> : <div />;
|
||||
|
||||
const containerClasses = classNames(
|
||||
"mx_LeftPanel_container", "mx_fadable",
|
||||
{
|
||||
"mx_LeftPanel_container_collapsed": this.props.collapsed,
|
||||
"mx_LeftPanel_container_hasTagPanel": tagPanelEnabled,
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{ tagPanel }
|
||||
<aside className={classes} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
|
||||
{ topBox }
|
||||
<CallPreview ConferenceHandler={VectorConferenceHandler} />
|
||||
<RoomList
|
||||
ref={this.collectRoomList}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ConferenceHandler={VectorConferenceHandler} />
|
||||
<BottomLeftMenu collapsed={this.props.collapsed}/>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = LeftPanel;
|
|
@ -19,8 +19,7 @@ limitations under the License.
|
|||
import * as Matrix from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { DragDropContext } from 'react-dnd';
|
||||
import HTML5Backend from 'react-dnd-html5-backend';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
|
||||
import Notifier from '../../Notifier';
|
||||
|
@ -32,6 +31,9 @@ import sessionStore from '../../stores/SessionStore';
|
|||
import MatrixClientPeg from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
|
||||
import TagOrderActions from '../../actions/TagOrderActions';
|
||||
import RoomListActions from '../../actions/RoomListActions';
|
||||
|
||||
/**
|
||||
* This is what our MatrixChat shows when we are logged in. The precise view is
|
||||
* determined by the page_type property.
|
||||
|
@ -209,8 +211,51 @@ const LoggedInView = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
_onDragEnd: function(result) {
|
||||
// Dragged to an invalid destination, not onto a droppable
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dest = result.destination.droppableId;
|
||||
|
||||
if (dest === 'tag-panel-droppable') {
|
||||
// Could be "GroupTile +groupId:domain"
|
||||
const draggableId = result.draggableId.split(' ').pop();
|
||||
|
||||
// Dispatch synchronously so that the TagPanel receives an
|
||||
// optimistic update from TagOrderStore before the previous
|
||||
// state is shown.
|
||||
dis.dispatch(TagOrderActions.moveTag(
|
||||
this._matrixClient,
|
||||
draggableId,
|
||||
result.destination.index,
|
||||
), true);
|
||||
} else if (dest.startsWith('room-sub-list-droppable_')) {
|
||||
this._onRoomTileEndDrag(result);
|
||||
}
|
||||
},
|
||||
|
||||
_onRoomTileEndDrag: function(result) {
|
||||
let newTag = result.destination.droppableId.split('_')[1];
|
||||
let prevTag = result.source.droppableId.split('_')[1];
|
||||
if (newTag === 'undefined') newTag = undefined;
|
||||
if (prevTag === 'undefined') prevTag = undefined;
|
||||
|
||||
const roomId = result.draggableId.split('_')[1];
|
||||
|
||||
const oldIndex = result.source.index;
|
||||
const newIndex = result.destination.index;
|
||||
|
||||
dis.dispatch(RoomListActions.tagRoom(
|
||||
this._matrixClient,
|
||||
this._matrixClient.getRoom(roomId),
|
||||
prevTag, newTag,
|
||||
oldIndex, newIndex,
|
||||
), true);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const TagPanel = sdk.getComponent('structures.TagPanel');
|
||||
const LeftPanel = sdk.getComponent('structures.LeftPanel');
|
||||
const RightPanel = sdk.getComponent('structures.RightPanel');
|
||||
const RoomView = sdk.getComponent('structures.RoomView');
|
||||
|
@ -221,6 +266,7 @@ const LoggedInView = React.createClass({
|
|||
const GroupView = sdk.getComponent('structures.GroupView');
|
||||
const MyGroups = sdk.getComponent('structures.MyGroups');
|
||||
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
|
||||
const CookieBar = sdk.getComponent('globals.CookieBar');
|
||||
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
|
||||
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
|
||||
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
|
||||
|
@ -308,7 +354,12 @@ const LoggedInView = React.createClass({
|
|||
|
||||
let topBar;
|
||||
const isGuest = this.props.matrixClient.isGuest();
|
||||
if (this.props.hasNewVersion) {
|
||||
if (this.props.showCookieBar &&
|
||||
this.props.config.piwik
|
||||
) {
|
||||
const policyUrl = this.props.config.piwik.policyUrl || null;
|
||||
topBar = <CookieBar policyUrl={policyUrl} />;
|
||||
} else if (this.props.hasNewVersion) {
|
||||
topBar = <NewVersionBar version={this.props.version} newVersion={this.props.newVersion}
|
||||
releaseNotes={this.props.newVersionReleaseNotes}
|
||||
/>;
|
||||
|
@ -329,22 +380,23 @@ const LoggedInView = React.createClass({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='mx_MatrixChat_wrapper'>
|
||||
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers}>
|
||||
{ topBar }
|
||||
<div className={bodyClasses}>
|
||||
{ SettingsStore.isFeatureEnabled("feature_tag_panel") ? <TagPanel /> : <div /> }
|
||||
<LeftPanel
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
<main className='mx_MatrixChat_middlePanel'>
|
||||
{ page_element }
|
||||
</main>
|
||||
{ right_panel }
|
||||
</div>
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
<div className={bodyClasses}>
|
||||
<LeftPanel
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
<main className='mx_MatrixChat_middlePanel'>
|
||||
{ page_element }
|
||||
</main>
|
||||
{ right_panel }
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default DragDropContext(HTML5Backend)(LoggedInView);
|
||||
export default LoggedInView;
|
||||
|
|
93
src/components/structures/LoginBox.js
Normal file
93
src/components/structures/LoginBox.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var React = require('react');
|
||||
import { _t } from '../../languageHandler';
|
||||
var sdk = require('../../index')
|
||||
var dis = require('../../dispatcher');
|
||||
var rate_limited_func = require('../../ratelimitedfunc');
|
||||
var AccessibleButton = require('../../components/views/elements/AccessibleButton');
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'LoginBox',
|
||||
|
||||
propTypes: {
|
||||
collapsed: React.PropTypes.bool,
|
||||
},
|
||||
|
||||
onToggleCollapse: function(show) {
|
||||
if (show) {
|
||||
dis.dispatch({
|
||||
action: 'show_left_panel',
|
||||
});
|
||||
}
|
||||
else {
|
||||
dis.dispatch({
|
||||
action: 'hide_left_panel',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onLoginClick: function() {
|
||||
dis.dispatch({ action: 'start_login' });
|
||||
},
|
||||
|
||||
onRegisterClick: function() {
|
||||
dis.dispatch({ action: 'start_registration' });
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
|
||||
var toggleCollapse;
|
||||
if (this.props.collapsed) {
|
||||
toggleCollapse =
|
||||
<AccessibleButton className="mx_SearchBox_maximise" onClick={ this.onToggleCollapse.bind(this, true) }>
|
||||
<TintableSvg src="img/maximise.svg" width="10" height="16" alt="Expand panel"/>
|
||||
</AccessibleButton>
|
||||
}
|
||||
else {
|
||||
toggleCollapse =
|
||||
<AccessibleButton className="mx_SearchBox_minimise" onClick={ this.onToggleCollapse.bind(this, false) }>
|
||||
<TintableSvg src="img/minimise.svg" width="10" height="16" alt="Collapse panel"/>
|
||||
</AccessibleButton>
|
||||
}
|
||||
|
||||
var loginButton;
|
||||
if (!this.props.collapsed) {
|
||||
loginButton = (
|
||||
<div className="mx_LoginBox_loginButton_wrapper">
|
||||
<AccessibleButton className="mx_LoginBox_loginButton" element="button" onClick={this.onLoginClick}>
|
||||
{ _t("Login") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton className="mx_LoginBox_registerButton" element="button" onClick={this.onRegisterClick}>
|
||||
{ _t("Register") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
var self = this;
|
||||
return (
|
||||
<div className="mx_SearchBox mx_LoginBox">
|
||||
{ loginButton }
|
||||
{ toggleCollapse }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -165,12 +165,18 @@ export default React.createClass({
|
|||
newVersionReleaseNotes: null,
|
||||
checkingForUpdate: null,
|
||||
|
||||
showCookieBar: false,
|
||||
|
||||
// Parameters used in the registration dance with the IS
|
||||
register_client_secret: null,
|
||||
register_session_id: null,
|
||||
register_hs_url: null,
|
||||
register_is_url: null,
|
||||
register_id_sid: null,
|
||||
|
||||
// When showing Modal dialogs we need to set aria-hidden on the root app element
|
||||
// and disable it when there are no dialogs
|
||||
hideToSRUsers: false,
|
||||
};
|
||||
return s;
|
||||
},
|
||||
|
@ -223,8 +229,6 @@ export default React.createClass({
|
|||
componentWillMount: function() {
|
||||
SdkConfig.put(this.props.config);
|
||||
|
||||
if (!SettingsStore.getValue("analyticsOptOut")) Analytics.enable();
|
||||
|
||||
// Used by _viewRoom before getting state from sync
|
||||
this.firstSyncComplete = false;
|
||||
this.firstSyncPromise = Promise.defer();
|
||||
|
@ -287,6 +291,8 @@ export default React.createClass({
|
|||
this.handleResize();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
|
||||
this._pageChanging = false;
|
||||
|
||||
// check we have the right tint applied for this theme.
|
||||
// N.B. we don't call the whole of setTheme() here as we may be
|
||||
// racing with the theme CSS download finishing from index.js
|
||||
|
@ -345,16 +351,26 @@ export default React.createClass({
|
|||
guestIsUrl: this.getCurrentIsUrl(),
|
||||
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
|
||||
});
|
||||
}).catch((e) => {
|
||||
console.error(`Error attempting to load session: ${e}`);
|
||||
return false;
|
||||
}).then((loadedSession) => {
|
||||
if (!loadedSession) {
|
||||
// fall back to showing the login screen
|
||||
dis.dispatch({action: "start_login"});
|
||||
}
|
||||
});
|
||||
}).done();
|
||||
// Note we don't catch errors from this: we catch everything within
|
||||
// loadSession as there's logic there to ask the user if they want
|
||||
// to try logging out.
|
||||
});
|
||||
|
||||
if (SettingsStore.getValue("showCookieBar")) {
|
||||
this.setState({
|
||||
showCookieBar: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||
Analytics.enable();
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -364,13 +380,62 @@ export default React.createClass({
|
|||
window.removeEventListener('resize', this.handleResize);
|
||||
},
|
||||
|
||||
componentDidUpdate: function() {
|
||||
componentWillUpdate: function(props, state) {
|
||||
if (this.shouldTrackPageChange(this.state, state)) {
|
||||
this.startPageChangeTimer();
|
||||
}
|
||||
},
|
||||
|
||||
componentDidUpdate: function(prevProps, prevState) {
|
||||
if (this.shouldTrackPageChange(prevState, this.state)) {
|
||||
const durationMs = this.stopPageChangeTimer();
|
||||
Analytics.trackPageChange(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
this.focusComposer = false;
|
||||
}
|
||||
},
|
||||
|
||||
startPageChangeTimer() {
|
||||
// This shouldn't happen because componentWillUpdate and componentDidUpdate
|
||||
// are used.
|
||||
if (this._pageChanging) {
|
||||
console.warn('MatrixChat.startPageChangeTimer: timer already started');
|
||||
return;
|
||||
}
|
||||
this._pageChanging = true;
|
||||
performance.mark('riot_MatrixChat_page_change_start');
|
||||
},
|
||||
|
||||
stopPageChangeTimer() {
|
||||
if (!this._pageChanging) {
|
||||
console.warn('MatrixChat.stopPageChangeTimer: timer not started');
|
||||
return;
|
||||
}
|
||||
this._pageChanging = false;
|
||||
performance.mark('riot_MatrixChat_page_change_stop');
|
||||
performance.measure(
|
||||
'riot_MatrixChat_page_change_delta',
|
||||
'riot_MatrixChat_page_change_start',
|
||||
'riot_MatrixChat_page_change_stop',
|
||||
);
|
||||
performance.clearMarks('riot_MatrixChat_page_change_start');
|
||||
performance.clearMarks('riot_MatrixChat_page_change_stop');
|
||||
const measurement = performance.getEntriesByName('riot_MatrixChat_page_change_delta').pop();
|
||||
|
||||
// In practice, sometimes the entries list is empty, so we get no measurement
|
||||
if (!measurement) return null;
|
||||
|
||||
return measurement.duration;
|
||||
},
|
||||
|
||||
shouldTrackPageChange(prevState, state) {
|
||||
return prevState.currentRoomId !== state.currentRoomId ||
|
||||
prevState.view !== state.view ||
|
||||
prevState.page_type !== state.page_type;
|
||||
},
|
||||
|
||||
setStateForNewView: function(state) {
|
||||
if (state.view === undefined) {
|
||||
throw new Error("setStateForNewView with no view!");
|
||||
|
@ -608,6 +673,33 @@ export default React.createClass({
|
|||
case 'send_event':
|
||||
this.onSendEvent(payload.room_id, payload.event);
|
||||
break;
|
||||
case 'aria_hide_main_app':
|
||||
this.setState({
|
||||
hideToSRUsers: true,
|
||||
});
|
||||
break;
|
||||
case 'aria_unhide_main_app':
|
||||
this.setState({
|
||||
hideToSRUsers: false,
|
||||
});
|
||||
break;
|
||||
case 'accept_cookies':
|
||||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true);
|
||||
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
|
||||
|
||||
this.setState({
|
||||
showCookieBar: false,
|
||||
});
|
||||
Analytics.enable();
|
||||
break;
|
||||
case 'reject_cookies':
|
||||
SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false);
|
||||
SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false);
|
||||
|
||||
this.setState({
|
||||
showCookieBar: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -618,18 +710,26 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
_startRegistration: function(params) {
|
||||
this.setStateForNewView({
|
||||
const newState = {
|
||||
view: VIEWS.REGISTER,
|
||||
// these params may be undefined, but if they are,
|
||||
// unset them from our state: we don't want to
|
||||
// resume a previous registration session if the
|
||||
// user just clicked 'register'
|
||||
register_client_secret: params.client_secret,
|
||||
register_session_id: params.session_id,
|
||||
register_hs_url: params.hs_url,
|
||||
register_is_url: params.is_url,
|
||||
register_id_sid: params.sid,
|
||||
});
|
||||
};
|
||||
|
||||
// Only honour params if they are all present, otherwise we reset
|
||||
// HS and IS URLs when switching to registration.
|
||||
if (params.client_secret &&
|
||||
params.session_id &&
|
||||
params.hs_url &&
|
||||
params.is_url &&
|
||||
params.sid
|
||||
) {
|
||||
newState.register_client_secret = params.client_secret;
|
||||
newState.register_session_id = params.session_id;
|
||||
newState.register_hs_url = params.hs_url;
|
||||
newState.register_is_url = params.is_url;
|
||||
newState.register_id_sid = params.sid;
|
||||
}
|
||||
|
||||
this.setStateForNewView(newState);
|
||||
this.notifyNewScreen('register');
|
||||
},
|
||||
|
||||
|
@ -847,16 +947,37 @@ export default React.createClass({
|
|||
}).close;
|
||||
},
|
||||
|
||||
_leaveRoomWarnings: function(roomId) {
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
// Show a warning if there are additional complications.
|
||||
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
|
||||
const warnings = [];
|
||||
if (joinRules) {
|
||||
const rule = joinRules.getContent().join_rule;
|
||||
if (rule !== "public") {
|
||||
warnings.push((
|
||||
<span className="warning" key="non_public_warning">
|
||||
{' '/* Whitespace, otherwise the sentences get smashed together */ }
|
||||
{ _t("This room is not public. You will not be able to rejoin without an invite.") }
|
||||
</span>
|
||||
));
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
},
|
||||
|
||||
_leaveRoom: function(roomId) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
|
||||
const warnings = this._leaveRoomWarnings(roomId);
|
||||
|
||||
Modal.createTrackedDialog('Leave room', '', QuestionDialog, {
|
||||
title: _t("Leave room"),
|
||||
description: (
|
||||
<span>
|
||||
{ _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
|
||||
{ warnings }
|
||||
</span>
|
||||
),
|
||||
onFinished: (shouldLeave) => {
|
||||
|
@ -1066,10 +1187,10 @@ export default React.createClass({
|
|||
// this if we are not scrolled up in the view. To find out, delegate to
|
||||
// the timeline panel. If the timeline panel doesn't exist, then we assume
|
||||
// it is safe to reset the timeline.
|
||||
if (!self._loggedInView) {
|
||||
if (!self._loggedInView || !self._loggedInView.child) {
|
||||
return true;
|
||||
}
|
||||
return self._loggedInView.getDecoratedComponentInstance().canResetTimelineInRoom(roomId);
|
||||
return self._loggedInView.child.canResetTimelineInRoom(roomId);
|
||||
});
|
||||
|
||||
cli.on('sync', function(state, prevState) {
|
||||
|
@ -1111,6 +1232,28 @@ export default React.createClass({
|
|||
action: 'logout',
|
||||
});
|
||||
});
|
||||
cli.on('no_consent', function(message, consentUri) {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('No Consent Dialog', '', QuestionDialog, {
|
||||
title: _t('Terms and Conditions'),
|
||||
description: <div>
|
||||
<p> { _t(
|
||||
'To continue using the %(homeserverDomain)s homeserver ' +
|
||||
'you must review and agree to our terms and conditions.',
|
||||
{ homeserverDomain: cli.getDomain() },
|
||||
) }
|
||||
</p>
|
||||
</div>,
|
||||
button: _t('Review terms and conditions'),
|
||||
cancelButton: _t('Dismiss'),
|
||||
onFinished: (confirmed) => {
|
||||
if (confirmed) {
|
||||
window.open(consentUri, '_blank');
|
||||
}
|
||||
},
|
||||
}, null, true);
|
||||
});
|
||||
|
||||
cli.on("accountData", function(ev) {
|
||||
if (ev.getType() === 'im.vector.web.settings') {
|
||||
if (ev.getContent() && ev.getContent().theme) {
|
||||
|
@ -1149,18 +1292,6 @@ export default React.createClass({
|
|||
cli.on("crypto.warning", (type) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
switch (type) {
|
||||
case 'CRYPTO_WARNING_ACCOUNT_MIGRATED':
|
||||
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
|
||||
title: _t('Cryptography data migrated'),
|
||||
description: _t(
|
||||
"A one-off migration of cryptography data has been performed. "+
|
||||
"End-to-end encryption will not work if you go back to an older "+
|
||||
"version of Riot. If you need to use end-to-end cryptography on "+
|
||||
"an older version, log out of Riot first. To retain message history, "+
|
||||
"export and re-import your keys.",
|
||||
),
|
||||
});
|
||||
break;
|
||||
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
|
||||
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
|
||||
title: _t('Old cryptography data detected'),
|
||||
|
@ -1317,7 +1448,6 @@ export default React.createClass({
|
|||
if (this.props.onNewScreen) {
|
||||
this.props.onNewScreen(screen);
|
||||
}
|
||||
Analytics.trackPageChange();
|
||||
},
|
||||
|
||||
onAliasClick: function(event, alias) {
|
||||
|
@ -1487,6 +1617,17 @@ export default React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onServerConfigChange(config) {
|
||||
const newState = {};
|
||||
if (config.hsUrl) {
|
||||
newState.register_hs_url = config.hsUrl;
|
||||
}
|
||||
if (config.isUrl) {
|
||||
newState.register_is_url = config.isUrl;
|
||||
}
|
||||
this.setState(newState);
|
||||
},
|
||||
|
||||
_makeRegistrationUrl: function(params) {
|
||||
if (this.props.startingFragmentQueryParams.referrer) {
|
||||
params.referrer = this.props.startingFragmentQueryParams.referrer;
|
||||
|
@ -1536,6 +1677,7 @@ export default React.createClass({
|
|||
onRegistered={this.onRegistered}
|
||||
currentRoomId={this.state.currentRoomId}
|
||||
teamToken={this._teamToken}
|
||||
showCookieBar={this.state.showCookieBar}
|
||||
{...this.props}
|
||||
{...this.state}
|
||||
/>
|
||||
|
@ -1575,6 +1717,7 @@ export default React.createClass({
|
|||
onLoginClick={this.onLoginClick}
|
||||
onRegisterClick={this.onRegisterClick}
|
||||
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1609,6 +1752,7 @@ export default React.createClass({
|
|||
onForgotPasswordClick={this.onForgotPasswordClick}
|
||||
enableGuest={this.props.enableGuest}
|
||||
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
|
||||
onServerConfigChange={this.onServerConfigChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -325,7 +325,7 @@ module.exports = React.createClass({
|
|||
const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial");
|
||||
|
||||
if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
|
||||
const dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1} showTwelveHour={this.props.isTwelveHour} /></li>;
|
||||
const dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1} /></li>;
|
||||
ret.push(dateSeparator);
|
||||
}
|
||||
|
||||
|
@ -447,10 +447,18 @@ module.exports = React.createClass({
|
|||
// is this a continuation of the previous message?
|
||||
let continuation = false;
|
||||
|
||||
// Some events should appear as continuations from previous events of
|
||||
// different types.
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
const eventTypeContinues =
|
||||
prevEvent !== null &&
|
||||
continuedTypes.includes(mxEv.getType()) &&
|
||||
continuedTypes.includes(prevEvent.getType());
|
||||
|
||||
if (prevEvent !== null
|
||||
&& prevEvent.sender && mxEv.sender
|
||||
&& mxEv.sender.userId === prevEvent.sender.userId
|
||||
&& mxEv.getType() == prevEvent.getType()) {
|
||||
&& (mxEv.getType() == prevEvent.getType() || eventTypeContinues)) {
|
||||
continuation = true;
|
||||
}
|
||||
|
||||
|
@ -479,7 +487,7 @@ module.exports = React.createClass({
|
|||
|
||||
// do we need a date separator since the last event?
|
||||
if (this._wantsDateSeparator(prevEvent, eventDate)) {
|
||||
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} showTwelveHour={this.props.isTwelveHour} /></li>;
|
||||
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
|
||||
ret.push(dateSeparator);
|
||||
continuation = false;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
import sdk from '../../index';
|
||||
import { _t } from '../../languageHandler';
|
||||
import dis from '../../dispatcher';
|
||||
|
@ -63,6 +62,8 @@ export default withMatrixClient(React.createClass({
|
|||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const GroupTile = sdk.getComponent("groups.GroupTile");
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
|
||||
let content;
|
||||
let contentHeader;
|
||||
|
@ -73,9 +74,26 @@ export default withMatrixClient(React.createClass({
|
|||
});
|
||||
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
|
||||
content = groupNodes.length > 0 ?
|
||||
<GeminiScrollbar className="mx_MyGroups_joinedGroups">
|
||||
{ groupNodes }
|
||||
</GeminiScrollbar> :
|
||||
<GeminiScrollbarWrapper>
|
||||
<div className="mx_MyGroups_microcopy">
|
||||
<p>
|
||||
{ _t(
|
||||
"Did you know: you can use communities to filter your Riot.im experience!",
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
"To set up a filter, drag a community avatar over to the filter panel on " +
|
||||
"the far left hand side of the screen. You can click on an avatar in the " +
|
||||
"filter panel at any time to see only the rooms and people associated " +
|
||||
"with that community.",
|
||||
) }
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx_MyGroups_joinedGroups">
|
||||
{ groupNodes }
|
||||
</div>
|
||||
</GeminiScrollbarWrapper> :
|
||||
<div className="mx_MyGroups_placeholder">
|
||||
{ _t(
|
||||
"You're not currently a member of any communities.",
|
||||
|
|
421
src/components/structures/RightPanel.js
Normal file
421
src/components/structures/RightPanel.js
Normal file
|
@ -0,0 +1,421 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../languageHandler';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import Analytics from '../../Analytics';
|
||||
import RateLimitedFunc from '../../ratelimitedfunc';
|
||||
import AccessibleButton from '../../components/views/elements/AccessibleButton';
|
||||
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
|
||||
import GroupStore from '../../stores/GroupStore';
|
||||
|
||||
import { formatCount } from '../../utils/FormattingUtils';
|
||||
|
||||
class HeaderButton extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(ev) {
|
||||
Analytics.trackEvent(...this.props.analytics);
|
||||
dis.dispatch({
|
||||
action: 'view_right_panel_phase',
|
||||
phase: this.props.clickPhase,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
||||
|
||||
return <AccessibleButton
|
||||
aria-label={this.props.title}
|
||||
title={this.props.title}
|
||||
className="mx_RightPanel_headerButton"
|
||||
onClick={this.onClick} >
|
||||
|
||||
<div className="mx_RightPanel_headerButton_badge">
|
||||
{ this.props.badge ? this.props.badge : <span> </span> }
|
||||
</div>
|
||||
<TintableSvg src={this.props.iconSrc} width="25" height="25" />
|
||||
{ this.props.isHighlighted ? <div className="mx_RightPanel_headerButton_highlight" /> : <div /> }
|
||||
|
||||
</AccessibleButton>;
|
||||
}
|
||||
}
|
||||
|
||||
HeaderButton.propTypes = {
|
||||
// Whether this button is highlighted
|
||||
isHighlighted: PropTypes.bool.isRequired,
|
||||
// The phase to swap to when the button is clicked
|
||||
clickPhase: PropTypes.string.isRequired,
|
||||
// The source file of the icon to display
|
||||
iconSrc: PropTypes.string.isRequired,
|
||||
|
||||
// The badge to display above the icon
|
||||
badge: PropTypes.node,
|
||||
// The parameters to track the click event
|
||||
analytics: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
|
||||
// Button title
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'RightPanel',
|
||||
|
||||
propTypes: {
|
||||
// TODO: We're trying to move away from these being props, but we need to know
|
||||
// whether we should be displaying a room or group member list
|
||||
roomId: React.PropTypes.string, // if showing panels for a given room, this is set
|
||||
groupId: React.PropTypes.string, // if showing panels for a given group, this is set
|
||||
collapsed: React.PropTypes.bool, // currently unused property to request for a minimized view of the panel
|
||||
},
|
||||
|
||||
contextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient),
|
||||
},
|
||||
|
||||
Phase: {
|
||||
RoomMemberList: 'RoomMemberList',
|
||||
GroupMemberList: 'GroupMemberList',
|
||||
GroupRoomList: 'GroupRoomList',
|
||||
GroupRoomInfo: 'GroupRoomInfo',
|
||||
FilePanel: 'FilePanel',
|
||||
NotificationPanel: 'NotificationPanel',
|
||||
RoomMemberInfo: 'RoomMemberInfo',
|
||||
GroupMemberInfo: 'GroupMemberInfo',
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
const cli = this.context.matrixClient;
|
||||
cli.on("RoomState.members", this.onRoomStateMember);
|
||||
this._initGroupStore(this.props.groupId);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
if (this.context.matrixClient) {
|
||||
this.context.matrixClient.removeListener("RoomState.members", this.onRoomStateMember);
|
||||
}
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
phase: this.props.groupId ? this.Phase.GroupMemberList : this.Phase.RoomMemberList,
|
||||
isUserPrivilegedInGroup: null,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
this._initGroupStore(newProps.groupId);
|
||||
}
|
||||
},
|
||||
|
||||
_initGroupStore(groupId) {
|
||||
if (!groupId) return;
|
||||
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
|
||||
},
|
||||
|
||||
_unregisterGroupStore() {
|
||||
GroupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
},
|
||||
|
||||
onGroupStoreUpdated: function() {
|
||||
this.setState({
|
||||
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
|
||||
});
|
||||
},
|
||||
|
||||
onCollapseClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'hide_right_panel',
|
||||
});
|
||||
},
|
||||
|
||||
onInviteButtonClick: function() {
|
||||
if (this.context.matrixClient.isGuest()) {
|
||||
dis.dispatch({action: 'view_set_mxid'});
|
||||
return;
|
||||
}
|
||||
|
||||
// call AddressPickerDialog
|
||||
dis.dispatch({
|
||||
action: 'view_invite',
|
||||
roomId: this.props.roomId,
|
||||
});
|
||||
},
|
||||
|
||||
onInviteToGroupButtonClick: function() {
|
||||
showGroupInviteDialog(this.props.groupId).then(() => {
|
||||
this.setState({
|
||||
phase: this.Phase.GroupMemberList,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
onAddRoomToGroupButtonClick: function() {
|
||||
showGroupAddRoomDialog(this.props.groupId).then(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
},
|
||||
|
||||
onRoomStateMember: function(ev, state, member) {
|
||||
// redraw the badge on the membership list
|
||||
if (this.state.phase === this.Phase.RoomMemberList && member.roomId === this.props.roomId) {
|
||||
this._delayedUpdate();
|
||||
} else if (this.state.phase === this.Phase.RoomMemberInfo && member.roomId === this.props.roomId &&
|
||||
member.userId === this.state.member.userId) {
|
||||
// refresh the member info (e.g. new power level)
|
||||
this._delayedUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
_delayedUpdate: new RateLimitedFunc(function() {
|
||||
this.forceUpdate(); // eslint-disable-line babel/no-invalid-this
|
||||
}, 500),
|
||||
|
||||
onAction: function(payload) {
|
||||
if (payload.action === "view_user") {
|
||||
dis.dispatch({
|
||||
action: 'show_right_panel',
|
||||
});
|
||||
if (payload.member) {
|
||||
this.setState({
|
||||
phase: this.Phase.RoomMemberInfo,
|
||||
member: payload.member,
|
||||
});
|
||||
} else {
|
||||
if (this.props.roomId) {
|
||||
this.setState({
|
||||
phase: this.Phase.RoomMemberList,
|
||||
});
|
||||
} else if (this.props.groupId) {
|
||||
this.setState({
|
||||
phase: this.Phase.GroupMemberList,
|
||||
member: payload.member,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (payload.action === "view_group") {
|
||||
this.setState({
|
||||
phase: this.Phase.GroupMemberList,
|
||||
member: null,
|
||||
});
|
||||
} else if (payload.action === "view_group_room") {
|
||||
this.setState({
|
||||
phase: this.Phase.GroupRoomInfo,
|
||||
groupRoomId: payload.groupRoomId,
|
||||
});
|
||||
} else if (payload.action === "view_group_room_list") {
|
||||
this.setState({
|
||||
phase: this.Phase.GroupRoomList,
|
||||
});
|
||||
} else if (payload.action === "view_group_member_list") {
|
||||
this.setState({
|
||||
phase: this.Phase.GroupMemberList,
|
||||
});
|
||||
} else if (payload.action === "view_group_user") {
|
||||
this.setState({
|
||||
phase: this.Phase.GroupMemberInfo,
|
||||
member: payload.member,
|
||||
});
|
||||
} else if (payload.action === "view_room") {
|
||||
this.setState({
|
||||
phase: this.Phase.RoomMemberList,
|
||||
});
|
||||
} else if (payload.action === "view_right_panel_phase") {
|
||||
this.setState({
|
||||
phase: payload.phase,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const MemberList = sdk.getComponent('rooms.MemberList');
|
||||
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
|
||||
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
|
||||
const FilePanel = sdk.getComponent('structures.FilePanel');
|
||||
|
||||
const GroupMemberList = sdk.getComponent('groups.GroupMemberList');
|
||||
const GroupMemberInfo = sdk.getComponent('groups.GroupMemberInfo');
|
||||
const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
|
||||
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
|
||||
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
let inviteGroup;
|
||||
|
||||
let membersBadge;
|
||||
let membersTitle = _t('Members');
|
||||
if ((this.state.phase === this.Phase.RoomMemberList || this.state.phase === this.Phase.RoomMemberInfo)
|
||||
&& this.props.roomId
|
||||
) {
|
||||
const cli = this.context.matrixClient;
|
||||
const room = cli.getRoom(this.props.roomId);
|
||||
let isUserInRoom;
|
||||
if (room) {
|
||||
const numMembers = room.getJoinedMembers().length;
|
||||
membersTitle = _t('%(count)s Members', { count: numMembers });
|
||||
membersBadge = <div title={membersTitle}>{ formatCount(numMembers) }</div>;
|
||||
isUserInRoom = room.hasMembershipState(this.context.matrixClient.credentials.userId, 'join');
|
||||
}
|
||||
|
||||
if (isUserInRoom) {
|
||||
inviteGroup =
|
||||
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onInviteButtonClick}>
|
||||
<div className="mx_RightPanel_icon" >
|
||||
<TintableSvg src="img/icon-invite-people.svg" width="35" height="35" />
|
||||
</div>
|
||||
<div className="mx_RightPanel_message">{ _t('Invite to this room') }</div>
|
||||
</AccessibleButton>;
|
||||
}
|
||||
}
|
||||
|
||||
const isPhaseGroup = [
|
||||
this.Phase.GroupMemberInfo,
|
||||
this.Phase.GroupMemberList,
|
||||
].includes(this.state.phase);
|
||||
|
||||
let headerButtons = [];
|
||||
if (this.props.roomId) {
|
||||
headerButtons = [
|
||||
<HeaderButton key="_membersButton" title={membersTitle} iconSrc="img/icons-people.svg"
|
||||
isHighlighted={[this.Phase.RoomMemberList, this.Phase.RoomMemberInfo].includes(this.state.phase)}
|
||||
clickPhase={this.Phase.RoomMemberList}
|
||||
badge={membersBadge}
|
||||
analytics={['Right Panel', 'Member List Button', 'click']}
|
||||
/>,
|
||||
<HeaderButton key="_filesButton" title={_t('Files')} iconSrc="img/icons-files.svg"
|
||||
isHighlighted={this.state.phase === this.Phase.FilePanel}
|
||||
clickPhase={this.Phase.FilePanel}
|
||||
analytics={['Right Panel', 'File List Button', 'click']}
|
||||
/>,
|
||||
<HeaderButton key="_notifsButton" title={_t('Notifications')} iconSrc="img/icons-notifications.svg"
|
||||
isHighlighted={this.state.phase === this.Phase.NotificationPanel}
|
||||
clickPhase={this.Phase.NotificationPanel}
|
||||
analytics={['Right Panel', 'Notification List Button', 'click']}
|
||||
/>,
|
||||
];
|
||||
} else if (this.props.groupId) {
|
||||
headerButtons = [
|
||||
<HeaderButton key="_groupMembersButton" title={_t('Members')} iconSrc="img/icons-people.svg"
|
||||
isHighlighted={isPhaseGroup}
|
||||
clickPhase={this.Phase.GroupMemberList}
|
||||
analytics={['Right Panel', 'Group Member List Button', 'click']}
|
||||
/>,
|
||||
<HeaderButton key="_roomsButton" title={_t('Rooms')} iconSrc="img/icons-room.svg"
|
||||
isHighlighted={[this.Phase.GroupRoomList, this.Phase.GroupRoomInfo].includes(this.state.phase)}
|
||||
clickPhase={this.Phase.GroupRoomList}
|
||||
analytics={['Right Panel', 'Group Room List Button', 'click']}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
|
||||
if (this.props.roomId || this.props.groupId) {
|
||||
// Hiding the right panel hides it completely and relies on an 'expand' button
|
||||
// being put in the RoomHeader or GroupView header, so only show the minimise
|
||||
// button on these 2 screens or you won't be able to re-expand the panel.
|
||||
headerButtons.push(
|
||||
<div className="mx_RightPanel_headerButton mx_RightPanel_collapsebutton" key="_minimizeButton"
|
||||
title={_t("Hide panel")} aria-label={_t("Hide panel")} onClick={this.onCollapseClick}
|
||||
>
|
||||
<TintableSvg src="img/minimise.svg" width="10" height="16" />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
let panel = <div />;
|
||||
if (!this.props.collapsed) {
|
||||
if (this.props.roomId && this.state.phase === this.Phase.RoomMemberList) {
|
||||
panel = <MemberList roomId={this.props.roomId} key={this.props.roomId} />;
|
||||
} else if (this.props.groupId && this.state.phase === this.Phase.GroupMemberList) {
|
||||
panel = <GroupMemberList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
} else if (this.state.phase === this.Phase.GroupRoomList) {
|
||||
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
|
||||
} else if (this.state.phase === this.Phase.RoomMemberInfo) {
|
||||
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
|
||||
} else if (this.state.phase === this.Phase.GroupMemberInfo) {
|
||||
panel = <GroupMemberInfo
|
||||
groupMember={this.state.member}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.member.user_id} />;
|
||||
} else if (this.state.phase === this.Phase.GroupRoomInfo) {
|
||||
panel = <GroupRoomInfo
|
||||
groupRoomId={this.state.groupRoomId}
|
||||
groupId={this.props.groupId}
|
||||
key={this.state.groupRoomId} />;
|
||||
} else if (this.state.phase === this.Phase.NotificationPanel) {
|
||||
panel = <NotificationPanel />;
|
||||
} else if (this.state.phase === this.Phase.FilePanel) {
|
||||
panel = <FilePanel roomId={this.props.roomId} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (!panel) {
|
||||
panel = <div className="mx_RightPanel_blank" />;
|
||||
}
|
||||
|
||||
if (this.props.groupId && this.state.isUserPrivilegedInGroup) {
|
||||
inviteGroup = isPhaseGroup ? (
|
||||
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onInviteToGroupButtonClick}>
|
||||
<div className="mx_RightPanel_icon" >
|
||||
<TintableSvg src="img/icon-invite-people.svg" width="35" height="35" />
|
||||
</div>
|
||||
<div className="mx_RightPanel_message">{ _t('Invite to this community') }</div>
|
||||
</AccessibleButton>
|
||||
) : (
|
||||
<AccessibleButton className="mx_RightPanel_invite" onClick={this.onAddRoomToGroupButtonClick}>
|
||||
<div className="mx_RightPanel_icon" >
|
||||
<TintableSvg src="img/icons-room-add.svg" width="35" height="35" />
|
||||
</div>
|
||||
<div className="mx_RightPanel_message">{ _t('Add rooms to this community') }</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = classNames("mx_RightPanel", "mx_fadable", {
|
||||
"collapsed": this.props.collapsed,
|
||||
"mx_fadable_faded": this.props.disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className={classes}>
|
||||
<div className="mx_RightPanel_header">
|
||||
<div className="mx_RightPanel_headerButtonGroup">
|
||||
{ headerButtons }
|
||||
</div>
|
||||
</div>
|
||||
{ panel }
|
||||
<div className="mx_RightPanel_footer">
|
||||
{ inviteGroup }
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
},
|
||||
});
|
587
src/components/structures/RoomDirectory.js
Normal file
587
src/components/structures/RoomDirectory.js
Normal file
|
@ -0,0 +1,587 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var React = require('react');
|
||||
|
||||
var MatrixClientPeg = require('../../MatrixClientPeg');
|
||||
var ContentRepo = require("matrix-js-sdk").ContentRepo;
|
||||
var Modal = require('../../Modal');
|
||||
var sdk = require('../../index');
|
||||
var dis = require('../../dispatcher');
|
||||
|
||||
var linkify = require('linkifyjs');
|
||||
var linkifyString = require('linkifyjs/string');
|
||||
var linkifyMatrix = require('../../linkify-matrix');
|
||||
var sanitizeHtml = require('sanitize-html');
|
||||
import Promise from 'bluebird';
|
||||
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
import {instanceForInstanceId, protocolNameForInstanceId} from '../../utils/DirectoryUtils';
|
||||
|
||||
linkifyMatrix(linkify);
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'RoomDirectory',
|
||||
|
||||
propTypes: {
|
||||
config: React.PropTypes.object,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
config: {},
|
||||
}
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
protocolsLoading: true,
|
||||
instanceId: null,
|
||||
includeAll: false,
|
||||
roomServer: null,
|
||||
filterString: null,
|
||||
}
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this.nextBatch = null;
|
||||
this.filterTimeout = null;
|
||||
this.scrollPanel = null;
|
||||
this.protocols = null;
|
||||
|
||||
this.setState({protocolsLoading: true});
|
||||
MatrixClientPeg.get().getThirdpartyProtocols().done((response) => {
|
||||
this.protocols = response;
|
||||
this.setState({protocolsLoading: false});
|
||||
}, (err) => {
|
||||
console.warn(`error loading thirdparty protocols: ${err}`);
|
||||
this.setState({protocolsLoading: false});
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
// Guests currently aren't allowed to use this API, so
|
||||
// ignore this as otherwise this error is literally the
|
||||
// thing you see when loading the client!
|
||||
return;
|
||||
}
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to get protocol list from Home Server', '', ErrorDialog, {
|
||||
title: _t('Failed to get protocol list from Home Server'),
|
||||
description: _t('The Home Server may be too old to support third party networks'),
|
||||
});
|
||||
});
|
||||
|
||||
// dis.dispatch({
|
||||
// action: 'panel_disable',
|
||||
// sideDisabled: true,
|
||||
// middleDisabled: true,
|
||||
// });
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
// dis.dispatch({
|
||||
// action: 'panel_disable',
|
||||
// sideDisabled: false,
|
||||
// middleDisabled: false,
|
||||
// });
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this._unmounted = true;
|
||||
},
|
||||
|
||||
refreshRoomList: function() {
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
});
|
||||
this.getMoreRooms().done();
|
||||
},
|
||||
|
||||
getMoreRooms: function() {
|
||||
if (!MatrixClientPeg.get()) return Promise.resolve();
|
||||
|
||||
const my_filter_string = this.state.filterString;
|
||||
const my_server = this.state.roomServer;
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const my_next_batch = this.nextBatch;
|
||||
const opts = {limit: 20};
|
||||
if (my_server != MatrixClientPeg.getHomeServerName()) {
|
||||
opts.server = my_server;
|
||||
}
|
||||
if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId;
|
||||
} else if (this.state.includeAll) {
|
||||
opts.include_all_networks = true;
|
||||
}
|
||||
if (this.nextBatch) opts.since = this.nextBatch;
|
||||
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string } ;
|
||||
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
|
||||
if (
|
||||
my_filter_string != this.state.filterString ||
|
||||
my_server != this.state.roomServer ||
|
||||
my_next_batch != this.nextBatch)
|
||||
{
|
||||
// if the filter or server has changed since this request was sent,
|
||||
// throw away the result (don't even clear the busy flag
|
||||
// since we must still have a request in flight)
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return;
|
||||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.setState((s) => {
|
||||
s.publicRooms.push(...data.chunk);
|
||||
s.loading = false;
|
||||
return s;
|
||||
});
|
||||
return Boolean(data.next_batch);
|
||||
}, (err) => {
|
||||
if (
|
||||
my_filter_string != this.state.filterString ||
|
||||
my_server != this.state.roomServer ||
|
||||
my_next_batch != this.nextBatch)
|
||||
{
|
||||
// as above: we don't care about errors for old
|
||||
// requests either
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._unmounted) {
|
||||
// if we've been unmounted, we don't care either.
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ loading: false });
|
||||
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
|
||||
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to get public room list', '', ErrorDialog, {
|
||||
title: _t('Failed to get public room list'),
|
||||
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded'))
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* A limited interface for removing rooms from the directory.
|
||||
* Will set the room to not be publicly visible and delete the
|
||||
* default alias. In the long term, it would be better to allow
|
||||
* HS admins to do this through the RoomSettings interface, but
|
||||
* this needs SPEC-417.
|
||||
*/
|
||||
removeFromDirectory: function(room) {
|
||||
var alias = get_display_alias_for_room(room);
|
||||
var name = room.name || alias || _t('Unnamed room');
|
||||
|
||||
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
var desc;
|
||||
if (alias) {
|
||||
desc = _t('Delete the room alias %(alias)s and remove %(name)s from the directory?', {alias: alias, name: name});
|
||||
} else {
|
||||
desc = _t('Remove %(name)s from the directory?', {name: name});
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
|
||||
title: _t('Remove from Directory'),
|
||||
description: desc,
|
||||
onFinished: (should_delete) => {
|
||||
if (!should_delete) return;
|
||||
|
||||
var Loader = sdk.getComponent("elements.Spinner");
|
||||
var modal = Modal.createDialog(Loader);
|
||||
var step = _t('remove %(name)s from the directory.', {name: name});
|
||||
|
||||
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
|
||||
if (!alias) return;
|
||||
step = _t('delete the alias.');
|
||||
return MatrixClientPeg.get().deleteAlias(alias);
|
||||
}).done(() => {
|
||||
modal.close();
|
||||
this.refreshRoomList();
|
||||
}, (err) => {
|
||||
modal.close();
|
||||
this.refreshRoomList();
|
||||
console.error("Failed to " + step + ": " + err);
|
||||
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded'))
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onRoomClicked: function(room, ev) {
|
||||
if (ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.removeFromDirectory(room);
|
||||
} else {
|
||||
this.showRoom(room);
|
||||
}
|
||||
},
|
||||
|
||||
onOptionChange: function(server, instanceId, includeAll) {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
// Clear the public rooms out here otherwise we needlessly
|
||||
// spend time filtering lots of rooms when we're about to
|
||||
// to clear the list anyway.
|
||||
publicRooms: [],
|
||||
roomServer: server,
|
||||
instanceId: instanceId,
|
||||
includeAll: includeAll,
|
||||
}, this.refreshRoomList);
|
||||
// We also refresh the room list each time even though this
|
||||
// filtering is client-side. It hopefully won't be client side
|
||||
// for very long, and we may have fetched a thousand rooms to
|
||||
// find the five gitter ones, at which point we do not want
|
||||
// to render all those rooms when switching back to 'all networks'.
|
||||
// Easiest to just blow away the state & re-fetch.
|
||||
},
|
||||
|
||||
onFillRequest: function(backwards) {
|
||||
if (backwards || !this.nextBatch) return Promise.resolve(false);
|
||||
|
||||
return this.getMoreRooms();
|
||||
},
|
||||
|
||||
onFilterChange: function(alias) {
|
||||
this.setState({
|
||||
filterString: alias || null,
|
||||
});
|
||||
|
||||
// don't send the request for a little bit,
|
||||
// no point hammering the server with a
|
||||
// request for every keystroke, let the
|
||||
// user finish typing.
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
this.filterTimeout = setTimeout(() => {
|
||||
this.filterTimeout = null;
|
||||
this.refreshRoomList();
|
||||
}, 700);
|
||||
},
|
||||
|
||||
onFilterClear: function() {
|
||||
// update immediately
|
||||
this.setState({
|
||||
filterString: null,
|
||||
}, this.refreshRoomList);
|
||||
|
||||
if (this.filterTimeout) {
|
||||
clearTimeout(this.filterTimeout);
|
||||
}
|
||||
},
|
||||
|
||||
onJoinClick: function(alias) {
|
||||
// If we don't have a particular instance id selected, just show that rooms alias
|
||||
if (!this.state.instanceId) {
|
||||
// If the user specified an alias without a domain, add on whichever server is selected
|
||||
// in the dropdown
|
||||
if (alias.indexOf(':') == -1) {
|
||||
alias = alias + ':' + this.state.roomServer;
|
||||
}
|
||||
this.showRoomAlias(alias);
|
||||
} else {
|
||||
// This is a 3rd party protocol. Let's see if we can join it
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null;
|
||||
if (!fields) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
|
||||
title: _t('Unable to join network'),
|
||||
description: _t('Riot does not know how to join a room on this network'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => {
|
||||
if (resp.length > 0 && resp[0].alias) {
|
||||
this.showRoomAlias(resp[0].alias);
|
||||
} else {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
|
||||
title: _t('Room not found'),
|
||||
description: _t('Couldn\'t find a matching Matrix room'),
|
||||
});
|
||||
}
|
||||
}, (e) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
|
||||
title: _t('Fetching third party location failed'),
|
||||
description: _t('Unable to look up room ID from server'),
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
showRoomAlias: function(alias) {
|
||||
this.showRoom(null, alias);
|
||||
},
|
||||
|
||||
showRoom: function(room, room_alias) {
|
||||
var payload = {action: 'view_room'};
|
||||
if (room) {
|
||||
// Don't let the user view a room they won't be able to either
|
||||
// peek or join: fail earlier so they don't have to click back
|
||||
// to the directory.
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
if (!room.world_readable && !room.guest_can_join) {
|
||||
dis.dispatch({action: 'view_set_mxid'});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!room_alias) {
|
||||
room_alias = get_display_alias_for_room(room);
|
||||
}
|
||||
|
||||
payload.oob_data = {
|
||||
avatarUrl: room.avatar_url,
|
||||
// XXX: This logic is duplicated from the JS SDK which
|
||||
// would normally decide what the name is.
|
||||
name: room.name || room_alias || _t('Unnamed room'),
|
||||
};
|
||||
}
|
||||
// It's not really possible to join Matrix rooms by ID because the HS has no way to know
|
||||
// which servers to start querying. However, there's no other way to join rooms in
|
||||
// this list without aliases at present, so if roomAlias isn't set here we have no
|
||||
// choice but to supply the ID.
|
||||
if (room_alias) {
|
||||
payload.room_alias = room_alias;
|
||||
} else {
|
||||
payload.room_id = room.room_id;
|
||||
}
|
||||
dis.dispatch(payload);
|
||||
},
|
||||
|
||||
getRows: function() {
|
||||
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
|
||||
|
||||
if (!this.state.publicRooms) return [];
|
||||
|
||||
var rooms = this.state.publicRooms;
|
||||
var rows = [];
|
||||
var self = this;
|
||||
var guestRead, guestJoin, perms;
|
||||
for (var i = 0; i < rooms.length; i++) {
|
||||
var name = rooms[i].name || get_display_alias_for_room(rooms[i]) || _t('Unnamed room');
|
||||
guestRead = null;
|
||||
guestJoin = null;
|
||||
|
||||
if (rooms[i].world_readable) {
|
||||
guestRead = (
|
||||
<div className="mx_RoomDirectory_perm">{ _t('World readable') }</div>
|
||||
);
|
||||
}
|
||||
if (rooms[i].guest_can_join) {
|
||||
guestJoin = (
|
||||
<div className="mx_RoomDirectory_perm">{ _t('Guests can join') }</div>
|
||||
);
|
||||
}
|
||||
|
||||
perms = null;
|
||||
if (guestRead || guestJoin) {
|
||||
perms = <div className="mx_RoomDirectory_perms">{guestRead}{guestJoin}</div>;
|
||||
}
|
||||
|
||||
var topic = rooms[i].topic || '';
|
||||
topic = linkifyString(sanitizeHtml(topic));
|
||||
|
||||
rows.push(
|
||||
<tr key={ rooms[i].room_id }
|
||||
onClick={self.onRoomClicked.bind(self, rooms[i])}
|
||||
// cancel onMouseDown otherwise shift-clicking highlights text
|
||||
onMouseDown={(ev) => {ev.preventDefault();}}
|
||||
>
|
||||
<td className="mx_RoomDirectory_roomAvatar">
|
||||
<BaseAvatar width={24} height={24} resizeMethod='crop'
|
||||
name={ name } idName={ name }
|
||||
url={ ContentRepo.getHttpUriForMxc(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
rooms[i].avatar_url, 24, 24, "crop") } />
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomDescription">
|
||||
<div className="mx_RoomDirectory_name">{ name }</div>
|
||||
{ perms }
|
||||
<div className="mx_RoomDirectory_topic"
|
||||
onClick={ function(e) { e.stopPropagation() } }
|
||||
dangerouslySetInnerHTML={{ __html: topic }}/>
|
||||
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(rooms[i]) }</div>
|
||||
</td>
|
||||
<td className="mx_RoomDirectory_roomMemberCount">
|
||||
{ rooms[i].num_joined_members }
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
|
||||
collectScrollPanel: function(element) {
|
||||
this.scrollPanel = element;
|
||||
},
|
||||
|
||||
_stringLooksLikeId: function(s, field_type) {
|
||||
let pat = /^#[^\s]+:[^\s]/;
|
||||
if (field_type && field_type.regexp) {
|
||||
pat = new RegExp(field_type.regexp);
|
||||
}
|
||||
|
||||
return pat.test(s);
|
||||
},
|
||||
|
||||
_getFieldsForThirdPartyLocation: function(userInput, protocol, instance) {
|
||||
// make an object with the fields specified by that protocol. We
|
||||
// require that the values of all but the last field come from the
|
||||
// instance. The last is the user input.
|
||||
const requiredFields = protocol.location_fields;
|
||||
if (!requiredFields) return null;
|
||||
const fields = {};
|
||||
for (let i = 0; i < requiredFields.length - 1; ++i) {
|
||||
const thisField = requiredFields[i];
|
||||
if (instance.fields[thisField] === undefined) return null;
|
||||
fields[thisField] = instance.fields[thisField];
|
||||
}
|
||||
fields[requiredFields[requiredFields.length - 1]] = userInput;
|
||||
return fields;
|
||||
},
|
||||
|
||||
/**
|
||||
* called by the parent component when PageUp/Down/etc is pressed.
|
||||
*
|
||||
* We pass it down to the scroll panel.
|
||||
*/
|
||||
handleScrollKey: function(ev) {
|
||||
if (this.scrollPanel) {
|
||||
this.scrollPanel.handleScrollKey(ev);
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
||||
if (this.state.protocolsLoading) {
|
||||
return (
|
||||
<div className="mx_RoomDirectory">
|
||||
<SimpleRoomHeader title={ _t('Directory') } />
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let content;
|
||||
if (this.state.loading) {
|
||||
content = <div className="mx_RoomDirectory">
|
||||
<Loader />
|
||||
</div>;
|
||||
} else {
|
||||
const rows = this.getRows();
|
||||
// we still show the scrollpanel, at least for now, because
|
||||
// otherwise we don't fetch more because we don't get a fill
|
||||
// request from the scrollpanel because there isn't one
|
||||
let scrollpanel_content;
|
||||
if (rows.length == 0) {
|
||||
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
|
||||
} else {
|
||||
scrollpanel_content = <table ref="directory_table" className="mx_RoomDirectory_table">
|
||||
<tbody>
|
||||
{ this.getRows() }
|
||||
</tbody>
|
||||
</table>;
|
||||
}
|
||||
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
content = <ScrollPanel ref={this.collectScrollPanel}
|
||||
className="mx_RoomDirectory_tableWrapper"
|
||||
onFillRequest={ this.onFillRequest }
|
||||
stickyBottom={false}
|
||||
startAtBottom={false}
|
||||
onResize={function(){}}
|
||||
>
|
||||
{ scrollpanel_content }
|
||||
</ScrollPanel>;
|
||||
}
|
||||
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
let instance_expected_field_type;
|
||||
if (
|
||||
protocolName &&
|
||||
this.protocols &&
|
||||
this.protocols[protocolName] &&
|
||||
this.protocols[protocolName].location_fields.length > 0 &&
|
||||
this.protocols[protocolName].field_types
|
||||
) {
|
||||
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
|
||||
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
|
||||
}
|
||||
|
||||
|
||||
let placeholder = _t('Search for a room');
|
||||
if (!this.state.instanceId) {
|
||||
placeholder = _t('#example') + ':' + this.state.roomServer;
|
||||
} else if (instance_expected_field_type) {
|
||||
placeholder = instance_expected_field_type.placeholder;
|
||||
}
|
||||
|
||||
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
|
||||
showJoinButton = false;
|
||||
}
|
||||
}
|
||||
|
||||
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
|
||||
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
|
||||
return (
|
||||
<div className="mx_RoomDirectory">
|
||||
<SimpleRoomHeader title={ _t('Directory') } icon="img/icons-directory.svg" />
|
||||
<div className="mx_RoomDirectory_list">
|
||||
<div className="mx_RoomDirectory_listheader">
|
||||
<DirectorySearchBox
|
||||
className="mx_RoomDirectory_searchbox"
|
||||
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
|
||||
placeholder={placeholder} showJoinButton={showJoinButton}
|
||||
/>
|
||||
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
|
||||
// but works with the objects we get from the public room list
|
||||
function get_display_alias_for_room(room) {
|
||||
return room.canonical_alias || (room.aliases ? room.aliases[0] : "");
|
||||
}
|
|
@ -296,7 +296,7 @@ module.exports = React.createClass({
|
|||
if (hasUDE) {
|
||||
title = _t("Message not sent due to unknown devices being present");
|
||||
content = _t(
|
||||
"<showDevicesText>Show devices</showDevicesText>, <sendAnywayText>mark devices known and send</sendAnywayText> or <cancelText>cancel all</cancelText>.",
|
||||
"<showDevicesText>Show devices</showDevicesText>, <sendAnywayText>send anyway</sendAnywayText> or <cancelText>cancel</cancelText>.",
|
||||
{},
|
||||
{
|
||||
'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
|
||||
|
|
401
src/components/structures/RoomSubList.js
Normal file
401
src/components/structures/RoomSubList.js
Normal file
|
@ -0,0 +1,401 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var React = require('react');
|
||||
var ReactDOM = require('react-dom');
|
||||
var classNames = require('classnames');
|
||||
var sdk = require('../../index');
|
||||
import { Droppable } from 'react-beautiful-dnd';
|
||||
import { _t } from '../../languageHandler';
|
||||
var dis = require('../../dispatcher');
|
||||
var Unread = require('../../Unread');
|
||||
var MatrixClientPeg = require('../../MatrixClientPeg');
|
||||
var RoomNotifs = require('../../RoomNotifs');
|
||||
var FormattingUtils = require('../../utils/FormattingUtils');
|
||||
var AccessibleButton = require('../../components/views/elements/AccessibleButton');
|
||||
import Modal from '../../Modal';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
|
||||
|
||||
// turn this on for drop & drag console debugging galore
|
||||
var debug = false;
|
||||
|
||||
const TRUNCATE_AT = 10;
|
||||
|
||||
var RoomSubList = React.createClass({
|
||||
displayName: 'RoomSubList',
|
||||
|
||||
debug: debug,
|
||||
|
||||
propTypes: {
|
||||
list: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
|
||||
label: React.PropTypes.string.isRequired,
|
||||
tagName: React.PropTypes.string,
|
||||
editable: React.PropTypes.bool,
|
||||
|
||||
order: React.PropTypes.string.isRequired,
|
||||
|
||||
// passed through to RoomTile and used to highlight room with `!` regardless of notifications count
|
||||
isInvite: React.PropTypes.bool,
|
||||
|
||||
startAsHidden: React.PropTypes.bool,
|
||||
showSpinner: React.PropTypes.bool, // true to show a spinner if 0 elements when expanded
|
||||
collapsed: React.PropTypes.bool.isRequired, // is LeftPanel collapsed?
|
||||
onHeaderClick: React.PropTypes.func,
|
||||
alwaysShowHeader: React.PropTypes.bool,
|
||||
incomingCall: React.PropTypes.object,
|
||||
onShowMoreRooms: React.PropTypes.func,
|
||||
searchFilter: React.PropTypes.string,
|
||||
emptyContent: React.PropTypes.node, // content shown if the list is empty
|
||||
headerItems: React.PropTypes.node, // content shown in the sublist header
|
||||
extraTiles: React.PropTypes.arrayOf(React.PropTypes.node), // extra elements added beneath tiles
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
hidden: this.props.startAsHidden || false,
|
||||
truncateAt: TRUNCATE_AT,
|
||||
sortedList: [],
|
||||
};
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
onHeaderClick: function() {}, // NOP
|
||||
onShowMoreRooms: function() {}, // NOP
|
||||
extraTiles: [],
|
||||
isInvite: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this.setState({
|
||||
sortedList: this.applySearchFilter(this.props.list, this.props.searchFilter),
|
||||
});
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(newProps) {
|
||||
// order the room list appropriately before we re-render
|
||||
//if (debug) console.log("received new props, list = " + newProps.list);
|
||||
this.setState({
|
||||
sortedList: this.applySearchFilter(newProps.list, newProps.searchFilter),
|
||||
});
|
||||
},
|
||||
|
||||
applySearchFilter: function(list, filter) {
|
||||
if (filter === "") return list;
|
||||
return list.filter((room) => {
|
||||
return room.name && room.name.toLowerCase().indexOf(filter.toLowerCase()) >= 0
|
||||
});
|
||||
},
|
||||
|
||||
// The header is collapsable if it is hidden or not stuck
|
||||
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
|
||||
isCollapsableOnClick: function() {
|
||||
var stuck = this.refs.header.dataset.stuck;
|
||||
if (this.state.hidden || stuck === undefined || stuck === "none") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
// XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched,
|
||||
// but this is no longer true, so we must do it here (and can apply the small
|
||||
// optimisation of checking that we care about the room being read).
|
||||
//
|
||||
// Ultimately we need to transition to a state pushing flow where something
|
||||
// explicitly notifies the components concerned that the notif count for a room
|
||||
// has change (e.g. a Flux store).
|
||||
if (payload.action === 'on_room_read' &&
|
||||
this.props.list.some((r) => r.roomId === payload.roomId)
|
||||
) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
onClick: function(ev) {
|
||||
if (this.isCollapsableOnClick()) {
|
||||
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
|
||||
var isHidden = !this.state.hidden;
|
||||
this.setState({ hidden : isHidden });
|
||||
|
||||
if (isHidden) {
|
||||
// as good a way as any to reset the truncate state
|
||||
this.setState({ truncateAt : TRUNCATE_AT });
|
||||
}
|
||||
|
||||
this.props.onShowMoreRooms();
|
||||
this.props.onHeaderClick(isHidden);
|
||||
} else {
|
||||
// The header is stuck, so the click is to be interpreted as a scroll to the header
|
||||
this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition);
|
||||
}
|
||||
},
|
||||
|
||||
onRoomTileClick(roomId, ev) {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
clear_search: (ev && (ev.keyCode == KeyCode.ENTER || ev.keyCode == KeyCode.SPACE)),
|
||||
});
|
||||
},
|
||||
|
||||
_shouldShowNotifBadge: function(roomNotifState) {
|
||||
const showBadgeInStates = [RoomNotifs.ALL_MESSAGES, RoomNotifs.ALL_MESSAGES_LOUD];
|
||||
return showBadgeInStates.indexOf(roomNotifState) > -1;
|
||||
},
|
||||
|
||||
_shouldShowMentionBadge: function(roomNotifState) {
|
||||
return roomNotifState != RoomNotifs.MUTE;
|
||||
},
|
||||
|
||||
/**
|
||||
* Total up all the notification counts from the rooms
|
||||
*
|
||||
* @param {Number} If supplied will only total notifications for rooms outside the truncation number
|
||||
* @returns {Array} The array takes the form [total, highlight] where highlight is a bool
|
||||
*/
|
||||
roomNotificationCount: function(truncateAt) {
|
||||
var self = this;
|
||||
|
||||
if (this.props.isInvite) {
|
||||
return [0, true];
|
||||
}
|
||||
|
||||
return this.props.list.reduce(function(result, room, index) {
|
||||
if (truncateAt === undefined || index >= truncateAt) {
|
||||
var roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
|
||||
var highlight = room.getUnreadNotificationCount('highlight') > 0;
|
||||
var notificationCount = room.getUnreadNotificationCount();
|
||||
|
||||
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
|
||||
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
|
||||
const badges = notifBadges || mentionBadges;
|
||||
|
||||
if (badges) {
|
||||
result[0] += notificationCount;
|
||||
if (highlight) {
|
||||
result[1] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [0, false]);
|
||||
},
|
||||
|
||||
_updateSubListCount: function() {
|
||||
// Force an update by setting the state to the current state
|
||||
// Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate()
|
||||
// method is honoured
|
||||
this.setState(this.state);
|
||||
},
|
||||
|
||||
makeRoomTiles: function() {
|
||||
const DNDRoomTile = sdk.getComponent("rooms.DNDRoomTile");
|
||||
const RoomTile = sdk.getComponent("rooms.RoomTile");
|
||||
return this.state.sortedList.map((room, index) => {
|
||||
// XXX: is it evil to pass in this as a prop to RoomTile? Yes.
|
||||
|
||||
// We should only use <DNDRoomTile /> when editable
|
||||
const RoomTileComponent = this.props.editable ? DNDRoomTile : RoomTile;
|
||||
return <RoomTileComponent
|
||||
index={index} // For DND
|
||||
room={room}
|
||||
roomSubList={this}
|
||||
tagName={this.props.tagName}
|
||||
key={room.roomId}
|
||||
collapsed={this.props.collapsed || false}
|
||||
unread={Unread.doesRoomHaveUnreadMessages(room)}
|
||||
highlight={room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite}
|
||||
isInvite={this.props.isInvite}
|
||||
refreshSubList={this._updateSubListCount}
|
||||
incomingCall={null}
|
||||
onClick={this.onRoomTileClick}
|
||||
/>;
|
||||
});
|
||||
},
|
||||
|
||||
_getHeaderJsx: function() {
|
||||
var TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
var subListNotifications = this.roomNotificationCount();
|
||||
var subListNotifCount = subListNotifications[0];
|
||||
var subListNotifHighlight = subListNotifications[1];
|
||||
|
||||
var totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
|
||||
var roomCount = totalTiles > 0 ? totalTiles : '';
|
||||
|
||||
var chevronClasses = classNames({
|
||||
'mx_RoomSubList_chevron': true,
|
||||
'mx_RoomSubList_chevronRight': this.state.hidden,
|
||||
'mx_RoomSubList_chevronDown': !this.state.hidden,
|
||||
});
|
||||
|
||||
var badgeClasses = classNames({
|
||||
'mx_RoomSubList_badge': true,
|
||||
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
|
||||
});
|
||||
|
||||
var badge;
|
||||
if (subListNotifCount > 0) {
|
||||
badge = <div className={badgeClasses}>{ FormattingUtils.formatCount(subListNotifCount) }</div>;
|
||||
} else if (this.props.isInvite) {
|
||||
// no notifications but highlight anyway because this is an invite badge
|
||||
badge = <div className={badgeClasses}>!</div>;
|
||||
}
|
||||
|
||||
// When collapsed, allow a long hover on the header to show user
|
||||
// the full tag name and room count
|
||||
var title;
|
||||
if (this.props.collapsed) {
|
||||
title = this.props.label;
|
||||
if (roomCount !== '') {
|
||||
title += " [" + roomCount + "]";
|
||||
}
|
||||
}
|
||||
|
||||
var incomingCall;
|
||||
if (this.props.incomingCall) {
|
||||
var self = this;
|
||||
// Check if the incoming call is for this section
|
||||
var incomingCallRoom = this.props.list.filter(function(room) {
|
||||
return self.props.incomingCall.roomId === room.roomId;
|
||||
});
|
||||
|
||||
if (incomingCallRoom.length === 1) {
|
||||
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
|
||||
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>;
|
||||
}
|
||||
}
|
||||
|
||||
var tabindex = this.props.searchFilter === "" ? "0" : "-1";
|
||||
|
||||
return (
|
||||
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
|
||||
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
|
||||
{ this.props.collapsed ? '' : this.props.label }
|
||||
<div className="mx_RoomSubList_roomCount">{ roomCount }</div>
|
||||
<div className={chevronClasses}></div>
|
||||
{ badge }
|
||||
{ incomingCall }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
_createOverflowTile: function(overflowCount, totalCount) {
|
||||
var content = <div className="mx_RoomSubList_chevronDown"></div>;
|
||||
|
||||
var overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
|
||||
var overflowNotifCount = overflowNotifications[0];
|
||||
var overflowNotifHighlight = overflowNotifications[1];
|
||||
if (overflowNotifCount && !this.props.collapsed) {
|
||||
content = FormattingUtils.formatCount(overflowNotifCount);
|
||||
}
|
||||
|
||||
var badgeClasses = classNames({
|
||||
'mx_RoomSubList_moreBadge': true,
|
||||
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
|
||||
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
|
||||
<div className="mx_RoomSubList_line"></div>
|
||||
<div className="mx_RoomSubList_more">{ _t("more") }</div>
|
||||
<div className={ badgeClasses }>{ content }</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
},
|
||||
|
||||
_showFullMemberList: function() {
|
||||
this.setState({
|
||||
truncateAt: -1
|
||||
});
|
||||
|
||||
this.props.onShowMoreRooms();
|
||||
this.props.onHeaderClick(false);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var connectDropTarget = this.props.connectDropTarget;
|
||||
var TruncatedList = sdk.getComponent('elements.TruncatedList');
|
||||
|
||||
var label = this.props.collapsed ? null : this.props.label;
|
||||
|
||||
let content;
|
||||
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) {
|
||||
content = this.props.emptyContent;
|
||||
} else {
|
||||
content = this.makeRoomTiles();
|
||||
content.push(...this.props.extraTiles);
|
||||
}
|
||||
|
||||
if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) {
|
||||
var subList;
|
||||
var classes = "mx_RoomSubList";
|
||||
|
||||
if (!this.state.hidden) {
|
||||
subList = <TruncatedList className={ classes } truncateAt={this.state.truncateAt}
|
||||
createOverflowElement={this._createOverflowTile} >
|
||||
{ content }
|
||||
</TruncatedList>;
|
||||
}
|
||||
else {
|
||||
subList = <TruncatedList className={ classes }>
|
||||
</TruncatedList>;
|
||||
}
|
||||
|
||||
const subListContent = <div>
|
||||
{ this._getHeaderJsx() }
|
||||
{ subList }
|
||||
</div>;
|
||||
|
||||
return this.props.editable ?
|
||||
<Droppable
|
||||
droppableId={"room-sub-list-droppable_" + this.props.tagName}
|
||||
type="draggable-RoomTile"
|
||||
>
|
||||
{ (provided, snapshot) => (
|
||||
<div ref={provided.innerRef}>
|
||||
{ subListContent }
|
||||
</div>
|
||||
) }
|
||||
</Droppable> : subListContent;
|
||||
}
|
||||
else {
|
||||
var Loader = sdk.getComponent("elements.Spinner");
|
||||
return (
|
||||
<div className="mx_RoomSubList">
|
||||
{ this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined }
|
||||
{ (this.props.showSpinner && !this.state.hidden) ? <Loader /> : undefined }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = RoomSubList;
|
|
@ -264,12 +264,19 @@ module.exports = React.createClass({
|
|||
isPeeking: true, // this will change to false if peeking fails
|
||||
});
|
||||
MatrixClientPeg.get().peekInRoom(roomId).then((room) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
room: room,
|
||||
peekLoading: false,
|
||||
});
|
||||
this._onRoomLoaded(room);
|
||||
}, (err) => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop peeking if anything went wrong
|
||||
this.setState({
|
||||
isPeeking: false,
|
||||
|
@ -286,7 +293,7 @@ module.exports = React.createClass({
|
|||
} else {
|
||||
throw err;
|
||||
}
|
||||
}).done();
|
||||
});
|
||||
}
|
||||
} else if (room) {
|
||||
// Stop peeking because we have joined this room previously
|
||||
|
@ -460,6 +467,15 @@ module.exports = React.createClass({
|
|||
case 'message_sent':
|
||||
this._checkIfAlone(this.state.room);
|
||||
break;
|
||||
case 'post_sticker_message':
|
||||
this.injectSticker(
|
||||
payload.data.content.url,
|
||||
payload.data.content.info,
|
||||
payload.data.description || payload.data.name);
|
||||
break;
|
||||
case 'picture_snapshot':
|
||||
this.uploadFile(payload.file);
|
||||
break;
|
||||
case 'notifier_enabled':
|
||||
case 'upload_failed':
|
||||
case 'upload_started':
|
||||
|
@ -620,8 +636,8 @@ module.exports = React.createClass({
|
|||
const room = this.state.room;
|
||||
if (!room) return;
|
||||
|
||||
const color_scheme = SettingsStore.getValue("roomColor", room.room_id);
|
||||
console.log("Tinter.tint from updateTint");
|
||||
const color_scheme = SettingsStore.getValue("roomColor", room.roomId);
|
||||
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
|
||||
},
|
||||
|
||||
|
@ -670,23 +686,7 @@ module.exports = React.createClass({
|
|||
// a member state changed in this room
|
||||
// refresh the conf call notification state
|
||||
this._updateConfCallNotification();
|
||||
|
||||
// if we are now a member of the room, where we were not before, that
|
||||
// means we have finished joining a room we were previously peeking
|
||||
// into.
|
||||
const me = MatrixClientPeg.get().credentials.userId;
|
||||
if (this.state.joining && this.state.room.hasMembershipState(me, "join")) {
|
||||
// Having just joined a room, check to see if it looks like a DM room, and if so,
|
||||
// mark it as one. This is to work around the fact that some clients don't support
|
||||
// is_direct. We should remove this once they do.
|
||||
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
|
||||
if (Rooms.looksLikeDirectMessageRoom(this.state.room, me)) {
|
||||
// XXX: There's not a whole lot we can really do if this fails: at best
|
||||
// perhaps we could try a couple more times, but since it's a temporary
|
||||
// compatability workaround, let's not bother.
|
||||
Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender()).done();
|
||||
}
|
||||
}
|
||||
this._updateDMState();
|
||||
}, 500),
|
||||
|
||||
_checkIfAlone: function(room) {
|
||||
|
@ -727,6 +727,44 @@ module.exports = React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_updateDMState() {
|
||||
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
|
||||
if (!me || me.membership !== "join") {
|
||||
return;
|
||||
}
|
||||
|
||||
// The user may have accepted an invite with is_direct set
|
||||
if (me.events.member.getPrevContent().membership === "invite" &&
|
||||
me.events.member.getPrevContent().is_direct
|
||||
) {
|
||||
// This is a DM with the sender of the invite event (which we assume
|
||||
// preceded the join event)
|
||||
Rooms.setDMRoom(
|
||||
this.state.room.roomId,
|
||||
me.events.member.getUnsigned().prev_sender,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const invitedMembers = this.state.room.getMembersWithMembership("invite");
|
||||
const joinedMembers = this.state.room.getMembersWithMembership("join");
|
||||
|
||||
// There must be one invited member and one joined member
|
||||
if (invitedMembers.length !== 1 || joinedMembers.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The user may have sent an invite with is_direct sent
|
||||
const other = invitedMembers[0];
|
||||
if (other &&
|
||||
other.membership === "invite" &&
|
||||
other.events.member.getContent().is_direct
|
||||
) {
|
||||
Rooms.setDMRoom(this.state.room.roomId, other.userId);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
onSearchResultsResize: function() {
|
||||
dis.dispatch({ action: 'timeline_resize' }, true);
|
||||
},
|
||||
|
@ -819,18 +857,6 @@ module.exports = React.createClass({
|
|||
action: 'join_room',
|
||||
opts: { inviteSignUrl: signUrl },
|
||||
});
|
||||
|
||||
// if this is an invite and has the 'direct' hint set, mark it as a DM room now.
|
||||
if (this.state.room) {
|
||||
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
|
||||
if (me && me.membership == 'invite') {
|
||||
if (me.events.member.getContent().is_direct) {
|
||||
// The 'direct' hint is there, so declare that this is a DM room for
|
||||
// whoever invited us.
|
||||
return Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
},
|
||||
|
@ -882,28 +908,52 @@ module.exports = React.createClass({
|
|||
this.setState({ draggingFile: false });
|
||||
},
|
||||
|
||||
uploadFile: function(file) {
|
||||
uploadFile: async function(file) {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'view_set_mxid'});
|
||||
return;
|
||||
}
|
||||
|
||||
ContentMessages.sendContentToRoom(
|
||||
file, this.state.room.roomId, MatrixClientPeg.get(),
|
||||
).done(undefined, (error) => {
|
||||
try {
|
||||
await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get());
|
||||
} catch (error) {
|
||||
if (error.name === "UnknownDeviceError") {
|
||||
// Let the staus bar handle this
|
||||
// Let the status bar handle this
|
||||
return;
|
||||
}
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
console.error("Failed to upload file " + file + " " + error);
|
||||
Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, {
|
||||
title: _t('Failed to upload file'),
|
||||
description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
|
||||
description: ((error && error.message)
|
||||
? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
|
||||
});
|
||||
|
||||
// bail early to avoid calling the dispatch below
|
||||
return;
|
||||
}
|
||||
|
||||
// Send message_sent callback, for things like _checkIfAlone because after all a file is still a message.
|
||||
dis.dispatch({
|
||||
action: 'message_sent',
|
||||
});
|
||||
},
|
||||
|
||||
injectSticker: function(url, info, text) {
|
||||
if (MatrixClientPeg.get().isGuest()) {
|
||||
dis.dispatch({action: 'view_set_mxid'});
|
||||
return;
|
||||
}
|
||||
|
||||
ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
|
||||
.done(undefined, (error) => {
|
||||
if (error.name === "UnknownDeviceError") {
|
||||
// Let the staus bar handle this
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onSearch: function(term, scope) {
|
||||
this.setState({
|
||||
searchTerm: term,
|
||||
|
@ -1586,7 +1636,8 @@ module.exports = React.createClass({
|
|||
displayConfCallNotification={this.state.displayConfCallNotification}
|
||||
maxHeight={this.state.auxPanelMaxHeight}
|
||||
onResize={this.onChildResize}
|
||||
showApps={this.state.showApps && !this.state.editingRoomSettings} >
|
||||
showApps={this.state.showApps}
|
||||
hideAppsDrawer={this.state.editingRoomSettings} >
|
||||
{ aux }
|
||||
</AuxPanel>
|
||||
);
|
||||
|
|
|
@ -17,9 +17,9 @@ limitations under the License.
|
|||
const React = require("react");
|
||||
const ReactDOM = require("react-dom");
|
||||
import PropTypes from 'prop-types';
|
||||
const GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
import Promise from 'bluebird';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import sdk from '../../index.js';
|
||||
|
||||
const DEBUG_SCROLL = false;
|
||||
// var DEBUG_SCROLL = true;
|
||||
|
@ -224,7 +224,7 @@ module.exports = React.createClass({
|
|||
onResize: function() {
|
||||
this.props.onResize();
|
||||
this.checkScroll();
|
||||
this.refs.geminiPanel.forceUpdate();
|
||||
if (this._gemScroll) this._gemScroll.forceUpdate();
|
||||
},
|
||||
|
||||
// after an update to the contents of the panel, check that the scroll is
|
||||
|
@ -665,14 +665,25 @@ module.exports = React.createClass({
|
|||
throw new Error("ScrollPanel._getScrollNode called when unmounted");
|
||||
}
|
||||
|
||||
return this.refs.geminiPanel.scrollbar.getViewElement();
|
||||
if (!this._gemScroll) {
|
||||
// Likewise, we should have the ref by this point, but if not
|
||||
// turn the NPE into something meaningful.
|
||||
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
|
||||
}
|
||||
|
||||
return this._gemScroll.scrollbar.getViewElement();
|
||||
},
|
||||
|
||||
_collectGeminiScroll: function(gemScroll) {
|
||||
this._gemScroll = gemScroll;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
// TODO: the classnames on the div and ol could do with being updated to
|
||||
// reflect the fact that we don't necessarily contain a list of messages.
|
||||
// it's not obvious why we have a separate div and ol anyway.
|
||||
return (<GeminiScrollbar autoshow={true} ref="geminiPanel"
|
||||
return (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll}
|
||||
onScroll={this.onScroll} onResize={this.onResize}
|
||||
className={this.props.className} style={this.props.style}>
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
|
@ -680,7 +691,7 @@ module.exports = React.createClass({
|
|||
{ this.props.children }
|
||||
</ol>
|
||||
</div>
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
163
src/components/structures/SearchBox.js
Normal file
163
src/components/structures/SearchBox.js
Normal file
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import { _t } from '../../languageHandler';
|
||||
import { KeyCode } from '../../Keyboard';
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import rate_limited_func from '../../ratelimitedfunc';
|
||||
import AccessibleButton from '../../components/views/elements/AccessibleButton';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'SearchBox',
|
||||
|
||||
propTypes: {
|
||||
collapsed: React.PropTypes.bool,
|
||||
onSearch: React.PropTypes.func,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
searchTerm: "",
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
dis.unregister(this.dispatcherRef);
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
switch (payload.action) {
|
||||
case 'view_room':
|
||||
if (this.refs.search && payload.clear_search) {
|
||||
this._clearSearch();
|
||||
}
|
||||
break;
|
||||
case 'focus_room_filter':
|
||||
if (this.refs.search) {
|
||||
this.refs.search.focus();
|
||||
this.refs.search.select();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
onChange: function() {
|
||||
if (!this.refs.search) return;
|
||||
this.setState({ searchTerm: this.refs.search.value });
|
||||
this.onSearch();
|
||||
},
|
||||
|
||||
onSearch: new rate_limited_func(
|
||||
function() {
|
||||
this.props.onSearch(this.refs.search.value);
|
||||
},
|
||||
100
|
||||
),
|
||||
|
||||
onToggleCollapse: function(show) {
|
||||
if (show) {
|
||||
dis.dispatch({
|
||||
action: 'show_left_panel',
|
||||
});
|
||||
}
|
||||
else {
|
||||
dis.dispatch({
|
||||
action: 'hide_left_panel',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_onKeyDown: function(ev) {
|
||||
switch (ev.keyCode) {
|
||||
case KeyCode.ESCAPE:
|
||||
this._clearSearch();
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_clearSearch: function() {
|
||||
this.refs.search.value = "";
|
||||
this.onChange();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
|
||||
var collapseTabIndex = this.refs.search && this.refs.search.value !== "" ? "-1" : "0";
|
||||
|
||||
var toggleCollapse;
|
||||
if (this.props.collapsed) {
|
||||
toggleCollapse =
|
||||
<AccessibleButton className="mx_SearchBox_maximise" tabIndex={collapseTabIndex} onClick={ this.onToggleCollapse.bind(this, true) }>
|
||||
<TintableSvg src="img/maximise.svg" width="10" height="16" alt={ _t("Expand panel") }/>
|
||||
</AccessibleButton>
|
||||
}
|
||||
else {
|
||||
toggleCollapse =
|
||||
<AccessibleButton className="mx_SearchBox_minimise" tabIndex={collapseTabIndex} onClick={ this.onToggleCollapse.bind(this, false) }>
|
||||
<TintableSvg src="img/minimise.svg" width="10" height="16" alt={ _t("Collapse panel") }/>
|
||||
</AccessibleButton>
|
||||
}
|
||||
|
||||
var searchControls;
|
||||
if (!this.props.collapsed) {
|
||||
searchControls = [
|
||||
this.state.searchTerm.length > 0 ?
|
||||
<AccessibleButton key="button"
|
||||
className="mx_SearchBox_closeButton"
|
||||
onClick={ ()=>{ this._clearSearch(); } }>
|
||||
<TintableSvg
|
||||
className="mx_SearchBox_searchButton"
|
||||
src="img/icons-close.svg" width="24" height="24"
|
||||
/>
|
||||
</AccessibleButton>
|
||||
:
|
||||
<TintableSvg
|
||||
key="button"
|
||||
className="mx_SearchBox_searchButton"
|
||||
src="img/icons-search-copy.svg" width="13" height="13"
|
||||
/>,
|
||||
<input
|
||||
key="searchfield"
|
||||
type="text"
|
||||
ref="search"
|
||||
className="mx_SearchBox_search"
|
||||
value={ this.state.searchTerm }
|
||||
onChange={ this.onChange }
|
||||
onKeyDown={ this._onKeyDown }
|
||||
placeholder={ _t('Filter room names') }
|
||||
/>
|
||||
];
|
||||
}
|
||||
|
||||
var self = this;
|
||||
return (
|
||||
<div className="mx_SearchBox">
|
||||
{ searchControls }
|
||||
{ toggleCollapse }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
|
@ -20,10 +20,12 @@ import { MatrixClient } from 'matrix-js-sdk';
|
|||
import TagOrderStore from '../../stores/TagOrderStore';
|
||||
|
||||
import GroupActions from '../../actions/GroupActions';
|
||||
import TagOrderActions from '../../actions/TagOrderActions';
|
||||
|
||||
import sdk from '../../index';
|
||||
import dis from '../../dispatcher';
|
||||
import { _t } from '../../languageHandler';
|
||||
|
||||
import { Droppable } from 'react-beautiful-dnd';
|
||||
|
||||
const TagPanel = React.createClass({
|
||||
displayName: 'TagPanel',
|
||||
|
@ -42,6 +44,7 @@ const TagPanel = React.createClass({
|
|||
componentWillMount: function() {
|
||||
this.unmounted = false;
|
||||
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.matrixClient.on("sync", this._onClientSync);
|
||||
|
||||
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
|
||||
if (this.unmounted) {
|
||||
|
@ -59,6 +62,7 @@ const TagPanel = React.createClass({
|
|||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
|
||||
this.context.matrixClient.removeListener("sync", this._onClientSync);
|
||||
if (this._filterStoreToken) {
|
||||
this._filterStoreToken.remove();
|
||||
}
|
||||
|
@ -69,7 +73,17 @@ const TagPanel = React.createClass({
|
|||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
|
||||
},
|
||||
|
||||
onClick() {
|
||||
_onClientSync(syncState, prevState) {
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING or PREPARED.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
if (reconnected) {
|
||||
// Load joined groups
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
|
||||
}
|
||||
},
|
||||
|
||||
onMouseDown(e) {
|
||||
dis.dispatch({action: 'deselect_tags'});
|
||||
},
|
||||
|
||||
|
@ -78,30 +92,65 @@ const TagPanel = React.createClass({
|
|||
dis.dispatch({action: 'view_create_group'});
|
||||
},
|
||||
|
||||
onTagTileEndDrag() {
|
||||
dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient));
|
||||
onClearFilterClick(ev) {
|
||||
dis.dispatch({action: 'deselect_tags'});
|
||||
},
|
||||
|
||||
render() {
|
||||
const GroupsButton = sdk.getComponent('elements.GroupsButton');
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
const TintableSvg = sdk.getComponent('elements.TintableSvg');
|
||||
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
|
||||
const tags = this.state.orderedTags.map((tag, index) => {
|
||||
return <DNDTagTile
|
||||
key={tag + '_' + index}
|
||||
key={tag}
|
||||
tag={tag}
|
||||
index={index}
|
||||
selected={this.state.selectedTags.includes(tag)}
|
||||
onEndDrag={this.onTagTileEndDrag}
|
||||
/>;
|
||||
});
|
||||
return <div className="mx_TagPanel" onClick={this.onClick}>
|
||||
<div className="mx_TagPanel_tagTileContainer">
|
||||
{ tags }
|
||||
</div>
|
||||
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}>
|
||||
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" />
|
||||
|
||||
const clearButton = this.state.selectedTags.length > 0 ?
|
||||
<TintableSvg src="img/icons-close.svg" width="24" height="24"
|
||||
alt={_t("Clear filter")}
|
||||
title={_t("Clear filter")}
|
||||
/> :
|
||||
<div />;
|
||||
|
||||
return <div className="mx_TagPanel">
|
||||
<AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
|
||||
{ clearButton }
|
||||
</AccessibleButton>
|
||||
<div className="mx_TagPanel_divider" />
|
||||
<GeminiScrollbarWrapper
|
||||
className="mx_TagPanel_scroller"
|
||||
autoshow={true}
|
||||
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
|
||||
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6253
|
||||
onMouseDown={this.onMouseDown}
|
||||
>
|
||||
<Droppable
|
||||
droppableId="tag-panel-droppable"
|
||||
type="draggable-TagTile"
|
||||
>
|
||||
{ (provided, snapshot) => (
|
||||
<div
|
||||
className="mx_TagPanel_tagTileContainer"
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{ tags }
|
||||
{ provided.placeholder }
|
||||
</div>
|
||||
) }
|
||||
</Droppable>
|
||||
</GeminiScrollbarWrapper>
|
||||
<div className="mx_TagPanel_divider" />
|
||||
<div className="mx_TagPanel_groupsButton">
|
||||
<GroupsButton tooltip={true} />
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
|
|
|
@ -302,6 +302,8 @@ var TimelinePanel = React.createClass({
|
|||
|
||||
// set off a pagination request.
|
||||
onMessageListFillRequest: function(backwards) {
|
||||
if (!this._shouldPaginate()) return Promise.resolve(false);
|
||||
|
||||
const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
|
||||
const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate';
|
||||
const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating';
|
||||
|
@ -622,6 +624,7 @@ var TimelinePanel = React.createClass({
|
|||
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
|
||||
dis.dispatch({
|
||||
action: 'on_room_read',
|
||||
roomId: this.props.timelineSet.room.roomId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1091,6 +1094,17 @@ var TimelinePanel = React.createClass({
|
|||
}, this.props.onReadMarkerUpdated);
|
||||
},
|
||||
|
||||
_shouldPaginate: function() {
|
||||
// don't try to paginate while events in the timeline are
|
||||
// still being decrypted. We don't render events while they're
|
||||
// being decrypted, so they don't take up space in the timeline.
|
||||
// This means we can pull quite a lot of events into the timeline
|
||||
// and end up trying to render a lot of events.
|
||||
return !this.state.events.some((e) => {
|
||||
return e.isBeingDecrypted();
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const MessagePanel = sdk.getComponent("structures.MessagePanel");
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
|
@ -1108,9 +1122,9 @@ var TimelinePanel = React.createClass({
|
|||
// exist.
|
||||
if (this.state.timelineLoading) {
|
||||
return (
|
||||
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
|
||||
<Loader />
|
||||
</div>
|
||||
<div className="mx_RoomView_messagePanelSpinner">
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -30,7 +30,6 @@ import Promise from 'bluebird';
|
|||
const packageJson = require('../../../package.json');
|
||||
const UserSettingsStore = require('../../UserSettingsStore');
|
||||
const CallMediaHandler = require('../../CallMediaHandler');
|
||||
const GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
const Email = require('../../email');
|
||||
const AddThreepid = require('../../AddThreepid');
|
||||
const SdkConfig = require('../../SdkConfig');
|
||||
|
@ -64,6 +63,7 @@ const gHVersionLabel = function(repo, token='') {
|
|||
const SIMPLE_SETTINGS = [
|
||||
{ id: "urlPreviewsEnabled" },
|
||||
{ id: "autoplayGifsAndVideos" },
|
||||
{ id: "alwaysShowEncryptionIcons" },
|
||||
{ id: "hideReadReceipts" },
|
||||
{ id: "dontSendTypingNotifications" },
|
||||
{ id: "alwaysShowTimestamps" },
|
||||
|
@ -79,14 +79,16 @@ const SIMPLE_SETTINGS = [
|
|||
{ id: "Pill.shouldHidePillAvatar" },
|
||||
{ id: "TextualBody.disableBigEmoji" },
|
||||
{ id: "VideoView.flipVideoHorizontally" },
|
||||
{ id: "TagPanel.disableTagPanel" },
|
||||
{ id: "enableWidgetScreenshots" },
|
||||
];
|
||||
|
||||
// These settings must be defined in SettingsStore
|
||||
const ANALYTICS_SETTINGS = [
|
||||
{
|
||||
id: 'analyticsOptOut',
|
||||
id: 'analyticsOptIn',
|
||||
fn: function(checked) {
|
||||
Analytics[checked ? 'disable' : 'enable']();
|
||||
checked ? Analytics.enable() : Analytics.disable();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@ -794,11 +796,18 @@ module.exports = React.createClass({
|
|||
}
|
||||
return (
|
||||
<div>
|
||||
<h3>{ _t("Bug Report") }</h3>
|
||||
<h3>{ _t("Debug Logs Submission") }</h3>
|
||||
<div className="mx_UserSettings_section">
|
||||
<p>{ _t("Found a bug?") }</p>
|
||||
<button className="mx_UserSettings_button danger"
|
||||
onClick={this._onBugReportClicked}>{ _t('Report it') }
|
||||
<p>{
|
||||
_t( "If you've submitted a bug via GitHub, debug logs can help " +
|
||||
"us track down the problem. Debug logs contain application " +
|
||||
"usage data including your username, the IDs or aliases of " +
|
||||
"the rooms or groups you have visited and the usernames of " +
|
||||
"other users. They do not contain messages.",
|
||||
)
|
||||
}</p>
|
||||
<button className="mx_UserSettings_button"
|
||||
onClick={this._onBugReportClicked}>{ _t('Submit debug logs') }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1110,6 +1119,7 @@ module.exports = React.createClass({
|
|||
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
|
||||
const Notifications = sdk.getComponent("settings.Notifications");
|
||||
const EditableText = sdk.getComponent('elements.EditableText');
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
|
||||
const avatarUrl = (
|
||||
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
|
||||
|
@ -1205,8 +1215,9 @@ module.exports = React.createClass({
|
|||
onCancelClick={this.props.onClose}
|
||||
/>
|
||||
|
||||
<GeminiScrollbar className="mx_UserSettings_body"
|
||||
autoshow={true}>
|
||||
<GeminiScrollbarWrapper
|
||||
className="mx_UserSettings_body"
|
||||
autoshow={true}>
|
||||
|
||||
<h3>{ _t("Profile") }</h3>
|
||||
|
||||
|
@ -1319,7 +1330,7 @@ module.exports = React.createClass({
|
|||
|
||||
{ this._renderDeactivateAccount() }
|
||||
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
57
src/components/structures/ViewSource.js
Normal file
57
src/components/structures/ViewSource.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import SyntaxHighlight from '../views/elements/SyntaxHighlight';
|
||||
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'ViewSource',
|
||||
|
||||
propTypes: {
|
||||
content: PropTypes.object.isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
|
||||
onKeyDown: function(ev) {
|
||||
if (ev.keyCode == 27) { // escape
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
return (
|
||||
<div className="mx_ViewSource">
|
||||
<SyntaxHighlight className="json">
|
||||
{ JSON.stringify(this.props.content, null, 2) }
|
||||
</SyntaxHighlight>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
|
@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler';
|
|||
import sdk from '../../../index';
|
||||
import Modal from "../../../Modal";
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
|
||||
import PasswordReset from "../../../PasswordReset";
|
||||
|
||||
|
@ -185,7 +186,7 @@ module.exports = React.createClass({
|
|||
);
|
||||
} else {
|
||||
let serverConfigSection;
|
||||
if (!config.disable_custom_urls) {
|
||||
if (!SdkConfig.get().disable_custom_urls) {
|
||||
serverConfigSection = (
|
||||
<ServerConfig ref="serverConfig"
|
||||
withToggleButton={true}
|
||||
|
|
|
@ -58,6 +58,7 @@ module.exports = React.createClass({
|
|||
// login shouldn't care how password recovery is done.
|
||||
onForgotPasswordClick: PropTypes.func,
|
||||
onCancelClick: PropTypes.func,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -218,6 +219,8 @@ module.exports = React.createClass({
|
|||
if (config.isUrl !== undefined) {
|
||||
newState.enteredIdentityServerUrl = config.isUrl;
|
||||
}
|
||||
|
||||
this.props.onServerConfigChange(config);
|
||||
this.setState(newState, function() {
|
||||
self._initLoginLogic(config.hsUrl || null, config.isUrl);
|
||||
});
|
||||
|
@ -428,10 +431,10 @@ module.exports = React.createClass({
|
|||
// FIXME: remove status.im theme tweaks
|
||||
const theme = SettingsStore.getValue("theme");
|
||||
if (theme !== "status") {
|
||||
header = <h2>{ _t('Sign in') }</h2>;
|
||||
header = <h2>{ _t('Sign in') } { loader }</h2>;
|
||||
} else {
|
||||
if (!this.state.errorText) {
|
||||
header = <h2>{ _t('Sign in to get started') }</h2>;
|
||||
header = <h2>{ _t('Sign in to get started') } { loader }</h2>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ module.exports = React.createClass({
|
|||
// registration shouldn't know or care how login is done.
|
||||
onLoginClick: PropTypes.func.isRequired,
|
||||
onCancelClick: PropTypes.func,
|
||||
onServerConfigChange: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -131,6 +132,7 @@ module.exports = React.createClass({
|
|||
if (config.isUrl !== undefined) {
|
||||
newState.isUrl = config.isUrl;
|
||||
}
|
||||
this.props.onServerConfigChange(config);
|
||||
this.setState(newState, function() {
|
||||
this._replaceClient();
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -16,6 +17,7 @@ limitations under the License.
|
|||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import AvatarLogic from '../../../Avatar';
|
||||
import sdk from '../../../index';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
|
@ -36,6 +38,10 @@ module.exports = React.createClass({
|
|||
defaultToInitialLetter: PropTypes.bool, // true to add default url
|
||||
},
|
||||
|
||||
contextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient),
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 40,
|
||||
|
@ -49,6 +55,16 @@ module.exports = React.createClass({
|
|||
return this._getState(this.props);
|
||||
},
|
||||
|
||||
componentWillMount() {
|
||||
this.unmounted = false;
|
||||
this.context.matrixClient.on('sync', this.onClientSync);
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.matrixClient.removeListener('sync', this.onClientSync);
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(nextProps) {
|
||||
// work out if we need to call setState (if the image URLs array has changed)
|
||||
const newState = this._getState(nextProps);
|
||||
|
@ -67,6 +83,23 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onClientSync: function(syncState, prevState) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
// This means the state could be RECONNECTING, SYNCING or PREPARED.
|
||||
const reconnected = syncState !== "ERROR" && prevState !== syncState;
|
||||
if (reconnected &&
|
||||
// Did we fall back?
|
||||
this.state.urlsIndex > 0
|
||||
) {
|
||||
// Start from the highest priority URL again
|
||||
this.setState({
|
||||
urlsIndex: 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_getState: function(props) {
|
||||
// work out the full set of urls to try to load. This is formed like so:
|
||||
// imageUrls: [ props.url, props.urls, default image ]
|
||||
|
|
|
@ -1,169 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 Travis Ralston
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from "../../../index";
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import Presence from "../../../Presence";
|
||||
import dispatcher from "../../../dispatcher";
|
||||
import * as ContextualMenu from "../../structures/ContextualMenu";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
// This is an avatar with presence information and controls on it.
|
||||
module.exports = React.createClass({
|
||||
displayName: 'MemberPresenceAvatar',
|
||||
|
||||
propTypes: {
|
||||
member: PropTypes.object.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
width: 40,
|
||||
height: 40,
|
||||
resizeMethod: 'crop',
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
let presenceState = null;
|
||||
let presenceMessage = null;
|
||||
|
||||
// RoomMembers do not necessarily have a user.
|
||||
if (this.props.member.user) {
|
||||
presenceState = this.props.member.user.presence;
|
||||
presenceMessage = this.props.member.user.presenceStatusMsg;
|
||||
}
|
||||
|
||||
return {
|
||||
status: presenceState,
|
||||
message: presenceMessage,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
MatrixClientPeg.get().on("User.presence", this.onUserPresence);
|
||||
this.dispatcherRef = dispatcher.register(this.onAction);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("User.presence", this.onUserPresence);
|
||||
}
|
||||
dispatcher.unregister(this.dispatcherRef);
|
||||
},
|
||||
|
||||
onAction: function(payload) {
|
||||
if (payload.action !== "self_presence_updated") return;
|
||||
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) return;
|
||||
this.setState({
|
||||
status: payload.statusInfo.presence,
|
||||
message: payload.statusInfo.status_msg,
|
||||
});
|
||||
},
|
||||
|
||||
onUserPresence: function(event, user) {
|
||||
if (user.userId !== MatrixClientPeg.get().getUserId()) return;
|
||||
this.setState({
|
||||
status: user.presence,
|
||||
message: user.presenceStatusMsg,
|
||||
});
|
||||
},
|
||||
|
||||
onStatusChange: function(newStatus) {
|
||||
Presence.stopMaintainingStatus();
|
||||
if (newStatus === "online") {
|
||||
Presence.setState(newStatus);
|
||||
} else Presence.setState(newStatus, null, true);
|
||||
},
|
||||
|
||||
onClick: function(e) {
|
||||
const PresenceContextMenu = sdk.getComponent('context_menus.PresenceContextMenu');
|
||||
const elementRect = e.target.getBoundingClientRect();
|
||||
|
||||
// The window X and Y offsets are to adjust position when zoomed in to page
|
||||
const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3;
|
||||
const chevronOffset = 12;
|
||||
let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
||||
y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron
|
||||
|
||||
ContextualMenu.createMenu(PresenceContextMenu, {
|
||||
chevronOffset: chevronOffset,
|
||||
chevronFace: 'bottom',
|
||||
left: x,
|
||||
top: y,
|
||||
menuWidth: 125,
|
||||
currentStatus: this.state.status,
|
||||
onChange: this.onStatusChange,
|
||||
});
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
// XXX NB the following assumes that user is non-null, which is not valid
|
||||
// const presenceState = this.props.member.user.presence;
|
||||
// const presenceLastActiveAgo = this.props.member.user.lastActiveAgo;
|
||||
// const presenceLastTs = this.props.member.user.lastPresenceTs;
|
||||
// const presenceCurrentlyActive = this.props.member.user.currentlyActive;
|
||||
// const presenceMessage = this.props.member.user.presenceStatusMsg;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const MemberAvatar = sdk.getComponent("avatars.MemberAvatar");
|
||||
|
||||
let onClickFn = null;
|
||||
if (this.props.member.userId === MatrixClientPeg.get().getUserId()) {
|
||||
onClickFn = this.onClick;
|
||||
}
|
||||
|
||||
const avatarNode = (
|
||||
<MemberAvatar member={this.props.member} width={this.props.width} height={this.props.height}
|
||||
resizeMethod={this.props.resizeMethod} />
|
||||
);
|
||||
let statusNode = (
|
||||
<span className={"mx_MemberPresenceAvatar_status mx_MemberPresenceAvatar_status_" + this.state.status} />
|
||||
);
|
||||
|
||||
// LABS: Disable presence management functions for now
|
||||
// Also disable the presence information if there's no status information
|
||||
if (!SettingsStore.isFeatureEnabled("feature_presence_management") || !this.state.status) {
|
||||
statusNode = null;
|
||||
onClickFn = null;
|
||||
}
|
||||
|
||||
let avatar = (
|
||||
<div className="mx_MemberPresenceAvatar">
|
||||
{ avatarNode }
|
||||
{ statusNode }
|
||||
</div>
|
||||
);
|
||||
if (onClickFn) {
|
||||
avatar = (
|
||||
<AccessibleButton onClick={onClickFn} className="mx_MemberPresenceAvatar" element="div">
|
||||
{ avatarNode }
|
||||
{ statusNode }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
return avatar;
|
||||
},
|
||||
});
|
|
@ -17,6 +17,7 @@ import React from "react";
|
|||
import PropTypes from 'prop-types';
|
||||
import {ContentRepo} from "matrix-js-sdk";
|
||||
import MatrixClientPeg from "../../../MatrixClientPeg";
|
||||
import Modal from '../../../Modal';
|
||||
import sdk from "../../../index";
|
||||
|
||||
module.exports = React.createClass({
|
||||
|
@ -31,6 +32,7 @@ module.exports = React.createClass({
|
|||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
resizeMethod: PropTypes.string,
|
||||
viewAvatarOnClick: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
@ -48,12 +50,34 @@ module.exports = React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
},
|
||||
|
||||
componentWillReceiveProps: function(newProps) {
|
||||
this.setState({
|
||||
urls: this.getImageUrls(newProps),
|
||||
});
|
||||
},
|
||||
|
||||
onRoomStateEvents: function(ev) {
|
||||
if (!this.props.room ||
|
||||
ev.getRoomId() !== this.props.room.roomId ||
|
||||
ev.getType() !== 'm.room.avatar'
|
||||
) return;
|
||||
|
||||
this.setState({
|
||||
urls: this.getImageUrls(this.props),
|
||||
});
|
||||
},
|
||||
|
||||
getImageUrls: function(props) {
|
||||
return [
|
||||
ContentRepo.getHttpUriForMxc(
|
||||
|
@ -87,10 +111,15 @@ module.exports = React.createClass({
|
|||
|
||||
const mlist = props.room.currentState.members;
|
||||
const userIds = [];
|
||||
const leftUserIds = [];
|
||||
// for .. in optimisation to return early if there are >2 keys
|
||||
for (const uid in mlist) {
|
||||
if (mlist.hasOwnProperty(uid)) {
|
||||
userIds.push(uid);
|
||||
if (["join", "invite"].includes(mlist[uid].membership)) {
|
||||
userIds.push(uid);
|
||||
} else {
|
||||
leftUserIds.push(uid);
|
||||
}
|
||||
}
|
||||
if (userIds.length > 2) {
|
||||
return null;
|
||||
|
@ -112,6 +141,14 @@ module.exports = React.createClass({
|
|||
false,
|
||||
);
|
||||
} else if (userIds.length == 1) {
|
||||
// The other 1-1 user left, leaving just the current user, so show the left user's avatar
|
||||
if (leftUserIds.length === 1) {
|
||||
return mlist[leftUserIds[0]].getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
props.width, props.height, props.resizeMethod,
|
||||
false,
|
||||
);
|
||||
}
|
||||
return mlist[userIds[0]].getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
Math.floor(props.width * window.devicePixelRatio),
|
||||
|
@ -124,17 +161,32 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
onRoomAvatarClick: function() {
|
||||
const avatarUrl = this.props.room.getAvatarUrl(
|
||||
MatrixClientPeg.get().getHomeserverUrl(),
|
||||
null, null, null, false);
|
||||
const ImageView = sdk.getComponent("elements.ImageView");
|
||||
const params = {
|
||||
src: avatarUrl,
|
||||
name: this.props.room.name,
|
||||
};
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
|
||||
|
||||
const {room, oobData, ...otherProps} = this.props;
|
||||
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
|
||||
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
|
||||
|
||||
const roomName = room ? room.name : oobData.name;
|
||||
|
||||
return (
|
||||
<BaseAvatar {...otherProps} name={roomName}
|
||||
idName={room ? room.roomId : null}
|
||||
urls={this.state.urls} />
|
||||
urls={this.state.urls}
|
||||
onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null} />
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/*
|
||||
* This component can be used to display generic HTML content in a contextual
|
||||
* menu.
|
||||
*/
|
||||
|
||||
|
||||
export default class GenericElementContextMenu extends React.Component {
|
||||
static PropTypes = {
|
||||
element: PropTypes.element.isRequired,
|
||||
// Function to be called when the parent window is resized
|
||||
// This can be used to reposition or close the menu on resize and
|
||||
// ensure that it is not displayed in a stale position.
|
||||
onResize: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.resize = this.resize.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.resize = this.resize.bind(this);
|
||||
window.addEventListener("resize", this.resize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("resize", this.resize);
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.props.onResize) {
|
||||
this.props.onResize();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>{ this.props.element }</div>;
|
||||
}
|
||||
}
|
30
src/components/views/context_menus/GenericTextContextMenu.js
Normal file
30
src/components/views/context_menus/GenericTextContextMenu.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class GenericTextContextMenu extends React.Component {
|
||||
static PropTypes = {
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
return <div>{ this.props.message }</div>;
|
||||
}
|
||||
}
|
344
src/components/views/context_menus/MessageContextMenu.js
Normal file
344
src/components/views/context_menus/MessageContextMenu.js
Normal file
|
@ -0,0 +1,344 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher';
|
||||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Modal from '../../../Modal';
|
||||
import Resend from '../../../Resend';
|
||||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
import {makeEventPermalink} from '../../../matrix-to';
|
||||
import { isUrlPermitted } from '../../../HtmlUtils';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'MessageContextMenu',
|
||||
|
||||
propTypes: {
|
||||
/* the MatrixEvent associated with the context menu */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
|
||||
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
|
||||
eventTileOps: PropTypes.object,
|
||||
|
||||
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
|
||||
collapseReplyThread: PropTypes.func,
|
||||
|
||||
/* callback called when the menu is dismissed */
|
||||
onFinished: PropTypes.func,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
canRedact: false,
|
||||
canPin: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions);
|
||||
this._checkPermissions();
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (cli) {
|
||||
cli.removeListener('RoomMember.powerLevel', this._checkPermissions);
|
||||
}
|
||||
},
|
||||
|
||||
_checkPermissions: function() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
||||
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId);
|
||||
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
|
||||
|
||||
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
|
||||
if (!SettingsStore.isFeatureEnabled("feature_pinning")) canPin = false;
|
||||
|
||||
this.setState({canRedact, canPin});
|
||||
},
|
||||
|
||||
_isPinned: function() {
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', '');
|
||||
if (!pinnedEvent) return false;
|
||||
return pinnedEvent.getContent().pinned.includes(this.props.mxEvent.getId());
|
||||
},
|
||||
|
||||
onResendClick: function() {
|
||||
Resend.resend(this.props.mxEvent);
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onViewSourceClick: function() {
|
||||
const ViewSource = sdk.getComponent('structures.ViewSource');
|
||||
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
|
||||
content: this.props.mxEvent.event,
|
||||
}, 'mx_Dialog_viewsource');
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onViewClearSourceClick: function() {
|
||||
const ViewSource = sdk.getComponent('structures.ViewSource');
|
||||
Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, {
|
||||
// FIXME: _clearEvent is private
|
||||
content: this.props.mxEvent._clearEvent,
|
||||
}, 'mx_Dialog_viewsource');
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onRedactClick: function() {
|
||||
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
|
||||
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
|
||||
onFinished: (proceed) => {
|
||||
if (!proceed) return;
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
cli.redactEvent(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()).catch(function(e) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
// display error message stating you couldn't delete this.
|
||||
const code = e.errcode || e.statusCode;
|
||||
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('You cannot delete this message. (%(code)s)', {code}),
|
||||
});
|
||||
}).done();
|
||||
},
|
||||
}, 'mx_Dialog_confirmredact');
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onCancelSendClick: function() {
|
||||
Resend.removeFromQueue(this.props.mxEvent);
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onForwardClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'forward_event',
|
||||
event: this.props.mxEvent,
|
||||
});
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onPinClick: function() {
|
||||
MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '')
|
||||
.catch((e) => {
|
||||
// Intercept the Event Not Found error and fall through the promise chain with no event.
|
||||
if (e.errcode === "M_NOT_FOUND") return null;
|
||||
throw e;
|
||||
})
|
||||
.then((event) => {
|
||||
const eventIds = (event ? event.pinned : []) || [];
|
||||
if (!eventIds.includes(this.props.mxEvent.getId())) {
|
||||
// Not pinned - add
|
||||
eventIds.push(this.props.mxEvent.getId());
|
||||
} else {
|
||||
// Pinned - remove
|
||||
eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
|
||||
}
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, '');
|
||||
});
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
closeMenu: function() {
|
||||
if (this.props.onFinished) this.props.onFinished();
|
||||
},
|
||||
|
||||
onUnhidePreviewClick: function() {
|
||||
if (this.props.eventTileOps) {
|
||||
this.props.eventTileOps.unhideWidget();
|
||||
}
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onQuoteClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'quote',
|
||||
text: this.props.eventTileOps.getInnerText(),
|
||||
});
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onReplyClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'reply_to_event',
|
||||
event: this.props.mxEvent,
|
||||
});
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onCollapseReplyThreadClick: function() {
|
||||
this.props.collapseReplyThread();
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const eventStatus = this.props.mxEvent.status;
|
||||
let resendButton;
|
||||
let redactButton;
|
||||
let cancelButton;
|
||||
let forwardButton;
|
||||
let pinButton;
|
||||
let viewClearSourceButton;
|
||||
let unhidePreviewButton;
|
||||
let externalURLButton;
|
||||
let quoteButton;
|
||||
let replyButton;
|
||||
let collapseReplyThread;
|
||||
|
||||
if (eventStatus === 'not_sent') {
|
||||
resendButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
|
||||
{ _t('Resend') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventStatus && this.state.canRedact) {
|
||||
redactButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
|
||||
{ _t('Remove') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventStatus === "queued" || eventStatus === "not_sent") {
|
||||
cancelButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
|
||||
{ _t('Cancel Sending') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!eventStatus && this.props.mxEvent.getType() === 'm.room.message') {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
|
||||
forwardButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
|
||||
{ _t('Forward Message') }
|
||||
</div>
|
||||
);
|
||||
|
||||
if (SettingsStore.isFeatureEnabled("feature_rich_quoting")) {
|
||||
replyButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
|
||||
{ _t('Reply') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.canPin) {
|
||||
pinButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
|
||||
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const viewSourceButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onViewSourceClick}>
|
||||
{ _t('View Source') }
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.props.mxEvent.getType() !== this.props.mxEvent.getWireType()) {
|
||||
viewClearSourceButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onViewClearSourceClick}>
|
||||
{ _t('View Decrypted Source') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.eventTileOps) {
|
||||
if (this.props.eventTileOps.isWidgetHidden()) {
|
||||
unhidePreviewButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onUnhidePreviewClick}>
|
||||
{ _t('Unhide Preview') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
|
||||
const permalinkButton = (
|
||||
<div className="mx_MessageContextMenu_field">
|
||||
<a href={makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId())}
|
||||
target="_blank" rel="noopener" onClick={this.closeMenu}>{ _t('Permalink') }</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.props.eventTileOps && this.props.eventTileOps.getInnerText) {
|
||||
quoteButton = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onQuoteClick}>
|
||||
{ _t('Quote') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bridges can provide a 'external_url' to link back to the source.
|
||||
if (
|
||||
typeof(this.props.mxEvent.event.content.external_url) === "string" &&
|
||||
isUrlPermitted(this.props.mxEvent.event.content.external_url)
|
||||
) {
|
||||
externalURLButton = (
|
||||
<div className="mx_MessageContextMenu_field">
|
||||
<a href={this.props.mxEvent.event.content.external_url}
|
||||
rel="noopener" target="_blank" onClick={this.closeMenu}>{ _t('Source URL') }</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.props.collapseReplyThread) {
|
||||
collapseReplyThread = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
|
||||
{ _t('Collapse Reply Thread') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ resendButton }
|
||||
{ redactButton }
|
||||
{ cancelButton }
|
||||
{ forwardButton }
|
||||
{ pinButton }
|
||||
{ viewSourceButton }
|
||||
{ viewClearSourceButton }
|
||||
{ unhidePreviewButton }
|
||||
{ permalinkButton }
|
||||
{ quoteButton }
|
||||
{ replyButton }
|
||||
{ externalURLButton }
|
||||
{ collapseReplyThread }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
368
src/components/views/context_menus/RoomTileContextMenu.js
Normal file
368
src/components/views/context_menus/RoomTileContextMenu.js
Normal file
|
@ -0,0 +1,368 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import Promise from 'bluebird';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher';
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
import * as Rooms from '../../../Rooms';
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import Modal from '../../../Modal';
|
||||
import RoomListActions from '../../../actions/RoomListActions';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'RoomTileContextMenu',
|
||||
|
||||
propTypes: {
|
||||
room: PropTypes.object.isRequired,
|
||||
/* callback called when the menu is dismissed */
|
||||
onFinished: PropTypes.func,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
|
||||
return {
|
||||
roomNotifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
|
||||
isFavourite: this.props.room.tags.hasOwnProperty("m.favourite"),
|
||||
isLowPriority: this.props.room.tags.hasOwnProperty("m.lowpriority"),
|
||||
isDirectMessage: Boolean(dmRoomMap.getUserIdForRoomId(this.props.room.roomId)),
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
|
||||
_toggleTag: function(tagNameOn, tagNameOff) {
|
||||
if (!MatrixClientPeg.get().isGuest()) {
|
||||
Promise.delay(500).then(() => {
|
||||
dis.dispatch(RoomListActions.tagRoom(
|
||||
MatrixClientPeg.get(),
|
||||
this.props.room,
|
||||
tagNameOff, tagNameOn,
|
||||
undefined, 0,
|
||||
), true);
|
||||
|
||||
this.props.onFinished();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_onClickFavourite: function() {
|
||||
// Tag room as 'Favourite'
|
||||
if (!this.state.isFavourite && this.state.isLowPriority) {
|
||||
this.setState({
|
||||
isFavourite: true,
|
||||
isLowPriority: false,
|
||||
});
|
||||
this._toggleTag("m.favourite", "m.lowpriority");
|
||||
} else if (this.state.isFavourite) {
|
||||
this.setState({isFavourite: false});
|
||||
this._toggleTag(null, "m.favourite");
|
||||
} else if (!this.state.isFavourite) {
|
||||
this.setState({isFavourite: true});
|
||||
this._toggleTag("m.favourite");
|
||||
}
|
||||
},
|
||||
|
||||
_onClickLowPriority: function() {
|
||||
// Tag room as 'Low Priority'
|
||||
if (!this.state.isLowPriority && this.state.isFavourite) {
|
||||
this.setState({
|
||||
isFavourite: false,
|
||||
isLowPriority: true,
|
||||
});
|
||||
this._toggleTag("m.lowpriority", "m.favourite");
|
||||
} else if (this.state.isLowPriority) {
|
||||
this.setState({isLowPriority: false});
|
||||
this._toggleTag(null, "m.lowpriority");
|
||||
} else if (!this.state.isLowPriority) {
|
||||
this.setState({isLowPriority: true});
|
||||
this._toggleTag("m.lowpriority");
|
||||
}
|
||||
},
|
||||
|
||||
_onClickDM: function() {
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
const newIsDirectMessage = !this.state.isDirectMessage;
|
||||
this.setState({
|
||||
isDirectMessage: newIsDirectMessage,
|
||||
});
|
||||
|
||||
Rooms.guessAndSetDMRoom(
|
||||
this.props.room, newIsDirectMessage,
|
||||
).delay(500).finally(() => {
|
||||
// Close the context menu
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
}, (err) => {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to set Direct Message status of room', '', ErrorDialog, {
|
||||
title: _t('Failed to set Direct Message status of room'),
|
||||
description: ((err && err.message) ? err.message : _t('Operation failed')),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_onClickLeave: function() {
|
||||
// Leave room
|
||||
dis.dispatch({
|
||||
action: 'leave_room',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
|
||||
// Close the context menu
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
},
|
||||
|
||||
_onClickReject: function() {
|
||||
dis.dispatch({
|
||||
action: 'reject_invite',
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
|
||||
// Close the context menu
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
},
|
||||
|
||||
_onClickForget: function() {
|
||||
// FIXME: duplicated with RoomSettings (and dead code in RoomView)
|
||||
MatrixClientPeg.get().forget(this.props.room.roomId).done(function() {
|
||||
dis.dispatch({ action: 'view_next_room' });
|
||||
}, function(err) {
|
||||
const errCode = err.errcode || _td("unknown error code");
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, {
|
||||
title: _t('Failed to forget room %(errCode)s', {errCode: errCode}),
|
||||
description: ((err && err.message) ? err.message : _t('Operation failed')),
|
||||
});
|
||||
});
|
||||
|
||||
// Close the context menu
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
},
|
||||
|
||||
_saveNotifState: function(newState) {
|
||||
if (MatrixClientPeg.get().isGuest()) return;
|
||||
|
||||
const oldState = this.state.roomNotifState;
|
||||
const roomId = this.props.room.roomId;
|
||||
|
||||
this.setState({
|
||||
roomNotifState: newState,
|
||||
});
|
||||
RoomNotifs.setRoomNotifsState(roomId, newState).done(() => {
|
||||
// delay slightly so that the user can see their state change
|
||||
// before closing the menu
|
||||
return Promise.delay(500).then(() => {
|
||||
if (this._unmounted) return;
|
||||
// Close the context menu
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
});
|
||||
}, (error) => {
|
||||
// TODO: some form of error notification to the user
|
||||
// to inform them that their state change failed.
|
||||
// For now we at least set the state back
|
||||
if (this._unmounted) return;
|
||||
this.setState({
|
||||
roomNotifState: oldState,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_onClickAlertMe: function() {
|
||||
this._saveNotifState(RoomNotifs.ALL_MESSAGES_LOUD);
|
||||
},
|
||||
|
||||
_onClickAllNotifs: function() {
|
||||
this._saveNotifState(RoomNotifs.ALL_MESSAGES);
|
||||
},
|
||||
|
||||
_onClickMentions: function() {
|
||||
this._saveNotifState(RoomNotifs.MENTIONS_ONLY);
|
||||
},
|
||||
|
||||
_onClickMute: function() {
|
||||
this._saveNotifState(RoomNotifs.MUTE);
|
||||
},
|
||||
|
||||
_renderNotifMenu: function() {
|
||||
const alertMeClasses = classNames({
|
||||
'mx_RoomTileContextMenu_notif_field': true,
|
||||
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.ALL_MESSAGES_LOUD,
|
||||
});
|
||||
|
||||
const allNotifsClasses = classNames({
|
||||
'mx_RoomTileContextMenu_notif_field': true,
|
||||
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.ALL_MESSAGES,
|
||||
});
|
||||
|
||||
const mentionsClasses = classNames({
|
||||
'mx_RoomTileContextMenu_notif_field': true,
|
||||
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.MENTIONS_ONLY,
|
||||
});
|
||||
|
||||
const muteNotifsClasses = classNames({
|
||||
'mx_RoomTileContextMenu_notif_field': true,
|
||||
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.MUTE,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_RoomTileContextMenu_notif_picker" >
|
||||
<img src="img/notif-slider.svg" width="20" height="107" />
|
||||
</div>
|
||||
<div className={alertMeClasses} onClick={this._onClickAlertMe} >
|
||||
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
|
||||
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-off-copy.svg" width="16" height="12" />
|
||||
{ _t('All messages (noisy)') }
|
||||
</div>
|
||||
<div className={allNotifsClasses} onClick={this._onClickAllNotifs} >
|
||||
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
|
||||
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-off.svg" width="16" height="12" />
|
||||
{ _t('All messages') }
|
||||
</div>
|
||||
<div className={mentionsClasses} onClick={this._onClickMentions} >
|
||||
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
|
||||
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute-mentions.svg" width="16" height="12" />
|
||||
{ _t('Mentions only') }
|
||||
</div>
|
||||
<div className={muteNotifsClasses} onClick={this._onClickMute} >
|
||||
<img className="mx_RoomTileContextMenu_notif_activeIcon" src="img/notif-active.svg" width="12" height="12" />
|
||||
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src="img/icon-context-mute.svg" width="16" height="12" />
|
||||
{ _t('Mute') }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
_renderLeaveMenu: function(membership) {
|
||||
if (!membership) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let leaveClickHandler = null;
|
||||
let leaveText = null;
|
||||
|
||||
switch (membership) {
|
||||
case "join":
|
||||
leaveClickHandler = this._onClickLeave;
|
||||
leaveText = _t('Leave');
|
||||
break;
|
||||
case "leave":
|
||||
case "ban":
|
||||
leaveClickHandler = this._onClickForget;
|
||||
leaveText = _t('Forget');
|
||||
break;
|
||||
case "invite":
|
||||
leaveClickHandler = this._onClickReject;
|
||||
leaveText = _t('Reject');
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mx_RoomTileContextMenu_leave" onClick={leaveClickHandler} >
|
||||
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_delete.svg" width="15" height="15" />
|
||||
{ leaveText }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
_renderRoomTagMenu: function() {
|
||||
const favouriteClasses = classNames({
|
||||
'mx_RoomTileContextMenu_tag_field': true,
|
||||
'mx_RoomTileContextMenu_tag_fieldSet': this.state.isFavourite,
|
||||
'mx_RoomTileContextMenu_tag_fieldDisabled': false,
|
||||
});
|
||||
|
||||
const lowPriorityClasses = classNames({
|
||||
'mx_RoomTileContextMenu_tag_field': true,
|
||||
'mx_RoomTileContextMenu_tag_fieldSet': this.state.isLowPriority,
|
||||
'mx_RoomTileContextMenu_tag_fieldDisabled': false,
|
||||
});
|
||||
|
||||
const dmClasses = classNames({
|
||||
'mx_RoomTileContextMenu_tag_field': true,
|
||||
'mx_RoomTileContextMenu_tag_fieldSet': this.state.isDirectMessage,
|
||||
'mx_RoomTileContextMenu_tag_fieldDisabled': false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={favouriteClasses} onClick={this._onClickFavourite} >
|
||||
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_fave.svg" width="15" height="15" />
|
||||
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_fave_on.svg" width="15" height="15" />
|
||||
{ _t('Favourite') }
|
||||
</div>
|
||||
<div className={lowPriorityClasses} onClick={this._onClickLowPriority} >
|
||||
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_low.svg" width="15" height="15" />
|
||||
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_low_on.svg" width="15" height="15" />
|
||||
{ _t('Low Priority') }
|
||||
</div>
|
||||
<div className={dmClasses} onClick={this._onClickDM} >
|
||||
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_person.svg" width="15" height="15" />
|
||||
<img className="mx_RoomTileContextMenu_tag_icon_set" src="img/icon_context_person_on.svg" width="15" height="15" />
|
||||
{ _t('Direct Chat') }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const myMember = this.props.room.getMember(
|
||||
MatrixClientPeg.get().credentials.userId,
|
||||
);
|
||||
|
||||
// Can't set notif level or tags on non-join rooms
|
||||
if (myMember.membership !== 'join') {
|
||||
return this._renderLeaveMenu(myMember.membership);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{ this._renderNotifMenu() }
|
||||
<hr className="mx_RoomTileContextMenu_separator" />
|
||||
{ this._renderLeaveMenu(myMember.membership) }
|
||||
<hr className="mx_RoomTileContextMenu_separator" />
|
||||
{ this._renderRoomTagMenu() }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
75
src/components/views/context_menus/TagTileContextMenu.js
Normal file
75
src/components/views/context_menus/TagTileContextMenu.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import dis from '../../../dispatcher';
|
||||
import TagOrderActions from '../../../actions/TagOrderActions';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import sdk from '../../../index';
|
||||
|
||||
export default class TagTileContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
tag: PropTypes.string.isRequired,
|
||||
/* callback called when the menu is dismissed */
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._onViewCommunityClick = this._onViewCommunityClick.bind(this);
|
||||
this._onRemoveClick = this._onRemoveClick.bind(this);
|
||||
}
|
||||
|
||||
_onViewCommunityClick() {
|
||||
dis.dispatch({
|
||||
action: 'view_group',
|
||||
group_id: this.props.tag,
|
||||
});
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
||||
_onRemoveClick() {
|
||||
dis.dispatch(TagOrderActions.removeTag(
|
||||
// XXX: Context menus don't have a MatrixClient context
|
||||
MatrixClientPeg.get(),
|
||||
this.props.tag,
|
||||
));
|
||||
this.props.onFinished();
|
||||
}
|
||||
|
||||
render() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
return <div>
|
||||
<div className="mx_TagTileContextMenu_item" onClick={this._onViewCommunityClick} >
|
||||
<TintableSvg
|
||||
className="mx_TagTileContextMenu_item_icon"
|
||||
src="img/icons-groups.svg"
|
||||
width="15"
|
||||
height="15"
|
||||
/>
|
||||
{ _t('View Community') }
|
||||
</div>
|
||||
<hr className="mx_TagTileContextMenu_separator" />
|
||||
<div className="mx_TagTileContextMenu_item" onClick={this._onRemoveClick} >
|
||||
<img className="mx_TagTileContextMenu_item_icon" src="img/icon_context_delete.svg" width="15" height="15" />
|
||||
{ _t('Remove') }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -22,7 +22,7 @@ import sdk from '../../../index';
|
|||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import Promise from 'bluebird';
|
||||
import { addressTypes, getAddressType } from '../../../UserAddress.js';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
|
||||
const TRUNCATE_QUERY_LIST = 40;
|
||||
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
|
||||
|
@ -243,9 +243,8 @@ module.exports = React.createClass({
|
|||
|
||||
_doNaiveGroupRoomSearch: function(query) {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
const groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
|
||||
const results = [];
|
||||
groupStore.getGroupRooms().forEach((r) => {
|
||||
GroupStore.getGroupRooms(this.props.groupId).forEach((r) => {
|
||||
const nameMatch = (r.name || '').toLowerCase().includes(lowerCaseQuery);
|
||||
const topicMatch = (r.topic || '').toLowerCase().includes(lowerCaseQuery);
|
||||
const aliasMatch = (r.canonical_alias || '').toLowerCase().includes(lowerCaseQuery);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,11 +16,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
|
||||
import { KeyCode } from '../../../Keyboard';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import sdk from '../../../index';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
|
||||
/**
|
||||
* Basic container for modal dialogs.
|
||||
|
@ -32,10 +37,20 @@ export default React.createClass({
|
|||
|
||||
propTypes: {
|
||||
// onFinished callback to call when Escape is pressed
|
||||
// Take a boolean which is true if the dialog was dismissed
|
||||
// with a positive / confirm action or false if it was
|
||||
// cancelled (BaseDialog itself only calls this with false).
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
|
||||
// callback to call when Enter is pressed
|
||||
onEnterPressed: PropTypes.func,
|
||||
// Whether the dialog should have a 'close' button that will
|
||||
// cause the dialog to be cancelled. This should only be set
|
||||
// to false if there is nothing the app can sensibly do if the
|
||||
// dialog is cancelled, eg. "We can't restore your session and
|
||||
// the app cannot work". Default: true.
|
||||
hasCancel: PropTypes.bool,
|
||||
|
||||
// called when a key is pressed
|
||||
onKeyDown: PropTypes.func,
|
||||
|
||||
// CSS class to apply to dialog div
|
||||
className: PropTypes.string,
|
||||
|
@ -46,41 +61,73 @@ export default React.createClass({
|
|||
|
||||
// children should be the content of the dialog
|
||||
children: PropTypes.node,
|
||||
|
||||
// Id of content element
|
||||
// If provided, this is used to add a aria-describedby attribute
|
||||
contentId: React.PropTypes.string,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
hasCancel: true,
|
||||
};
|
||||
},
|
||||
|
||||
childContextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient),
|
||||
},
|
||||
|
||||
getChildContext: function() {
|
||||
return {
|
||||
matrixClient: this._matrixClient,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount() {
|
||||
this._matrixClient = MatrixClientPeg.get();
|
||||
},
|
||||
|
||||
_onKeyDown: function(e) {
|
||||
if (e.keyCode === KeyCode.ESCAPE) {
|
||||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
if (this.props.hasCancel && e.keyCode === KeyCode.ESCAPE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.props.onFinished();
|
||||
} else if (e.keyCode === KeyCode.ENTER) {
|
||||
if (this.props.onEnterPressed) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.props.onEnterPressed(e);
|
||||
}
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
},
|
||||
|
||||
_onCancelClick: function(e) {
|
||||
this.props.onFinished();
|
||||
this.props.onFinished(false);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
||||
return (
|
||||
<div onKeyDown={this._onKeyDown} className={this.props.className}>
|
||||
<AccessibleButton onClick={this._onCancelClick}
|
||||
<FocusTrap onKeyDown={this._onKeyDown}
|
||||
className={this.props.className}
|
||||
role="dialog"
|
||||
aria-labelledby='mx_BaseDialog_title'
|
||||
// This should point to a node describing the dialog.
|
||||
// If we were about to completelly follow this recommendation we'd need to
|
||||
// make all the components relying on BaseDialog to be aware of it.
|
||||
// So instead we will use the whole content as the description.
|
||||
// Description comes first and if the content contains more text,
|
||||
// AT users can skip its presentation.
|
||||
aria-describedby={this.props.contentId}
|
||||
>
|
||||
{ this.props.hasCancel ? <AccessibleButton onClick={this._onCancelClick}
|
||||
className="mx_Dialog_cancelButton"
|
||||
>
|
||||
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
|
||||
</AccessibleButton>
|
||||
<div className={'mx_Dialog_title ' + this.props.titleClass}>
|
||||
</AccessibleButton> : null }
|
||||
<div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'>
|
||||
{ this.props.title }
|
||||
</div>
|
||||
{ this.props.children }
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
203
src/components/views/dialogs/BugReportDialog.js
Normal file
203
src/components/views/dialogs/BugReportDialog.js
Normal file
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
Copyright 2017 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import sdk from '../../../index';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default class BugReportDialog extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
sendLogs: true,
|
||||
busy: false,
|
||||
err: null,
|
||||
issueUrl: "",
|
||||
text: "",
|
||||
progress: null,
|
||||
};
|
||||
this._unmounted = false;
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onTextChange = this._onTextChange.bind(this);
|
||||
this._onIssueUrlChange = this._onIssueUrlChange.bind(this);
|
||||
this._onSendLogsChange = this._onSendLogsChange.bind(this);
|
||||
this._sendProgressCallback = this._sendProgressCallback.bind(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
}
|
||||
|
||||
_onCancel(ev) {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
_onSubmit(ev) {
|
||||
const userText =
|
||||
(this.state.text.length > 0 ? this.state.text + '\n\n': '') + 'Issue: ' +
|
||||
(this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given');
|
||||
|
||||
this.setState({ busy: true, progress: null, err: null });
|
||||
this._sendProgressCallback(_t("Preparing to send logs"));
|
||||
|
||||
require(['../../../rageshake/submit-rageshake'], (s) => {
|
||||
s(SdkConfig.get().bug_report_endpoint_url, {
|
||||
userText,
|
||||
sendLogs: true,
|
||||
progressCallback: this._sendProgressCallback,
|
||||
}).then(() => {
|
||||
if (!this._unmounted) {
|
||||
this.props.onFinished(false);
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
// N.B. first param is passed to piwik and so doesn't want i18n
|
||||
Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, {
|
||||
title: _t('Logs sent'),
|
||||
description: _t('Thank you!'),
|
||||
hasCancelButton: false,
|
||||
});
|
||||
}
|
||||
}, (err) => {
|
||||
if (!this._unmounted) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
progress: null,
|
||||
err: _t("Failed to send logs: ") + `${err.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_onTextChange(ev) {
|
||||
this.setState({ text: ev.target.value });
|
||||
}
|
||||
|
||||
_onIssueUrlChange(ev) {
|
||||
this.setState({ issueUrl: ev.target.value });
|
||||
}
|
||||
|
||||
_onSendLogsChange(ev) {
|
||||
this.setState({ sendLogs: ev.target.checked });
|
||||
}
|
||||
|
||||
_sendProgressCallback(progress) {
|
||||
if (this._unmounted) {
|
||||
return;
|
||||
}
|
||||
this.setState({progress: progress});
|
||||
}
|
||||
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
let error = null;
|
||||
if (this.state.err) {
|
||||
error = <div className="error">
|
||||
{this.state.err}
|
||||
</div>;
|
||||
}
|
||||
|
||||
let progress = null;
|
||||
if (this.state.busy) {
|
||||
progress = (
|
||||
<div className="progress">
|
||||
<Loader />
|
||||
{this.state.progress} ...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
|
||||
title={_t('Submit debug logs')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>
|
||||
{ _t(
|
||||
"Debug logs contain application usage data including your " +
|
||||
"username, the IDs or aliases of the rooms or groups you " +
|
||||
"have visited and the usernames of other users. They do " +
|
||||
"not contain messages.",
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
"Riot bugs are tracked on GitHub: <a>create a GitHub issue</a>.",
|
||||
{},
|
||||
{
|
||||
a: (sub) => <a
|
||||
target="_blank"
|
||||
href="https://github.com/vector-im/riot-web/issues/new"
|
||||
>
|
||||
{ sub }
|
||||
</a>,
|
||||
},
|
||||
) }
|
||||
</p>
|
||||
<div className="mx_BugReportDialog_field_container">
|
||||
<label
|
||||
htmlFor="mx_BugReportDialog_issueUrl"
|
||||
className="mx_BugReportDialog_field_label"
|
||||
>
|
||||
{ _t("GitHub issue link:") }
|
||||
</label>
|
||||
<input
|
||||
id="mx_BugReportDialog_issueUrl"
|
||||
type="text"
|
||||
className="mx_BugReportDialog_field_input"
|
||||
onChange={this._onIssueUrlChange}
|
||||
value={this.state.issueUrl}
|
||||
placeholder="https://github.com/vector-im/riot-web/issues/1337"
|
||||
/>
|
||||
</div>
|
||||
<div className="mx_BugReportDialog_field_container">
|
||||
<label
|
||||
htmlFor="mx_BugReportDialog_notes_label"
|
||||
className="mx_BugReportDialog_field_label"
|
||||
>
|
||||
{ _t("Notes:") }
|
||||
</label>
|
||||
<textarea
|
||||
className="mx_BugReportDialog_field_input"
|
||||
rows={5}
|
||||
onChange={this._onTextChange}
|
||||
value={this.state.text}
|
||||
/>
|
||||
</div>
|
||||
{progress}
|
||||
{error}
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t("Send logs")}
|
||||
onPrimaryButtonClick={this._onSubmit}
|
||||
focus={true}
|
||||
onCancel={this._onCancel}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
BugReportDialog.propTypes = {
|
||||
onFinished: React.PropTypes.func.isRequired,
|
||||
};
|
94
src/components/views/dialogs/ChangelogDialog.js
Normal file
94
src/components/views/dialogs/ChangelogDialog.js
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
Copyright 2016 Aviral Dasgupta
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import sdk from '../../../index';
|
||||
import request from 'browser-request';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
const REPOS = ['vector-im/riot-web', 'matrix-org/matrix-react-sdk', 'matrix-org/matrix-js-sdk'];
|
||||
|
||||
export default class ChangelogDialog extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const version = this.props.newVersion.split('-');
|
||||
const version2 = this.props.version.split('-');
|
||||
if(version == null || version2 == null) return;
|
||||
// parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version]
|
||||
for(let i=0; i<REPOS.length; i++) {
|
||||
const oldVersion = version2[2*i];
|
||||
const newVersion = version[2*i];
|
||||
request(`https://api.github.com/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`, (a, b, body) => {
|
||||
if(body == null) return;
|
||||
this.setState({[REPOS[i]]: JSON.parse(body).commits});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_elementsForCommit(commit) {
|
||||
return (
|
||||
<li key={commit.sha} className="mx_ChangelogDialog_li">
|
||||
<a href={commit.html_url} target="_blank" rel="noopener">
|
||||
{commit.commit.message}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const Spinner = sdk.getComponent('views.elements.Spinner');
|
||||
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
|
||||
|
||||
const logs = REPOS.map(repo => {
|
||||
if (this.state[repo] == null) return <Spinner key={repo} />;
|
||||
return (
|
||||
<div key={repo}>
|
||||
<h2>{repo}</h2>
|
||||
<ul>
|
||||
{this.state[repo].map(this._elementsForCommit)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
const content = (
|
||||
<div className="mx_ChangelogDialog_content">
|
||||
{this.props.version == null || this.props.newVersion == null ? <h2>{_t("Unavailable")}</h2> : logs}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<QuestionDialog
|
||||
title={_t("Changelog")}
|
||||
description={content}
|
||||
button={_t("Update")}
|
||||
onFinished={this.props.onFinished}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChangelogDialog.propTypes = {
|
||||
version: React.PropTypes.string.isRequired,
|
||||
newVersion: React.PropTypes.string.isRequired,
|
||||
onFinished: React.PropTypes.func.isRequired,
|
||||
};
|
|
@ -59,6 +59,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
);
|
||||
tiles.push(
|
||||
<RoomTile key={room.roomId} room={room}
|
||||
transparent={true}
|
||||
collapsed={false}
|
||||
selected={false}
|
||||
unread={Unread.doesRoomHaveUnreadMessages(room)}
|
||||
|
@ -128,7 +129,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
</div>
|
||||
<div className={labelClasses}><i>{ _t("Start new chat") }</i></div>
|
||||
</AccessibleButton>;
|
||||
content = <div className="mx_Dialog_content">
|
||||
content = <div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
{ _t('You already have existing direct chats with this user:') }
|
||||
<div className="mx_ChatCreateOrReuseDialog_tiles">
|
||||
{ this.state.tiles }
|
||||
|
@ -146,7 +147,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
if (this.state.busyProfile) {
|
||||
profile = <Spinner />;
|
||||
} else if (this.state.profileError) {
|
||||
profile = <div className="error">
|
||||
profile = <div className="error" role="alert">
|
||||
Unable to load profile information for { this.props.userId }
|
||||
</div>;
|
||||
} else {
|
||||
|
@ -162,14 +163,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
</div>;
|
||||
}
|
||||
content = <div>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>
|
||||
{ _t('Click on the button below to start chatting!') }
|
||||
</p>
|
||||
{ profile }
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t('Start Chatting')}
|
||||
onPrimaryButtonClick={this.props.onNewDMClick} />
|
||||
onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -178,6 +179,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
|
|||
<BaseDialog className='mx_ChatCreateOrReuseDialog'
|
||||
onFinished={this.props.onFinished.bind(false)}
|
||||
title={title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
{ content }
|
||||
</BaseDialog>
|
||||
|
|
|
@ -114,10 +114,10 @@ export default React.createClass({
|
|||
|
||||
return (
|
||||
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div id="mx_Dialog_content" className="mx_Dialog_content">
|
||||
<div className="mx_ConfirmUserActionDialog_avatar">
|
||||
{ avatar }
|
||||
</div>
|
||||
|
|
|
@ -55,11 +55,15 @@ export default React.createClass({
|
|||
|
||||
_checkGroupId: function(e) {
|
||||
let error = null;
|
||||
if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) {
|
||||
if (!this.state.groupId) {
|
||||
error = _t("Community IDs cannot not be empty.");
|
||||
} else if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) {
|
||||
error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'");
|
||||
}
|
||||
this.setState({
|
||||
groupIdError: error,
|
||||
// Reset createError to get rid of now stale error message
|
||||
createError: null,
|
||||
});
|
||||
return error;
|
||||
},
|
||||
|
@ -108,7 +112,7 @@ export default React.createClass({
|
|||
// XXX: We should catch errcodes and give sensible i18ned messages for them,
|
||||
// rather than displaying what the server gives us, but synapse doesn't give
|
||||
// any yet.
|
||||
createErrorNode = <div className="error">
|
||||
createErrorNode = <div className="error" role="alert">
|
||||
<div>{ _t('Something went wrong whilst creating your community') }</div>
|
||||
<div>{ this.state.createError.message }</div>
|
||||
</div>;
|
||||
|
@ -116,7 +120,6 @@ export default React.createClass({
|
|||
|
||||
return (
|
||||
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this._onFormSubmit}
|
||||
title={_t('Create Community')}
|
||||
>
|
||||
<form onSubmit={this._onFormSubmit}>
|
||||
|
|
|
@ -45,30 +45,31 @@ export default React.createClass({
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return (
|
||||
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={_t('Create Room')}
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_CreateRoomDialog_label">
|
||||
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<details className="mx_CreateRoomDialog_details">
|
||||
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
|
||||
<div>
|
||||
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
|
||||
<label htmlFor="checkbox">
|
||||
{ _t('Block users on other matrix homeservers from joining this room') }
|
||||
<br />
|
||||
({ _t('This setting cannot be changed later!') })
|
||||
</label>
|
||||
<form onSubmit={this.onOk}>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_CreateRoomDialog_label">
|
||||
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<details className="mx_CreateRoomDialog_details">
|
||||
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
|
||||
<div>
|
||||
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
|
||||
<label htmlFor="checkbox">
|
||||
{ _t('Block users on other matrix homeservers from joining this room') }
|
||||
<br />
|
||||
({ _t('This setting cannot be changed later!') })
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</form>
|
||||
<DialogButtons primaryButton={_t('Create Room')}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
onCancel={this.onCancel} />
|
||||
|
|
|
@ -33,10 +33,21 @@ export default class DeactivateAccountDialog extends React.Component {
|
|||
this._onOk = this._onOk.bind(this);
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onPasswordFieldChange = this._onPasswordFieldChange.bind(this);
|
||||
this._onEraseFieldChange = this._onEraseFieldChange.bind(this);
|
||||
|
||||
const deactivationPreferences =
|
||||
MatrixClientPeg.get().getAccountData('im.riot.account_deactivation_preferences');
|
||||
|
||||
const shouldErase = (
|
||||
deactivationPreferences &&
|
||||
deactivationPreferences.getContent() &&
|
||||
deactivationPreferences.getContent().shouldErase
|
||||
) || false;
|
||||
|
||||
this.state = {
|
||||
confirmButtonEnabled: false,
|
||||
busy: false,
|
||||
shouldErase,
|
||||
errStr: null,
|
||||
};
|
||||
}
|
||||
|
@ -47,19 +58,55 @@ export default class DeactivateAccountDialog extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
_onOk() {
|
||||
// This assumes that the HS requires password UI auth
|
||||
// for this endpoint. In reality it could be any UI auth.
|
||||
_onEraseFieldChange(ev) {
|
||||
this.setState({
|
||||
shouldErase: ev.target.checked,
|
||||
});
|
||||
}
|
||||
|
||||
async _onOk() {
|
||||
this.setState({busy: true});
|
||||
MatrixClientPeg.get().deactivateAccount({
|
||||
type: 'm.login.password',
|
||||
user: MatrixClientPeg.get().credentials.userId,
|
||||
password: this._passwordField.value,
|
||||
}).done(() => {
|
||||
Analytics.trackEvent('Account', 'Deactivate Account');
|
||||
Lifecycle.onLoggedOut();
|
||||
this.props.onFinished(false);
|
||||
}, (err) => {
|
||||
|
||||
// Before we deactivate the account insert an event into
|
||||
// the user's account data indicating that they wish to be
|
||||
// erased from the homeserver.
|
||||
//
|
||||
// We do this because the API for erasing after deactivation
|
||||
// might not be supported by the connected homeserver. Leaving
|
||||
// an indication in account data is only best-effort, and
|
||||
// in the worse case, the HS maintainer would have to run a
|
||||
// script to erase deactivated accounts that have shouldErase
|
||||
// set to true in im.riot.account_deactivation_preferences.
|
||||
//
|
||||
// Note: The preferences are scoped to Riot, hence the
|
||||
// "im.riot..." event type.
|
||||
//
|
||||
// Note: This may have already been set on previous attempts
|
||||
// where, for example, the user entered the wrong password.
|
||||
// This is fine because the UI always indicates the preference
|
||||
// prior to us calling `deactivateAccount`.
|
||||
try {
|
||||
await MatrixClientPeg.get().setAccountData('im.riot.account_deactivation_preferences', {
|
||||
shouldErase: this.state.shouldErase,
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
busy: false,
|
||||
errStr: _t('Failed to indicate account erasure'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// This assumes that the HS requires password UI auth
|
||||
// for this endpoint. In reality it could be any UI auth.
|
||||
const auth = {
|
||||
type: 'm.login.password',
|
||||
user: MatrixClientPeg.get().credentials.userId,
|
||||
password: this._passwordField.value,
|
||||
};
|
||||
await MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase);
|
||||
} catch (err) {
|
||||
let errStr = _t('Unknown error');
|
||||
// https://matrix.org/jira/browse/SYN-744
|
||||
if (err.httpStatus == 401 || err.httpStatus == 403) {
|
||||
|
@ -70,7 +117,12 @@ export default class DeactivateAccountDialog extends React.Component {
|
|||
busy: false,
|
||||
errStr: errStr,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Analytics.trackEvent('Account', 'Deactivate Account');
|
||||
Lifecycle.onLoggedOut();
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
_onCancel() {
|
||||
|
@ -105,21 +157,64 @@ export default class DeactivateAccountDialog extends React.Component {
|
|||
onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
titleClass="danger"
|
||||
title={_t("Deactivate Account")}>
|
||||
title={_t("Deactivate Account")}
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>{ _t("This will make your account permanently unusable. You will not be able to re-register the same user ID.") }</p>
|
||||
<p>{ _t(
|
||||
"This will make your account permanently unusable. " +
|
||||
"You will not be able to log in, and no one will be able to re-register the same " +
|
||||
"user ID. " +
|
||||
"This will cause your account to leave all rooms it is participating in, and it " +
|
||||
"will remove your account details from your identity server. " +
|
||||
"<b>This action is irreversible.</b>",
|
||||
{},
|
||||
{ b: (sub) => <b> { sub } </b> },
|
||||
) }</p>
|
||||
|
||||
<p>{ _t("This action is irreversible.") }</p>
|
||||
<p>{ _t(
|
||||
"Deactivating your account <b>does not by default cause us to forget messages you " +
|
||||
"have sent.</b> " +
|
||||
"If you would like us to forget your messages, please tick the box below.",
|
||||
{},
|
||||
{ b: (sub) => <b> { sub } </b> },
|
||||
) }</p>
|
||||
|
||||
<p>{ _t("To continue, please enter your password.") }</p>
|
||||
<p>{ _t(
|
||||
"Message visibility in Matrix is similar to email. " +
|
||||
"Our forgetting your messages means that messages you have sent will not be shared " +
|
||||
"with any new or unregistered users, but registered users who already have access " +
|
||||
"to these messages will still have access to their copy.",
|
||||
) }</p>
|
||||
|
||||
<div className="mx_DeactivateAccountDialog_input_section">
|
||||
<p>
|
||||
<label htmlFor="mx_DeactivateAccountDialog_erase_account_input">
|
||||
<input
|
||||
id="mx_DeactivateAccountDialog_erase_account_input"
|
||||
type="checkbox"
|
||||
checked={this.state.shouldErase}
|
||||
onChange={this._onEraseFieldChange}
|
||||
/>
|
||||
{ _t(
|
||||
"Please forget all messages I have sent when my account is deactivated " +
|
||||
"(<b>Warning:</b> this will cause future users to see an incomplete view " +
|
||||
"of conversations)",
|
||||
{},
|
||||
{ b: (sub) => <b>{ sub }</b> },
|
||||
) }
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<p>{ _t("To continue, please enter your password:") }</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={_t("password")}
|
||||
onChange={this._onPasswordFieldChange}
|
||||
ref={(e) => {this._passwordField = e;}}
|
||||
className={passwordBoxClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p>{ _t("Password") }:</p>
|
||||
<input
|
||||
type="password"
|
||||
onChange={this._onPasswordFieldChange}
|
||||
ref={(e) => {this._passwordField = e;}}
|
||||
className={passwordBoxClass}
|
||||
/>
|
||||
{ error }
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
|
|
623
src/components/views/dialogs/DevtoolsDialog.js
Normal file
623
src/components/views/dialogs/DevtoolsDialog.js
Normal file
|
@ -0,0 +1,623 @@
|
|||
/*
|
||||
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import SyntaxHighlight from '../elements/SyntaxHighlight';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
|
||||
class DevtoolsComponent extends React.Component {
|
||||
static contextTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
}
|
||||
|
||||
class GenericEditor extends DevtoolsComponent {
|
||||
// static propTypes = {onBack: PropTypes.func.isRequired};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this._onChange = this._onChange.bind(this);
|
||||
this.onBack = this.onBack.bind(this);
|
||||
}
|
||||
|
||||
onBack() {
|
||||
if (this.state.message) {
|
||||
this.setState({ message: null });
|
||||
} else {
|
||||
this.props.onBack();
|
||||
}
|
||||
}
|
||||
|
||||
_onChange(e) {
|
||||
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
|
||||
}
|
||||
|
||||
_buttons() {
|
||||
return <div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
|
||||
</div>;
|
||||
}
|
||||
|
||||
textInput(id, label) {
|
||||
return <div className="mx_DevTools_inputRow">
|
||||
<div className="mx_DevTools_inputLabelCell">
|
||||
<label htmlFor={id}>{ label }</label>
|
||||
</div>
|
||||
<div className="mx_DevTools_inputCell">
|
||||
<input id={id} className="mx_TextInputDialog_input" onChange={this._onChange} value={this.state[id]} size="32" />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
class SendCustomEvent extends GenericEditor {
|
||||
static getLabel() { return _t('Send Custom Event'); }
|
||||
|
||||
static propTypes = {
|
||||
onBack: PropTypes.func.isRequired,
|
||||
forceStateEvent: PropTypes.bool,
|
||||
inputs: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this._send = this._send.bind(this);
|
||||
|
||||
const {eventType, stateKey, evContent} = Object.assign({
|
||||
eventType: '',
|
||||
stateKey: '',
|
||||
evContent: '{\n\n}',
|
||||
}, this.props.inputs);
|
||||
|
||||
this.state = {
|
||||
isStateEvent: Boolean(this.props.forceStateEvent),
|
||||
|
||||
eventType,
|
||||
stateKey,
|
||||
evContent,
|
||||
};
|
||||
}
|
||||
|
||||
send(content) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (this.state.isStateEvent) {
|
||||
return cli.sendStateEvent(this.context.roomId, this.state.eventType, content, this.state.stateKey);
|
||||
} else {
|
||||
return cli.sendEvent(this.context.roomId, this.state.eventType, content);
|
||||
}
|
||||
}
|
||||
|
||||
async _send() {
|
||||
if (this.state.eventType === '') {
|
||||
this.setState({ message: _t('You must specify an event type!') });
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
const content = JSON.parse(this.state.evContent);
|
||||
await this.send(content);
|
||||
message = _t('Event sent!');
|
||||
} catch (e) {
|
||||
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
|
||||
}
|
||||
this.setState({ message });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.message) {
|
||||
return <div>
|
||||
<div className="mx_Dialog_content">
|
||||
{ this.state.message }
|
||||
</div>
|
||||
{ this._buttons() }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className="mx_Dialog_content">
|
||||
{ this.textInput('eventType', _t('Event Type')) }
|
||||
{ this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) }
|
||||
|
||||
<br />
|
||||
|
||||
<div className="mx_UserSettings_profileLabelCell">
|
||||
<label htmlFor="evContent"> { _t('Event Content') } </label>
|
||||
</div>
|
||||
<div>
|
||||
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_TextInputDialog_input" cols="63" rows="5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
|
||||
{ !this.state.message && !this.props.forceStateEvent && <div style={{float: "right"}}>
|
||||
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isStateEvent} />
|
||||
<label className="mx_DevTools_tgl-btn" data-tg-off="Event" data-tg-on="State Event" htmlFor="isStateEvent" />
|
||||
</div> }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
class SendAccountData extends GenericEditor {
|
||||
static getLabel() { return _t('Send Account Data'); }
|
||||
|
||||
static propTypes = {
|
||||
isRoomAccountData: PropTypes.bool,
|
||||
forceMode: PropTypes.bool,
|
||||
inputs: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this._send = this._send.bind(this);
|
||||
|
||||
const {eventType, evContent} = Object.assign({
|
||||
eventType: '',
|
||||
evContent: '{\n\n}',
|
||||
}, this.props.inputs);
|
||||
|
||||
this.state = {
|
||||
isRoomAccountData: Boolean(this.props.isRoomAccountData),
|
||||
|
||||
eventType,
|
||||
evContent,
|
||||
};
|
||||
}
|
||||
|
||||
send(content) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (this.state.isRoomAccountData) {
|
||||
return cli.setRoomAccountData(this.context.roomId, this.state.eventType, content);
|
||||
}
|
||||
return cli.setAccountData(this.state.eventType, content);
|
||||
}
|
||||
|
||||
async _send() {
|
||||
if (this.state.eventType === '') {
|
||||
this.setState({ message: _t('You must specify an event type!') });
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
const content = JSON.parse(this.state.evContent);
|
||||
await this.send(content);
|
||||
message = _t('Event sent!');
|
||||
} catch (e) {
|
||||
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
|
||||
}
|
||||
this.setState({ message });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.message) {
|
||||
return <div>
|
||||
<div className="mx_Dialog_content">
|
||||
{ this.state.message }
|
||||
</div>
|
||||
{ this._buttons() }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className="mx_Dialog_content">
|
||||
{ this.textInput('eventType', _t('Event Type')) }
|
||||
<br />
|
||||
|
||||
<div className="mx_UserSettings_profileLabelCell">
|
||||
<label htmlFor="evContent"> { _t('Event Content') } </label>
|
||||
</div>
|
||||
<div>
|
||||
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_TextInputDialog_input" cols="63" rows="5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> }
|
||||
{ !this.state.message && <div style={{float: "right"}}>
|
||||
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} disabled={this.props.forceMode} />
|
||||
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
|
||||
</div> }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
class FilteredList extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.any,
|
||||
query: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onQuery = this.onQuery.bind(this);
|
||||
}
|
||||
|
||||
onQuery(ev) {
|
||||
if (this.props.onChange) this.props.onChange(ev.target.value);
|
||||
}
|
||||
|
||||
filterChildren() {
|
||||
if (this.props.query) {
|
||||
const lowerQuery = this.props.query.toLowerCase();
|
||||
return this.props.children.filter((child) => child.key.toLowerCase().includes(lowerQuery));
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<input size="64"
|
||||
onChange={this.onQuery}
|
||||
value={this.props.query}
|
||||
placeholder={_t('Filter results')}
|
||||
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" />
|
||||
{ this.filterChildren() }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
class RoomStateExplorer extends DevtoolsComponent {
|
||||
static getLabel() { return _t('Explore Room State'); }
|
||||
|
||||
|
||||
static propTypes = {
|
||||
onBack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const room = MatrixClientPeg.get().getRoom(this.context.roomId);
|
||||
this.roomStateEvents = room.currentState.events;
|
||||
|
||||
this.onBack = this.onBack.bind(this);
|
||||
this.editEv = this.editEv.bind(this);
|
||||
this.onQueryEventType = this.onQueryEventType.bind(this);
|
||||
this.onQueryStateKey = this.onQueryStateKey.bind(this);
|
||||
|
||||
this.state = {
|
||||
eventType: null,
|
||||
event: null,
|
||||
editing: false,
|
||||
|
||||
queryEventType: '',
|
||||
queryStateKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
browseEventType(eventType) {
|
||||
return () => {
|
||||
this.setState({ eventType });
|
||||
};
|
||||
}
|
||||
|
||||
onViewSourceClick(event) {
|
||||
return () => {
|
||||
this.setState({ event });
|
||||
};
|
||||
}
|
||||
|
||||
onBack() {
|
||||
if (this.state.editing) {
|
||||
this.setState({ editing: false });
|
||||
} else if (this.state.event) {
|
||||
this.setState({ event: null });
|
||||
} else if (this.state.eventType) {
|
||||
this.setState({ eventType: null });
|
||||
} else {
|
||||
this.props.onBack();
|
||||
}
|
||||
}
|
||||
|
||||
editEv() {
|
||||
this.setState({ editing: true });
|
||||
}
|
||||
|
||||
onQueryEventType(filterEventType) {
|
||||
this.setState({ queryEventType: filterEventType });
|
||||
}
|
||||
|
||||
onQueryStateKey(filterStateKey) {
|
||||
this.setState({ queryStateKey: filterStateKey });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.event) {
|
||||
if (this.state.editing) {
|
||||
return <SendCustomEvent forceStateEvent={true} onBack={this.onBack} inputs={{
|
||||
eventType: this.state.event.getType(),
|
||||
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
|
||||
stateKey: this.state.event.getStateKey(),
|
||||
}} />;
|
||||
}
|
||||
|
||||
return <div className="mx_ViewSource">
|
||||
<div className="mx_Dialog_content">
|
||||
<SyntaxHighlight className="json">
|
||||
{ JSON.stringify(this.state.event.event, null, 2) }
|
||||
</SyntaxHighlight>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
<button onClick={this.editEv}>{ _t('Edit') }</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let list = null;
|
||||
|
||||
const classes = 'mx_DevTools_RoomStateExplorer_button';
|
||||
if (this.state.eventType === null) {
|
||||
list = <FilteredList query={this.state.queryEventType} onChange={this.onQueryEventType}>
|
||||
{
|
||||
Object.keys(this.roomStateEvents).map((evType) => {
|
||||
const stateGroup = this.roomStateEvents[evType];
|
||||
const stateKeys = Object.keys(stateGroup);
|
||||
|
||||
let onClickFn;
|
||||
if (stateKeys.length > 1) {
|
||||
onClickFn = this.browseEventType(evType);
|
||||
} else if (stateKeys.length === 1) {
|
||||
onClickFn = this.onViewSourceClick(stateGroup[stateKeys[0]]);
|
||||
}
|
||||
|
||||
return <button className={classes} key={evType} onClick={onClickFn}>
|
||||
{ evType }
|
||||
</button>;
|
||||
})
|
||||
}
|
||||
</FilteredList>;
|
||||
} else {
|
||||
const stateGroup = this.roomStateEvents[this.state.eventType];
|
||||
|
||||
list = <FilteredList query={this.state.queryStateKey} onChange={this.onQueryStateKey}>
|
||||
{
|
||||
Object.keys(stateGroup).map((stateKey) => {
|
||||
const ev = stateGroup[stateKey];
|
||||
return <button className={classes} key={stateKey} onClick={this.onViewSourceClick(ev)}>
|
||||
{ stateKey }
|
||||
</button>;
|
||||
})
|
||||
}
|
||||
</FilteredList>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className="mx_Dialog_content">
|
||||
{ list }
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
class AccountDataExplorer extends DevtoolsComponent {
|
||||
static getLabel() { return _t('Explore Account Data'); }
|
||||
|
||||
static propTypes = {
|
||||
onBack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.onBack = this.onBack.bind(this);
|
||||
this.editEv = this.editEv.bind(this);
|
||||
this._onChange = this._onChange.bind(this);
|
||||
this.onQueryEventType = this.onQueryEventType.bind(this);
|
||||
|
||||
this.state = {
|
||||
isRoomAccountData: false,
|
||||
event: null,
|
||||
editing: false,
|
||||
|
||||
queryEventType: '',
|
||||
};
|
||||
}
|
||||
|
||||
getData() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (this.state.isRoomAccountData) {
|
||||
return cli.getRoom(this.context.roomId).accountData;
|
||||
}
|
||||
return cli.store.accountData;
|
||||
}
|
||||
|
||||
onViewSourceClick(event) {
|
||||
return () => {
|
||||
this.setState({ event });
|
||||
};
|
||||
}
|
||||
|
||||
onBack() {
|
||||
if (this.state.editing) {
|
||||
this.setState({ editing: false });
|
||||
} else if (this.state.event) {
|
||||
this.setState({ event: null });
|
||||
} else {
|
||||
this.props.onBack();
|
||||
}
|
||||
}
|
||||
|
||||
_onChange(e) {
|
||||
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
|
||||
}
|
||||
|
||||
editEv() {
|
||||
this.setState({ editing: true });
|
||||
}
|
||||
|
||||
onQueryEventType(queryEventType) {
|
||||
this.setState({ queryEventType });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.event) {
|
||||
if (this.state.editing) {
|
||||
return <SendAccountData isRoomAccountData={this.state.isRoomAccountData} onBack={this.onBack} inputs={{
|
||||
eventType: this.state.event.getType(),
|
||||
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
|
||||
}} forceMode={true} />;
|
||||
}
|
||||
|
||||
return <div className="mx_ViewSource">
|
||||
<div className="mx_Dialog_content">
|
||||
<SyntaxHighlight className="json">
|
||||
{ JSON.stringify(this.state.event.event, null, 2) }
|
||||
</SyntaxHighlight>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
<button onClick={this.editEv}>{ _t('Edit') }</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
|
||||
const classes = 'mx_DevTools_RoomStateExplorer_button';
|
||||
|
||||
const data = this.getData();
|
||||
Object.keys(data).forEach((evType) => {
|
||||
const ev = data[evType];
|
||||
rows.push(<button className={classes} key={evType} onClick={this.onViewSourceClick(ev)}>
|
||||
{ evType }
|
||||
</button>);
|
||||
});
|
||||
|
||||
return <div>
|
||||
<div className="mx_Dialog_content">
|
||||
<FilteredList query={this.state.queryEventType} onChange={this.onQueryEventType}>
|
||||
{ rows }
|
||||
</FilteredList>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onBack}>{ _t('Back') }</button>
|
||||
{ !this.state.message && <div style={{float: "right"}}>
|
||||
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} />
|
||||
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" />
|
||||
</div> }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const Entries = [
|
||||
SendCustomEvent,
|
||||
RoomStateExplorer,
|
||||
SendAccountData,
|
||||
AccountDataExplorer,
|
||||
];
|
||||
|
||||
export default class DevtoolsDialog extends React.Component {
|
||||
static childContextTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
// client: PropTypes.instanceOf(MatixClient),
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onBack = this.onBack.bind(this);
|
||||
this.onCancel = this.onCancel.bind(this);
|
||||
|
||||
this.state = {
|
||||
mode: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
}
|
||||
|
||||
getChildContext() {
|
||||
return { roomId: this.props.roomId };
|
||||
}
|
||||
|
||||
_setMode(mode) {
|
||||
return () => {
|
||||
this.setState({ mode });
|
||||
};
|
||||
}
|
||||
|
||||
onBack() {
|
||||
if (this.prevMode) {
|
||||
this.setState({ mode: this.prevMode });
|
||||
this.prevMode = null;
|
||||
} else {
|
||||
this.setState({ mode: null });
|
||||
}
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
|
||||
render() {
|
||||
let body;
|
||||
|
||||
if (this.state.mode) {
|
||||
body = <div>
|
||||
<div className="mx_DevTools_label_left">{ this.state.mode.getLabel() }</div>
|
||||
<div className="mx_DevTools_label_right">Room ID: { this.props.roomId }</div>
|
||||
<div className="mx_DevTools_label_bottom" />
|
||||
<this.state.mode onBack={this.onBack} />
|
||||
</div>;
|
||||
} else {
|
||||
const classes = "mx_DevTools_RoomStateExplorer_button";
|
||||
body = <div>
|
||||
<div>
|
||||
<div className="mx_DevTools_label_left">{ _t('Toolbox') }</div>
|
||||
<div className="mx_DevTools_label_right">Room ID: { this.props.roomId }</div>
|
||||
<div className="mx_DevTools_label_bottom" />
|
||||
|
||||
<div className="mx_Dialog_content">
|
||||
{ Entries.map((Entry) => {
|
||||
const label = Entry.getLabel();
|
||||
const onClick = this._setMode(Entry);
|
||||
return <button className={classes} key={label} onClick={onClick}>{ label }</button>;
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this.onCancel}>{ _t('Cancel') }</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
return (
|
||||
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished} title={_t('Developer Tools')}>
|
||||
{ body }
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -52,22 +52,18 @@ export default React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
if (this.props.focus) {
|
||||
this.refs.button.focus();
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
return (
|
||||
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
|
||||
title={this.props.title || _t('Error')}>
|
||||
<div className="mx_Dialog_content">
|
||||
title={this.props.title || _t('Error')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
{ this.props.description || _t('An error has occurred.') }
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button ref="button" className="mx_Dialog_primary" onClick={this.props.onFinished}>
|
||||
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
|
||||
{ this.props.button || _t('OK') }
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -73,11 +73,12 @@ export default React.createClass({
|
|||
let content;
|
||||
if (this.state.authError) {
|
||||
content = (
|
||||
<div>
|
||||
<div>{ this.state.authError.message || this.state.authError.toString() }</div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<div role="alert">{ this.state.authError.message || this.state.authError.toString() }</div>
|
||||
<br />
|
||||
<AccessibleButton onClick={this._onDismissClick}
|
||||
className="mx_UserSettings_button"
|
||||
autoFocus="true"
|
||||
>
|
||||
{ _t("Dismiss") }
|
||||
</AccessibleButton>
|
||||
|
@ -85,7 +86,7 @@ export default React.createClass({
|
|||
);
|
||||
} else {
|
||||
content = (
|
||||
<div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<InteractiveAuth ref={this._collectInteractiveAuth}
|
||||
matrixClient={this.props.matrixClient}
|
||||
authData={this.props.authData}
|
||||
|
@ -100,6 +101,7 @@ export default React.createClass({
|
|||
<BaseDialog className="mx_InteractiveAuthDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={this.state.authError ? 'Error' : (this.props.title || _t('Authentication'))}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
{ content }
|
||||
</BaseDialog>
|
||||
|
|
|
@ -126,11 +126,11 @@ export default React.createClass({
|
|||
text = _t(text, {displayName: displayName});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<p>{ text }</p>
|
||||
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button onClick={this._onVerifyClicked}>
|
||||
<button onClick={this._onVerifyClicked} autoFocus="true">
|
||||
{ _t('Start verification') }
|
||||
</button>
|
||||
<button onClick={this._onShareClicked}>
|
||||
|
@ -154,7 +154,7 @@ export default React.createClass({
|
|||
content = this._renderContent();
|
||||
} else {
|
||||
content = (
|
||||
<div>
|
||||
<div id='mx_Dialog_content'>
|
||||
<p>{ _t('Loading device info...') }</p>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
@ -165,6 +165,7 @@ export default React.createClass({
|
|||
<BaseDialog className='mx_KeyShareRequestDialog'
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t('Encryption key request')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
{ content }
|
||||
</BaseDialog>
|
||||
|
|
|
@ -60,13 +60,14 @@ export default React.createClass({
|
|||
}
|
||||
return (
|
||||
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
{ this.props.description }
|
||||
</div>
|
||||
<DialogButtons primaryButton={this.props.button || _t('OK')}
|
||||
cancelButton={this.props.cancelButton}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
primaryButtonClass={primaryButtonClass}
|
||||
focus={this.props.focus}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -35,44 +36,74 @@ export default React.createClass({
|
|||
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
|
||||
},
|
||||
|
||||
_continueClicked: function() {
|
||||
this.props.onFinished(true);
|
||||
_onClearStorageClick: function() {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Session Restore Confirm Logout', '', QuestionDialog, {
|
||||
title: _t("Sign out"),
|
||||
description:
|
||||
<div>{ _t("Log out and remove encryption keys?") }</div>,
|
||||
button: _t("Sign out"),
|
||||
danger: true,
|
||||
onFinished: this.props.onFinished,
|
||||
});
|
||||
},
|
||||
|
||||
_onRefreshClick: function() {
|
||||
// Is this likely to help? Probably not, but giving only one button
|
||||
// that clears your storage seems awful.
|
||||
window.location.reload(true);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
let bugreport;
|
||||
|
||||
const clearStorageButton = (
|
||||
<button onClick={this._onClearStorageClick} className="danger">
|
||||
{ _t("Clear Storage and Sign Out") }
|
||||
</button>
|
||||
);
|
||||
|
||||
let dialogButtons;
|
||||
if (SdkConfig.get().bug_report_endpoint_url) {
|
||||
bugreport = (
|
||||
<p>
|
||||
{ _t(
|
||||
"Otherwise, <a>click here</a> to send a bug report.",
|
||||
{},
|
||||
{ 'a': (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a> },
|
||||
) }
|
||||
</p>
|
||||
);
|
||||
dialogButtons = <DialogButtons primaryButton={_t("Send Logs")}
|
||||
onPrimaryButtonClick={this._sendBugReport}
|
||||
focus={true}
|
||||
hasCancel={false}
|
||||
>
|
||||
{ clearStorageButton }
|
||||
</DialogButtons>;
|
||||
} else {
|
||||
dialogButtons = <DialogButtons primaryButton={_t("Refresh")}
|
||||
onPrimaryButtonClick={this._onRefreshClick}
|
||||
focus={true}
|
||||
hasCancel={false}
|
||||
>
|
||||
{ clearStorageButton }
|
||||
</DialogButtons>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
|
||||
title={_t('Unable to restore session')}>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>{ _t("We encountered an error trying to restore your previous session. If " +
|
||||
"you continue, you will need to log in again, and encrypted chat " +
|
||||
"history will be unreadable.") }</p>
|
||||
title={_t('Unable to restore session')}
|
||||
contentId='mx_Dialog_content'
|
||||
hasCancel={false}
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>{ _t("We encountered an error trying to restore your previous session.") }</p>
|
||||
|
||||
<p>{ _t("If you have previously used a more recent version of Riot, your session " +
|
||||
"may be incompatible with this version. Close this window and return " +
|
||||
"to the more recent version.") }</p>
|
||||
<p>{ _t(
|
||||
"If you have previously used a more recent version of Riot, your session " +
|
||||
"may be incompatible with this version. Close this window and return " +
|
||||
"to the more recent version.",
|
||||
) }</p>
|
||||
|
||||
{ bugreport }
|
||||
<p>{ _t(
|
||||
"Clearing your browser's storage may fix the problem, but will sign you " +
|
||||
"out and cause any encrypted chat history to become unreadable.",
|
||||
) }</p>
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t("Continue anyway")}
|
||||
onPrimaryButtonClick={this._continueClicked}
|
||||
onCancel={this.props.onFinished} />
|
||||
{ dialogButtons }
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -41,9 +41,6 @@ export default React.createClass({
|
|||
};
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
},
|
||||
|
||||
onEmailAddressChanged: function(value) {
|
||||
this.setState({
|
||||
emailAddress: value,
|
||||
|
@ -131,6 +128,7 @@ export default React.createClass({
|
|||
|
||||
const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText
|
||||
className="mx_SetEmailDialog_email_input"
|
||||
autoFocus="true"
|
||||
placeholder={_t("Email address")}
|
||||
placeholderClassName="mx_SetEmailDialog_email_input_placeholder"
|
||||
blurToCancel={false}
|
||||
|
@ -140,9 +138,10 @@ export default React.createClass({
|
|||
<BaseDialog className="mx_SetEmailDialog"
|
||||
onFinished={this.onCancelled}
|
||||
title={this.props.title}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>
|
||||
<p id='mx_Dialog_content'>
|
||||
{ _t('This will allow you to reset your password and receive notifications.') }
|
||||
</p>
|
||||
{ emailInput }
|
||||
|
|
|
@ -235,14 +235,14 @@ export default React.createClass({
|
|||
"error": Boolean(this.state.usernameError),
|
||||
"success": usernameAvailable,
|
||||
});
|
||||
usernameIndicator = <div className={usernameIndicatorClasses}>
|
||||
usernameIndicator = <div className={usernameIndicatorClasses} role="alert">
|
||||
{ usernameAvailable ? _t('Username available') : this.state.usernameError }
|
||||
</div>;
|
||||
}
|
||||
|
||||
let authErrorIndicator = null;
|
||||
if (this.state.authError) {
|
||||
authErrorIndicator = <div className="error">
|
||||
authErrorIndicator = <div className="error" role="alert">
|
||||
{ this.state.authError }
|
||||
</div>;
|
||||
}
|
||||
|
@ -254,8 +254,9 @@ export default React.createClass({
|
|||
<BaseDialog className="mx_SetMxIdDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t('To get started, please pick a username!')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<div className="mx_SetMxIdDialog_input_group">
|
||||
<input type="text" ref="input_value" value={this.state.username}
|
||||
autoFocus={true}
|
||||
|
|
137
src/components/views/dialogs/SetPasswordDialog.js
Normal file
137
src/components/views/dialogs/SetPasswordDialog.js
Normal file
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
const WarmFuzzy = function(props) {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
let title = _t('You have successfully set a password!');
|
||||
if (props.didSetEmail) {
|
||||
title = _t('You have successfully set a password and an email address!');
|
||||
}
|
||||
const advice = _t('You can now return to your account after signing out, and sign in on other devices.');
|
||||
let extraAdvice = null;
|
||||
if (!props.didSetEmail) {
|
||||
extraAdvice = _t('Remember, you can always set an email address in user settings if you change your mind.');
|
||||
}
|
||||
|
||||
return <BaseDialog className="mx_SetPasswordDialog"
|
||||
onFinished={props.onFinished}
|
||||
title={ title }
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>
|
||||
{ advice }
|
||||
</p>
|
||||
<p>
|
||||
{ extraAdvice }
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button
|
||||
className="mx_Dialog_primary"
|
||||
autoFocus={true}
|
||||
onClick={props.onFinished}>
|
||||
{ _t('Continue') }
|
||||
</button>
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt the user to set a password
|
||||
*
|
||||
* On success, `onFinished()` when finished
|
||||
*/
|
||||
export default React.createClass({
|
||||
displayName: 'SetPasswordDialog',
|
||||
propTypes: {
|
||||
onFinished: React.PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
console.info('SetPasswordDialog component will mount');
|
||||
},
|
||||
|
||||
_onPasswordChanged: function(res) {
|
||||
Modal.createDialog(WarmFuzzy, {
|
||||
didSetEmail: res.didSetEmail,
|
||||
onFinished: () => {
|
||||
this._onContinueClicked();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
_onContinueClicked: function() {
|
||||
this.props.onFinished(true);
|
||||
},
|
||||
|
||||
_onPasswordChangeError: function(err) {
|
||||
let errMsg = err.error || "";
|
||||
if (err.httpStatus === 403) {
|
||||
errMsg = _t('Failed to change password. Is your password correct?');
|
||||
} else if (err.httpStatus) {
|
||||
errMsg += ' ' + _t(
|
||||
'(HTTP status %(httpStatus)s)',
|
||||
{ httpStatus: err.httpStatus },
|
||||
);
|
||||
}
|
||||
this.setState({
|
||||
error: errMsg,
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const ChangePassword = sdk.getComponent('views.settings.ChangePassword');
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_SetPasswordDialog"
|
||||
onFinished={this.props.onFinished}
|
||||
title={ _t('Please set a password!') }
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<p>
|
||||
{ _t('This will allow you to return to your account after signing out, and sign in on other devices.') }
|
||||
</p>
|
||||
<ChangePassword
|
||||
className="mx_SetPasswordDialog_change_password"
|
||||
rowClassName=""
|
||||
rowLabelClassName=""
|
||||
rowInputClassName=""
|
||||
buttonClassName="mx_Dialog_primary mx_SetPasswordDialog_change_password_button"
|
||||
confirm={false}
|
||||
autoFocusNewPasswordInput={true}
|
||||
shouldAskForEmail={true}
|
||||
onError={this._onPasswordChangeError}
|
||||
onFinished={this._onPasswordChanged} />
|
||||
<div className="error">
|
||||
{ this.state.error }
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -61,17 +61,18 @@ export default React.createClass({
|
|||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
return (
|
||||
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
|
||||
onEnterPressed={this.onOk}
|
||||
title={this.props.title}
|
||||
>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_TextInputDialog_label">
|
||||
<label htmlFor="textinput"> { this.props.description } </label>
|
||||
<form onSubmit={this.onOk}>
|
||||
<div className="mx_Dialog_content">
|
||||
<div className="mx_TextInputDialog_label">
|
||||
<label htmlFor="textinput"> { this.props.description } </label>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DialogButtons primaryButton={this.props.button}
|
||||
onPrimaryButtonClick={this.onOk}
|
||||
onCancel={this.onCancel} />
|
||||
|
|
|
@ -146,6 +146,7 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
render: function() {
|
||||
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
|
||||
if (this.props.devices === null) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
return <Spinner />;
|
||||
|
@ -189,8 +190,9 @@ export default React.createClass({
|
|||
<BaseDialog className='mx_UnknownDeviceDialog'
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t('Room contains unknown devices')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<GeminiScrollbar autoshow={false} className="mx_Dialog_content">
|
||||
<GeminiScrollbarWrapper autoshow={false} className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<h4>
|
||||
{ _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) }
|
||||
</h4>
|
||||
|
@ -198,7 +200,7 @@ export default React.createClass({
|
|||
{ _t("Unknown devices") }:
|
||||
|
||||
<UnknownDeviceList devices={this.props.devices} />
|
||||
</GeminiScrollbar>
|
||||
</GeminiScrollbarWrapper>
|
||||
<DialogButtons primaryButton={sendButtonLabel}
|
||||
onPrimaryButtonClick={sendButtonOnClick}
|
||||
onCancel={this._onDismissClicked} />
|
||||
|
|
254
src/components/views/directory/NetworkDropdown.js
Normal file
254
src/components/views/directory/NetworkDropdown.js
Normal file
|
@ -0,0 +1,254 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import {instanceForInstanceId} from '../../../utils/DirectoryUtils';
|
||||
|
||||
const DEFAULT_ICON_URL = "img/network-matrix.svg";
|
||||
|
||||
export default class NetworkDropdown extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.dropdownRootElement = null;
|
||||
this.ignoreEvent = null;
|
||||
|
||||
this.onInputClick = this.onInputClick.bind(this);
|
||||
this.onRootClick = this.onRootClick.bind(this);
|
||||
this.onDocumentClick = this.onDocumentClick.bind(this);
|
||||
this.onMenuOptionClick = this.onMenuOptionClick.bind(this);
|
||||
this.onInputKeyUp = this.onInputKeyUp.bind(this);
|
||||
this.collectRoot = this.collectRoot.bind(this);
|
||||
this.collectInputTextBox = this.collectInputTextBox.bind(this);
|
||||
|
||||
this.inputTextBox = null;
|
||||
|
||||
const server = MatrixClientPeg.getHomeServerName();
|
||||
this.state = {
|
||||
expanded: false,
|
||||
selectedServer: server,
|
||||
selectedInstance: null,
|
||||
includeAllNetworks: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
// Listen for all clicks on the document so we can close the
|
||||
// menu when the user clicks somewhere else
|
||||
document.addEventListener('click', this.onDocumentClick, false);
|
||||
|
||||
// fire this now so the defaults can be set up
|
||||
this.props.onOptionChange(this.state.selectedServer, this.state.selectedInstance, this.state.includeAllNetworks);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this.onDocumentClick, false);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.state.expanded && this.inputTextBox) {
|
||||
this.inputTextBox.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onDocumentClick(ev) {
|
||||
// Close the dropdown if the user clicks anywhere that isn't
|
||||
// within our root element
|
||||
if (ev !== this.ignoreEvent) {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onRootClick(ev) {
|
||||
// This captures any clicks that happen within our elements,
|
||||
// such that we can then ignore them when they're seen by the
|
||||
// click listener on the document handler, ie. not close the
|
||||
// dropdown immediately after opening it.
|
||||
// NB. We can't just stopPropagation() because then the event
|
||||
// doesn't reach the React onClick().
|
||||
this.ignoreEvent = ev;
|
||||
}
|
||||
|
||||
onInputClick(ev) {
|
||||
this.setState({
|
||||
expanded: !this.state.expanded,
|
||||
});
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
onMenuOptionClick(server, instance, includeAll) {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
selectedServer: server,
|
||||
selectedInstanceId: instance ? instance.instance_id : null,
|
||||
includeAll: includeAll,
|
||||
});
|
||||
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
|
||||
}
|
||||
|
||||
onInputKeyUp(e) {
|
||||
if (e.key == 'Enter') {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
selectedServer: e.target.value,
|
||||
selectedNetwork: null,
|
||||
});
|
||||
this.props.onOptionChange(e.target.value, null);
|
||||
}
|
||||
}
|
||||
|
||||
collectRoot(e) {
|
||||
if (this.dropdownRootElement) {
|
||||
this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
|
||||
}
|
||||
if (e) {
|
||||
e.addEventListener('click', this.onRootClick, false);
|
||||
}
|
||||
this.dropdownRootElement = e;
|
||||
}
|
||||
|
||||
collectInputTextBox(e) {
|
||||
this.inputTextBox = e;
|
||||
}
|
||||
|
||||
_getMenuOptions() {
|
||||
const options = [];
|
||||
|
||||
let servers = [];
|
||||
if (this.props.config.servers) {
|
||||
servers = servers.concat(this.props.config.servers);
|
||||
}
|
||||
|
||||
if (servers.indexOf(MatrixClientPeg.getHomeServerName()) == -1) {
|
||||
servers.unshift(MatrixClientPeg.getHomeServerName());
|
||||
}
|
||||
|
||||
// For our own HS, we can use the instance_ids given in the third party protocols
|
||||
// response to get the server to filter the room list by network for us.
|
||||
// We can't get thirdparty protocols for remote server yet though, so for those
|
||||
// we can only show the default room list.
|
||||
for (const server of servers) {
|
||||
options.push(this._makeMenuOption(server, null, true));
|
||||
if (server == MatrixClientPeg.getHomeServerName()) {
|
||||
options.push(this._makeMenuOption(server, null, false));
|
||||
if (this.props.protocols) {
|
||||
for (const proto of Object.keys(this.props.protocols)) {
|
||||
if (!this.props.protocols[proto].instances) continue;
|
||||
|
||||
const sortedInstances = this.props.protocols[proto].instances;
|
||||
sortedInstances.sort(function(x, y) {
|
||||
const a = x.desc
|
||||
const b = y.desc
|
||||
if (a < b) {
|
||||
return -1;
|
||||
} else if (a > b) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
for (const instance of sortedInstances) {
|
||||
if (!instance.instance_id) continue;
|
||||
options.push(this._makeMenuOption(server, instance, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
_makeMenuOption(server, instance, includeAll, handleClicks) {
|
||||
if (handleClicks === undefined) handleClicks = true;
|
||||
|
||||
let icon;
|
||||
let name;
|
||||
let span_class;
|
||||
let key;
|
||||
|
||||
if (!instance && includeAll) {
|
||||
key = server;
|
||||
name = server;
|
||||
span_class = 'mx_NetworkDropdown_menu_all';
|
||||
} else if (!instance) {
|
||||
key = server + '_all';
|
||||
name = 'Matrix';
|
||||
icon = <img src="img/network-matrix.svg" />;
|
||||
span_class = 'mx_NetworkDropdown_menu_network';
|
||||
} else {
|
||||
key = server + '_inst_' + instance.instance_id;
|
||||
const imgUrl = instance.icon ?
|
||||
MatrixClientPeg.get().mxcUrlToHttp(instance.icon, 25, 25, 'crop', true) :
|
||||
DEFAULT_ICON_URL;
|
||||
icon = <img src={imgUrl} />;
|
||||
name = instance.desc;
|
||||
span_class = 'mx_NetworkDropdown_menu_network';
|
||||
}
|
||||
|
||||
const click_handler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
|
||||
|
||||
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={click_handler}>
|
||||
{icon}
|
||||
<span className="mx_NetworkDropdown_menu_network">{name}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
render() {
|
||||
let current_value;
|
||||
|
||||
let menu;
|
||||
if (this.state.expanded) {
|
||||
const menu_options = this._getMenuOptions();
|
||||
menu = <div className="mx_NetworkDropdown_menu">
|
||||
{menu_options}
|
||||
</div>;
|
||||
current_value = <input type="text" className="mx_NetworkDropdown_networkoption"
|
||||
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp}
|
||||
placeholder="matrix.org" // 'matrix.org' as an example of an HS name
|
||||
/>
|
||||
} else {
|
||||
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
|
||||
current_value = this._makeMenuOption(
|
||||
this.state.selectedServer, instance, this.state.includeAll, false
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="mx_NetworkDropdown" ref={this.collectRoot}>
|
||||
<div className="mx_NetworkDropdown_input" onClick={this.onInputClick}>
|
||||
{current_value}
|
||||
<span className="mx_NetworkDropdown_arrow"></span>
|
||||
{menu}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
NetworkDropdown.propTypes = {
|
||||
onOptionChange: React.PropTypes.func.isRequired,
|
||||
protocols: React.PropTypes.object,
|
||||
// The room directory config. May have a 'servers' key that is a list of server names to include in the dropdown
|
||||
config: React.PropTypes.object,
|
||||
};
|
||||
|
||||
NetworkDropdown.defaultProps = {
|
||||
protocols: {},
|
||||
config: {},
|
||||
};
|
|
@ -17,6 +17,8 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { KeyCode } from '../../../Keyboard';
|
||||
|
||||
/**
|
||||
* AccessibleButton is a generic wrapper for any element that should be treated
|
||||
* as a button. Identifies the element as a button, setting proper tab
|
||||
|
@ -28,8 +30,34 @@ import PropTypes from 'prop-types';
|
|||
export default function AccessibleButton(props) {
|
||||
const {element, onClick, children, ...restProps} = props;
|
||||
restProps.onClick = onClick;
|
||||
// We need to consume enter onKeyDown and space onKeyUp
|
||||
// otherwise we are risking also activating other keyboard focusable elements
|
||||
// that might receive focus as a result of the AccessibleButtonClick action
|
||||
// It's because we are using html buttons at a few places e.g. inside dialogs
|
||||
// And divs which we report as role button to assistive technologies.
|
||||
// Browsers handle space and enter keypresses differently and we are only adjusting to the
|
||||
// inconsistencies here
|
||||
restProps.onKeyDown = function(e) {
|
||||
if (e.keyCode === KeyCode.ENTER) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return onClick(e);
|
||||
}
|
||||
if (e.keyCode === KeyCode.SPACE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
restProps.onKeyUp = function(e) {
|
||||
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
|
||||
if (e.keyCode === KeyCode.SPACE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return onClick(e);
|
||||
}
|
||||
if (e.keyCode === KeyCode.ENTER) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
restProps.tabIndex = restProps.tabIndex || "0";
|
||||
restProps.role = "button";
|
||||
|
|
|
@ -52,6 +52,10 @@ export default class AppPermission extends React.Component {
|
|||
e2eWarningText =
|
||||
<span className='mx_AppPermissionWarningTextLabel'>{ _t('NOTE: Apps are not end-to-end encrypted') }</span>;
|
||||
}
|
||||
const cookieWarning =
|
||||
<span className='mx_AppPermissionWarningTextLabel'>
|
||||
{ _t('Warning: This widget might use cookies.') }
|
||||
</span>;
|
||||
return (
|
||||
<div className='mx_AppPermissionWarning'>
|
||||
<div className='mx_AppPermissionWarningImage'>
|
||||
|
@ -60,6 +64,7 @@ export default class AppPermission extends React.Component {
|
|||
<div className='mx_AppPermissionWarningText'>
|
||||
<span className='mx_AppPermissionWarningTextLabel'>{ _t('Do you want to load widget from URL:') }</span> <span className='mx_AppPermissionWarningTextURL'>{ this.state.curlBase }</span>
|
||||
{ e2eWarningText }
|
||||
{ cookieWarning }
|
||||
</div>
|
||||
<input
|
||||
className='mx_AppPermissionButton'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/*
|
||||
/**
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -36,32 +36,26 @@ import WidgetUtils from '../../../WidgetUtils';
|
|||
import dis from '../../../dispatcher';
|
||||
|
||||
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
|
||||
const ENABLE_REACT_PERF = false;
|
||||
|
||||
export default React.createClass({
|
||||
displayName: 'AppTile',
|
||||
export default class AppTile extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = this._getNewState(props);
|
||||
|
||||
propTypes: {
|
||||
id: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
room: PropTypes.object.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
|
||||
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
|
||||
fullWidth: PropTypes.bool,
|
||||
// UserId of the current user
|
||||
userId: PropTypes.string.isRequired,
|
||||
// UserId of the entity that added / modified the widget
|
||||
creatorUserId: PropTypes.string,
|
||||
waitForIframeLoad: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps() {
|
||||
return {
|
||||
url: "",
|
||||
waitForIframeLoad: true,
|
||||
};
|
||||
},
|
||||
this._onWidgetAction = this._onWidgetAction.bind(this);
|
||||
this._onMessage = this._onMessage.bind(this);
|
||||
this._onLoaded = this._onLoaded.bind(this);
|
||||
this._onEditClick = this._onEditClick.bind(this);
|
||||
this._onDeleteClick = this._onDeleteClick.bind(this);
|
||||
this._onSnapshotClick = this._onSnapshotClick.bind(this);
|
||||
this.onClickMenuBar = this.onClickMenuBar.bind(this);
|
||||
this._onMinimiseClick = this._onMinimiseClick.bind(this);
|
||||
this._onInitialLoad = this._onInitialLoad.bind(this);
|
||||
this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
|
||||
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
|
||||
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set initial component state when the App wUrl (widget URL) is being updated.
|
||||
|
@ -73,8 +67,8 @@ export default React.createClass({
|
|||
const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_');
|
||||
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
|
||||
return {
|
||||
initialising: true, // True while we are mangling the widget URL
|
||||
loading: this.props.waitForIframeLoad, // True while the iframe content is loading
|
||||
initialising: true, // True while we are mangling the widget URL
|
||||
loading: this.props.waitForIframeLoad, // True while the iframe content is loading
|
||||
widgetUrl: this._addWurlParams(newProps.url),
|
||||
widgetPermissionId: widgetPermissionId,
|
||||
// Assume that widget has permission to load if we are the user who
|
||||
|
@ -83,8 +77,20 @@ export default React.createClass({
|
|||
error: null,
|
||||
deleting: false,
|
||||
widgetPageTitle: newProps.widgetPageTitle,
|
||||
allowedCapabilities: (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) ?
|
||||
this.props.whitelistCapabilities : [],
|
||||
requestedCapabilities: [],
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the widget support a given capability
|
||||
* @param {string} capability Capability to check for
|
||||
* @return {Boolean} True if capability supported
|
||||
*/
|
||||
_hasCapability(capability) {
|
||||
return this.state.allowedCapabilities.some((c) => {return c === capability;});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add widget instance specific parameters to pass in wUrl
|
||||
|
@ -112,11 +118,7 @@ export default React.createClass({
|
|||
u.query = params;
|
||||
|
||||
return u.format();
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return this._getNewState(this.props);
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api
|
||||
|
@ -140,7 +142,7 @@ export default React.createClass({
|
|||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}
|
||||
|
||||
isMixedContent() {
|
||||
const parentContentProtocol = window.location.protocol;
|
||||
|
@ -152,14 +154,43 @@ export default React.createClass({
|
|||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
WidgetMessaging.startListening();
|
||||
WidgetMessaging.addEndpoint(this.props.id, this.props.url);
|
||||
window.addEventListener('message', this._onMessage, false);
|
||||
this.setScalarToken();
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Legacy Jitsi widget messaging -- TODO replace this with standard widget
|
||||
// postMessaging API
|
||||
window.addEventListener('message', this._onMessage, false);
|
||||
|
||||
// Widget action listeners
|
||||
this.dispatcherRef = dis.register(this._onWidgetAction);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
// Allow parents to access widget messaging
|
||||
if (this.props.collectWidgetMessaging) {
|
||||
this.props.collectWidgetMessaging(this.widgetMessaging);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Widget action listeners
|
||||
dis.unregister(this.dispatcherRef);
|
||||
|
||||
// Widget postMessage listeners
|
||||
try {
|
||||
if (this.widgetMessaging) {
|
||||
this.widgetMessaging.stop();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to stop listening for widgetMessaging events', e.message);
|
||||
}
|
||||
// Jitsi listener
|
||||
window.removeEventListener('message', this._onMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a scalar token to the widget URL, if required
|
||||
|
@ -211,13 +242,7 @@ export default React.createClass({
|
|||
initialising: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
WidgetMessaging.stopListening();
|
||||
WidgetMessaging.removeEndpoint(this.props.id, this.props.url);
|
||||
window.removeEventListener('message', this._onMessage);
|
||||
},
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.url !== this.props.url) {
|
||||
|
@ -232,8 +257,10 @@ export default React.createClass({
|
|||
widgetPageTitle: nextProps.widgetPageTitle,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Legacy Jitsi widget messaging
|
||||
// TODO -- This should be replaced with the new widget postMessaging API
|
||||
_onMessage(event) {
|
||||
if (this.props.type !== 'jitsi') {
|
||||
return;
|
||||
|
@ -251,63 +278,145 @@ export default React.createClass({
|
|||
.document.querySelector('iframe[id^="jitsiConferenceFrame"]');
|
||||
PlatformPeg.get().setupScreenSharingForIframe(iframe);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
_canUserModify() {
|
||||
// User widgets should always be modifiable by their creator
|
||||
if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) {
|
||||
return true;
|
||||
}
|
||||
// Check if the current user can modify widgets in the current room
|
||||
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
|
||||
},
|
||||
}
|
||||
|
||||
_onEditClick(e) {
|
||||
console.log("Edit widget ID ", this.props.id);
|
||||
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
|
||||
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
|
||||
this.props.room.roomId, 'type_' + this.props.type, this.props.id);
|
||||
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
|
||||
src: src,
|
||||
}, "mx_IntegrationsManager");
|
||||
},
|
||||
if (this.props.onEditClick) {
|
||||
this.props.onEditClick();
|
||||
} else {
|
||||
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
|
||||
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
|
||||
this.props.room, 'type_' + this.props.type, this.props.id);
|
||||
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
|
||||
src: src,
|
||||
}, "mx_IntegrationsManager");
|
||||
}
|
||||
}
|
||||
|
||||
_onSnapshotClick(e) {
|
||||
console.warn("Requesting widget snapshot");
|
||||
this.widgetMessaging.getScreenshot()
|
||||
.catch((err) => {
|
||||
console.error("Failed to get screenshot", err);
|
||||
})
|
||||
.then((screenshot) => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: screenshot,
|
||||
}, true);
|
||||
});
|
||||
}
|
||||
|
||||
/* If user has permission to modify widgets, delete the widget,
|
||||
* otherwise revoke access for the widget to load in the user's browser
|
||||
*/
|
||||
_onDeleteClick() {
|
||||
if (this._canUserModify()) {
|
||||
// Show delete confirmation dialog
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
|
||||
title: _t("Delete Widget"),
|
||||
description: _t(
|
||||
"Deleting a widget removes it for all users in this room." +
|
||||
" Are you sure you want to delete this widget?"),
|
||||
button: _t("Delete widget"),
|
||||
onFinished: (confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.setState({deleting: true});
|
||||
MatrixClientPeg.get().sendStateEvent(
|
||||
this.props.room.roomId,
|
||||
'im.vector.modular.widgets',
|
||||
{}, // empty content
|
||||
this.props.id,
|
||||
).catch((e) => {
|
||||
console.error('Failed to delete widget', e);
|
||||
this.setState({deleting: false});
|
||||
});
|
||||
},
|
||||
});
|
||||
if (this.props.onDeleteClick) {
|
||||
this.props.onDeleteClick();
|
||||
} else {
|
||||
console.log("Revoke widget permissions - %s", this.props.id);
|
||||
this._revokeWidgetPermission();
|
||||
if (this._canUserModify()) {
|
||||
// Show delete confirmation dialog
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
|
||||
title: _t("Delete Widget"),
|
||||
description: _t(
|
||||
"Deleting a widget removes it for all users in this room." +
|
||||
" Are you sure you want to delete this widget?"),
|
||||
button: _t("Delete widget"),
|
||||
onFinished: (confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.setState({deleting: true});
|
||||
MatrixClientPeg.get().sendStateEvent(
|
||||
this.props.room.roomId,
|
||||
'im.vector.modular.widgets',
|
||||
{}, // empty content
|
||||
this.props.id,
|
||||
).catch((e) => {
|
||||
console.error('Failed to delete widget', e);
|
||||
}).finally(() => {
|
||||
this.setState({deleting: false});
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.log("Revoke widget permissions - %s", this.props.id);
|
||||
this._revokeWidgetPermission();
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when widget iframe has finished loading
|
||||
*/
|
||||
_onLoaded() {
|
||||
if (!this.widgetMessaging) {
|
||||
this._onInitialLoad();
|
||||
}
|
||||
this.setState({loading: false});
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on initial load of the widget iframe
|
||||
*/
|
||||
_onInitialLoad() {
|
||||
this.widgetMessaging = new WidgetMessaging(this.props.id, this.props.url, this.refs.appFrame.contentWindow);
|
||||
this.widgetMessaging.getCapabilities().then((requestedCapabilities) => {
|
||||
console.log(`Widget ${this.props.id} requested capabilities:`, requestedCapabilities);
|
||||
requestedCapabilities = requestedCapabilities || [];
|
||||
|
||||
// Allow whitelisted capabilities
|
||||
let requestedWhitelistCapabilies = [];
|
||||
|
||||
if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) {
|
||||
requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) {
|
||||
return this.indexOf(e)>=0;
|
||||
}, this.props.whitelistCapabilities);
|
||||
|
||||
if (requestedWhitelistCapabilies.length > 0 ) {
|
||||
console.warn(`Widget ${this.props.id} allowing requested, whitelisted properties:`,
|
||||
requestedWhitelistCapabilies);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO -- Add UI to warn about and optionally allow requested capabilities
|
||||
this.setState({
|
||||
requestedCapabilities,
|
||||
allowedCapabilities: this.state.allowedCapabilities.concat(requestedWhitelistCapabilies),
|
||||
});
|
||||
|
||||
if (this.props.onCapabilityRequest) {
|
||||
this.props.onCapabilityRequest(requestedCapabilities);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err);
|
||||
});
|
||||
}
|
||||
|
||||
_onWidgetAction(payload) {
|
||||
if (payload.widgetId === this.props.id) {
|
||||
switch (payload.action) {
|
||||
case 'm.sticker':
|
||||
if (this._hasCapability('m.sticker')) {
|
||||
dis.dispatch({action: 'post_sticker_message', data: payload.data});
|
||||
} else {
|
||||
console.warn('Ignoring sticker message. Invalid capability');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set remote content title on AppTile
|
||||
|
@ -321,7 +430,7 @@ export default React.createClass({
|
|||
}, (err) =>{
|
||||
console.error("Failed to get page title", err);
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
// Widget labels to render, depending upon user permissions
|
||||
// These strings are translated at the point that they are inserted in to the DOM, in the render method
|
||||
|
@ -330,20 +439,20 @@ export default React.createClass({
|
|||
return _td('Delete widget');
|
||||
}
|
||||
return _td('Revoke widget access');
|
||||
},
|
||||
}
|
||||
|
||||
/* TODO -- Store permission in account data so that it is persisted across multiple devices */
|
||||
_grantWidgetPermission() {
|
||||
console.warn('Granting permission to load widget - ', this.state.widgetUrl);
|
||||
localStorage.setItem(this.state.widgetPermissionId, true);
|
||||
this.setState({hasPermissionToLoad: true});
|
||||
},
|
||||
}
|
||||
|
||||
_revokeWidgetPermission() {
|
||||
console.warn('Revoking permission to load widget - ', this.state.widgetUrl);
|
||||
localStorage.removeItem(this.state.widgetPermissionId);
|
||||
this.setState({hasPermissionToLoad: false});
|
||||
},
|
||||
}
|
||||
|
||||
formatAppTileName() {
|
||||
let appTileName = "No name";
|
||||
|
@ -351,7 +460,7 @@ export default React.createClass({
|
|||
appTileName = this.props.name.trim();
|
||||
}
|
||||
return appTileName;
|
||||
},
|
||||
}
|
||||
|
||||
onClickMenuBar(ev) {
|
||||
ev.preventDefault();
|
||||
|
@ -366,16 +475,49 @@ export default React.createClass({
|
|||
action: 'appsDrawer',
|
||||
show: !this.props.show,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
_getSafeUrl() {
|
||||
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
|
||||
const parsedWidgetUrl = url.parse(this.state.widgetUrl, true);
|
||||
if (ENABLE_REACT_PERF) {
|
||||
parsedWidgetUrl.search = null;
|
||||
parsedWidgetUrl.query.react_perf = true;
|
||||
}
|
||||
let safeWidgetUrl = '';
|
||||
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
|
||||
safeWidgetUrl = url.format(parsedWidgetUrl);
|
||||
}
|
||||
return safeWidgetUrl;
|
||||
},
|
||||
}
|
||||
|
||||
_getTileTitle() {
|
||||
const name = this.formatAppTileName();
|
||||
const titleSpacer = <span> - </span>;
|
||||
let title = '';
|
||||
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
|
||||
title = this.state.widgetPageTitle;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<b>{ name }</b>
|
||||
<span>{ title ? titleSpacer : '' }{ title }</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
_onMinimiseClick(e) {
|
||||
if (this.props.onMinimiseClick) {
|
||||
this.props.onMinimiseClick();
|
||||
}
|
||||
}
|
||||
|
||||
_onPopoutWidgetClick(e) {
|
||||
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
||||
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes,noreferrer=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener noreferrer'}).click();
|
||||
}
|
||||
|
||||
render() {
|
||||
let appTileBody;
|
||||
|
@ -393,14 +535,22 @@ export default React.createClass({
|
|||
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
|
||||
"allow-same-origin allow-scripts allow-presentation";
|
||||
|
||||
// Additional iframe feature pemissions
|
||||
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
|
||||
const iframeFeatures = "microphone; camera; encrypted-media;";
|
||||
|
||||
if (this.props.show) {
|
||||
const loadingElement = (
|
||||
<div className='mx_AppTileBody mx_AppLoading'>
|
||||
<div className="mx_AppLoading_spinner_fadeIn">
|
||||
<MessageSpinner msg='Loading...' />
|
||||
</div>
|
||||
);
|
||||
if (this.state.initialising) {
|
||||
appTileBody = loadingElement;
|
||||
appTileBody = (
|
||||
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ loadingElement }
|
||||
</div>
|
||||
);
|
||||
} else if (this.state.hasPermissionToLoad == true) {
|
||||
if (this.isMixedContent()) {
|
||||
appTileBody = (
|
||||
|
@ -410,9 +560,15 @@ export default React.createClass({
|
|||
);
|
||||
} else {
|
||||
appTileBody = (
|
||||
<div className={this.state.loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody'}>
|
||||
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
|
||||
{ this.state.loading && loadingElement }
|
||||
{ /*
|
||||
The "is" attribute in the following iframe tag is needed in order to enable rendering of the
|
||||
"allow" attribute, which is unknown to react 15.
|
||||
*/ }
|
||||
<iframe
|
||||
is
|
||||
allow={iframeFeatures}
|
||||
ref="appFrame"
|
||||
src={this._getSafeUrl()}
|
||||
allowFullScreen="true"
|
||||
|
@ -446,29 +602,53 @@ export default React.createClass({
|
|||
deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
|
||||
}
|
||||
|
||||
// Picture snapshot - only show button when apps are maximised.
|
||||
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
|
||||
const showPictureSnapshotIcon = 'img/camera_green.svg';
|
||||
const popoutWidgetIcon = 'img/button-new-window.svg';
|
||||
const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg');
|
||||
|
||||
return (
|
||||
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
|
||||
{ this.props.showMenubar &&
|
||||
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
|
||||
<span className="mx_AppTileMenuBarTitle">
|
||||
<TintableSvgButton
|
||||
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
|
||||
{ this.props.showMinimise && <TintableSvgButton
|
||||
src={windowStateIcon}
|
||||
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
|
||||
title={_t('Minimize apps')}
|
||||
width="10"
|
||||
height="10"
|
||||
/>
|
||||
<b>{ this.formatAppTileName() }</b>
|
||||
{ this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && (
|
||||
<span> - { this.state.widgetPageTitle }</span>
|
||||
) }
|
||||
onClick={this._onMinimiseClick}
|
||||
/> }
|
||||
{ this.props.showTitle && this._getTileTitle() }
|
||||
</span>
|
||||
<span className="mx_AppTileMenuBarWidgets">
|
||||
{ /* Popout widget */ }
|
||||
{ this.props.showPopout && <TintableSvgButton
|
||||
src={popoutWidgetIcon}
|
||||
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
|
||||
title={_t('Popout widget')}
|
||||
onClick={this._onPopoutWidgetClick}
|
||||
width="10"
|
||||
height="10"
|
||||
/> }
|
||||
|
||||
{ /* Snapshot widget */ }
|
||||
{ showPictureSnapshotButton && <TintableSvgButton
|
||||
src={showPictureSnapshotIcon}
|
||||
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
|
||||
title={_t('Picture')}
|
||||
onClick={this._onSnapshotClick}
|
||||
width="10"
|
||||
height="10"
|
||||
/> }
|
||||
|
||||
{ /* Edit widget */ }
|
||||
{ showEditButton && <TintableSvgButton
|
||||
src="img/edit_green.svg"
|
||||
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
|
||||
className={"mx_AppTileMenuBarWidget " +
|
||||
(this.props.showDelete ? "mx_AppTileMenuBarWidgetPadding" : "")}
|
||||
title={_t('Edit')}
|
||||
onClick={this._onEditClick}
|
||||
width="10"
|
||||
|
@ -476,18 +656,77 @@ export default React.createClass({
|
|||
/> }
|
||||
|
||||
{ /* Delete widget */ }
|
||||
<TintableSvgButton
|
||||
{ this.props.showDelete && <TintableSvgButton
|
||||
src={deleteIcon}
|
||||
className={deleteClasses}
|
||||
title={_t(deleteWidgetLabel)}
|
||||
onClick={this._onDeleteClick}
|
||||
width="10"
|
||||
height="10"
|
||||
/>
|
||||
/> }
|
||||
</span>
|
||||
</div>
|
||||
</div> }
|
||||
{ appTileBody }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
AppTile.displayName ='AppTile';
|
||||
|
||||
AppTile.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
room: PropTypes.object.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
|
||||
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
|
||||
fullWidth: PropTypes.bool,
|
||||
// UserId of the current user
|
||||
userId: PropTypes.string.isRequired,
|
||||
// UserId of the entity that added / modified the widget
|
||||
creatorUserId: PropTypes.string,
|
||||
waitForIframeLoad: PropTypes.bool,
|
||||
showMenubar: PropTypes.bool,
|
||||
// Should the AppTile render itself
|
||||
show: PropTypes.bool,
|
||||
// Optional onEditClickHandler (overrides default behaviour)
|
||||
onEditClick: PropTypes.func,
|
||||
// Optional onDeleteClickHandler (overrides default behaviour)
|
||||
onDeleteClick: PropTypes.func,
|
||||
// Optional onMinimiseClickHandler
|
||||
onMinimiseClick: PropTypes.func,
|
||||
// Optionally hide the tile title
|
||||
showTitle: PropTypes.bool,
|
||||
// Optionally hide the tile minimise icon
|
||||
showMinimise: PropTypes.bool,
|
||||
// Optionally handle minimise button pointer events (default false)
|
||||
handleMinimisePointerEvents: PropTypes.bool,
|
||||
// Optionally hide the delete icon
|
||||
showDelete: PropTypes.bool,
|
||||
// Optionally hide the popout widget icon
|
||||
showPopout: PropTypes.bool,
|
||||
// Widget capabilities to allow by default (without user confirmation)
|
||||
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
||||
// basic widget capabilities, e.g. injecting sticker message events.
|
||||
whitelistCapabilities: PropTypes.array,
|
||||
// Optional function to be called on widget capability request
|
||||
// Called with an array of the requested capabilities
|
||||
onCapabilityRequest: PropTypes.func,
|
||||
// Is this an instance of a user widget
|
||||
userWidget: PropTypes.bool,
|
||||
};
|
||||
|
||||
AppTile.defaultProps = {
|
||||
url: "",
|
||||
waitForIframeLoad: true,
|
||||
showMenubar: true,
|
||||
showTitle: true,
|
||||
showMinimise: true,
|
||||
showDelete: true,
|
||||
showPopout: true,
|
||||
handleMinimisePointerEvents: false,
|
||||
whitelistCapabilities: [],
|
||||
userWidget: false,
|
||||
};
|
||||
|
|
|
@ -15,71 +15,30 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { DragSource, DropTarget } from 'react-dnd';
|
||||
|
||||
import TagTile from './TagTile';
|
||||
import dis from '../../../dispatcher';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
|
||||
const tagTileSource = {
|
||||
canDrag: function(props, monitor) {
|
||||
return true;
|
||||
},
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
beginDrag: function(props) {
|
||||
// Return the data describing the dragged item
|
||||
return {
|
||||
tag: props.tag,
|
||||
};
|
||||
},
|
||||
|
||||
endDrag: function(props, monitor, component) {
|
||||
const dropResult = monitor.getDropResult();
|
||||
if (!monitor.didDrop() || !dropResult) {
|
||||
return;
|
||||
}
|
||||
props.onEndDrag();
|
||||
},
|
||||
};
|
||||
|
||||
const tagTileTarget = {
|
||||
canDrop(props, monitor) {
|
||||
return true;
|
||||
},
|
||||
|
||||
hover(props, monitor, component) {
|
||||
if (!monitor.canDrop()) return;
|
||||
const draggedY = monitor.getClientOffset().y;
|
||||
const {top, bottom} = findDOMNode(component).getBoundingClientRect();
|
||||
const targetY = (top + bottom) / 2;
|
||||
dis.dispatch({
|
||||
action: 'order_tag',
|
||||
tag: monitor.getItem().tag,
|
||||
targetTag: props.tag,
|
||||
// Note: we indicate that the tag should be after the target when
|
||||
// it's being dragged over the top half of the target.
|
||||
after: draggedY < targetY,
|
||||
});
|
||||
},
|
||||
|
||||
drop(props) {
|
||||
// Return the data to be returned by getDropResult
|
||||
return {
|
||||
tag: props.tag,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default
|
||||
DropTarget('TagTile', tagTileTarget, (connect, monitor) => ({
|
||||
connectDropTarget: connect.dropTarget(),
|
||||
}))(DragSource('TagTile', tagTileSource, (connect, monitor) => ({
|
||||
connectDragSource: connect.dragSource(),
|
||||
}))((props) => {
|
||||
const { connectDropTarget, connectDragSource, ...otherProps } = props;
|
||||
return connectDropTarget(connectDragSource(
|
||||
<div>
|
||||
<TagTile {...otherProps} />
|
||||
</div>,
|
||||
));
|
||||
}));
|
||||
export default function DNDTagTile(props) {
|
||||
return <div>
|
||||
<Draggable
|
||||
key={props.tag}
|
||||
draggableId={props.tag}
|
||||
index={props.index}
|
||||
type="draggable-TagTile"
|
||||
>
|
||||
{ (provided, snapshot) => (
|
||||
<div>
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<TagTile {...props} />
|
||||
</div>
|
||||
{ provided.placeholder }
|
||||
</div>
|
||||
) }
|
||||
</Draggable>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Aidan Gauland
|
||||
Copyright 2018 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,8 +15,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
@ -30,13 +29,32 @@ module.exports = React.createClass({
|
|||
// The primary button which is styled differently and has default focus.
|
||||
primaryButton: PropTypes.node.isRequired,
|
||||
|
||||
// A node to insert into the cancel button instead of default "Cancel"
|
||||
cancelButton: PropTypes.node,
|
||||
|
||||
// onClick handler for the primary button.
|
||||
onPrimaryButtonClick: PropTypes.func.isRequired,
|
||||
|
||||
// should there be a cancel button? default: true
|
||||
hasCancel: PropTypes.bool,
|
||||
|
||||
// onClick handler for the cancel button.
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func,
|
||||
|
||||
focus: PropTypes.bool,
|
||||
|
||||
disabled: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
hasCancel: true,
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
|
||||
_onCancelClick: function() {
|
||||
this.props.onCancel();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
@ -44,18 +62,23 @@ module.exports = React.createClass({
|
|||
if (this.props.primaryButtonClass) {
|
||||
primaryButtonClassName += " " + this.props.primaryButtonClass;
|
||||
}
|
||||
let cancelButton;
|
||||
if (this.props.cancelButton || this.props.hasCancel) {
|
||||
cancelButton = <button onClick={this._onCancelClick} disabled={this.props.disabled}>
|
||||
{ this.props.cancelButton || _t("Cancel") }
|
||||
</button>;
|
||||
}
|
||||
return (
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button className={primaryButtonClassName}
|
||||
onClick={this.props.onPrimaryButtonClick}
|
||||
autoFocus={this.props.focus}
|
||||
disabled={this.props.disabled}
|
||||
>
|
||||
{ this.props.primaryButton }
|
||||
</button>
|
||||
{ this.props.children }
|
||||
<button onClick={this.props.onCancel}>
|
||||
{ _t("Cancel") }
|
||||
</button>
|
||||
{ cancelButton }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -107,7 +107,11 @@ export default class Flair extends React.Component {
|
|||
}
|
||||
const profiles = await this._getGroupProfiles(groups);
|
||||
if (!this.unmounted) {
|
||||
this.setState({profiles: profiles.filter((profile) => {return profile.avatarUrl;})});
|
||||
this.setState({
|
||||
profiles: profiles.filter((profile) => {
|
||||
return profile ? profile.avatarUrl : false;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
35
src/components/views/elements/GeminiScrollbarWrapper.js
Normal file
35
src/components/views/elements/GeminiScrollbarWrapper.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GeminiScrollbar from 'react-gemini-scrollbar';
|
||||
|
||||
function GeminiScrollbarWrapper(props) {
|
||||
const {wrappedRef, ...wrappedProps} = props;
|
||||
|
||||
// Enable forceGemini so that gemini is always enabled. This is
|
||||
// to avoid future issues where a feature is implemented without
|
||||
// doing QA on every OS/browser combination.
|
||||
//
|
||||
// By default GeminiScrollbar allows native scrollbars to be used
|
||||
// on macOS. Use forceGemini to enable Gemini's non-native
|
||||
// scrollbars on all OSs.
|
||||
return <GeminiScrollbar ref={wrappedRef} forceGemini={true} {...wrappedProps}>
|
||||
{ props.children }
|
||||
</GeminiScrollbar>;
|
||||
}
|
||||
export default GeminiScrollbarWrapper;
|
||||
|
205
src/components/views/elements/ImageView.js
Normal file
205
src/components/views/elements/ImageView.js
Normal file
|
@ -0,0 +1,205 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var React = require('react');
|
||||
|
||||
var MatrixClientPeg = require('../../../MatrixClientPeg');
|
||||
|
||||
import {formatDate} from '../../../DateUtils';
|
||||
var filesize = require('filesize');
|
||||
var AccessibleButton = require('../../../components/views/elements/AccessibleButton');
|
||||
const Modal = require('../../../Modal');
|
||||
const sdk = require('../../../index');
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'ImageView',
|
||||
|
||||
propTypes: {
|
||||
src: React.PropTypes.string.isRequired, // the source of the image being displayed
|
||||
name: React.PropTypes.string, // the main title ('name') for the image
|
||||
link: React.PropTypes.string, // the link (if any) applied to the name of the image
|
||||
width: React.PropTypes.number, // width of the image src in pixels
|
||||
height: React.PropTypes.number, // height of the image src in pixels
|
||||
fileSize: React.PropTypes.number, // size of the image src in bytes
|
||||
onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
|
||||
|
||||
// the event (if any) that the Image is displaying. Used for event-specific stuff like
|
||||
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit
|
||||
// properties above, which let us use lightboxes to display images which aren't associated
|
||||
// with events.
|
||||
mxEvent: React.PropTypes.object,
|
||||
},
|
||||
|
||||
// XXX: keyboard shortcuts for managing dialogs should be done by the modal
|
||||
// dialog base class somehow, surely...
|
||||
componentDidMount: function() {
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
},
|
||||
|
||||
onKeyDown: function(ev) {
|
||||
if (ev.keyCode == 27) { // escape
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.props.onFinished();
|
||||
}
|
||||
},
|
||||
|
||||
onRedactClick: function() {
|
||||
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
|
||||
Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, {
|
||||
onFinished: (proceed) => {
|
||||
if (!proceed) return;
|
||||
var self = this;
|
||||
MatrixClientPeg.get().redactEvent(
|
||||
this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()
|
||||
).catch(function(e) {
|
||||
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
// display error message stating you couldn't delete this.
|
||||
var code = e.errcode || e.statusCode;
|
||||
Modal.createTrackedDialog('You cannot delete this image.', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('You cannot delete this image. (%(code)s)', {code: code})
|
||||
});
|
||||
}).done();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getName: function () {
|
||||
var name = this.props.name;
|
||||
if (name && this.props.link) {
|
||||
name = <a href={ this.props.link } target="_blank" rel="noopener">{ name }</a>;
|
||||
}
|
||||
return name;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
||||
/*
|
||||
// In theory max-width: 80%, max-height: 80% on the CSS should work
|
||||
// but in practice, it doesn't, so do it manually:
|
||||
|
||||
var width = this.props.width || 500;
|
||||
var height = this.props.height || 500;
|
||||
|
||||
var maxWidth = document.documentElement.clientWidth * 0.8;
|
||||
var maxHeight = document.documentElement.clientHeight * 0.8;
|
||||
|
||||
var widthFrac = width / maxWidth;
|
||||
var heightFrac = height / maxHeight;
|
||||
|
||||
var displayWidth;
|
||||
var displayHeight;
|
||||
if (widthFrac > heightFrac) {
|
||||
displayWidth = Math.min(width, maxWidth);
|
||||
displayHeight = (displayWidth / width) * height;
|
||||
} else {
|
||||
displayHeight = Math.min(height, maxHeight);
|
||||
displayWidth = (displayHeight / height) * width;
|
||||
}
|
||||
|
||||
var style = {
|
||||
width: displayWidth,
|
||||
height: displayHeight
|
||||
};
|
||||
*/
|
||||
var style, res;
|
||||
|
||||
if (this.props.width && this.props.height) {
|
||||
style = {
|
||||
width: this.props.width,
|
||||
height: this.props.height,
|
||||
};
|
||||
res = style.width + "x" + style.height + "px";
|
||||
}
|
||||
|
||||
var size;
|
||||
if (this.props.fileSize) {
|
||||
size = filesize(this.props.fileSize);
|
||||
}
|
||||
|
||||
var size_res;
|
||||
if (size && res) {
|
||||
size_res = size + ", " + res;
|
||||
}
|
||||
else {
|
||||
size_res = size || res;
|
||||
}
|
||||
|
||||
var showEventMeta = !!this.props.mxEvent;
|
||||
|
||||
var eventMeta;
|
||||
if(showEventMeta) {
|
||||
// Figure out the sender, defaulting to mxid
|
||||
let sender = this.props.mxEvent.getSender();
|
||||
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
|
||||
if (room) {
|
||||
const member = room.getMember(sender);
|
||||
if (member) sender = member.name;
|
||||
}
|
||||
|
||||
eventMeta = (<div className="mx_ImageView_metadata">
|
||||
{ _t('Uploaded on %(date)s by %(user)s', {date: formatDate(new Date(this.props.mxEvent.getTs())), user: sender}) }
|
||||
</div>);
|
||||
}
|
||||
|
||||
var eventRedact;
|
||||
if(showEventMeta) {
|
||||
eventRedact = (<div className="mx_ImageView_button" onClick={this.onRedactClick}>
|
||||
{ _t('Remove') }
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_ImageView">
|
||||
<div className="mx_ImageView_lhs">
|
||||
</div>
|
||||
<div className="mx_ImageView_content">
|
||||
<img src={this.props.src} style={style}/>
|
||||
<div className="mx_ImageView_labelWrapper">
|
||||
<div className="mx_ImageView_label">
|
||||
<AccessibleButton className="mx_ImageView_cancel" onClick={ this.props.onFinished }><img src="img/cancel-white.svg" width="18" height="18" alt={ _t('Close') }/></AccessibleButton>
|
||||
<div className="mx_ImageView_shim">
|
||||
</div>
|
||||
<div className="mx_ImageView_name">
|
||||
{ this.getName() }
|
||||
</div>
|
||||
{ eventMeta }
|
||||
<a className="mx_ImageView_link" href={ this.props.src } download={ this.props.name } target="_blank" rel="noopener">
|
||||
<div className="mx_ImageView_download">
|
||||
{ _t('Download this file') }<br/>
|
||||
<span className="mx_ImageView_size">{ size_res }</span>
|
||||
</div>
|
||||
</a>
|
||||
{ eventRedact }
|
||||
<div className="mx_ImageView_shim">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_ImageView_rhs">
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
33
src/components/views/elements/InlineSpinner.js
Normal file
33
src/components/views/elements/InlineSpinner.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const React = require('react');
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'InlineSpinner',
|
||||
|
||||
render: function() {
|
||||
var w = this.props.w || 16;
|
||||
var h = this.props.h || 16;
|
||||
var imgClass = this.props.imgClassName || "";
|
||||
|
||||
return (
|
||||
<div className="mx_InlineSpinner">
|
||||
<img src="img/spinner.gif" width={w} height={h} className={imgClass}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
|
@ -64,7 +64,7 @@ export default class ManageIntegsButton extends React.Component {
|
|||
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
|
||||
Modal.createDialog(IntegrationsManager, {
|
||||
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
|
||||
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.roomId) :
|
||||
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) :
|
||||
null,
|
||||
}, "mx_IntegrationsManager");
|
||||
}
|
||||
|
@ -103,5 +103,5 @@ export default class ManageIntegsButton extends React.Component {
|
|||
}
|
||||
|
||||
ManageIntegsButton.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
room: PropTypes.object.isRequired,
|
||||
};
|
||||
|
|
114
src/components/views/elements/PersistedElement.js
Normal file
114
src/components/views/elements/PersistedElement.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Copyright 2018 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
// pass in a custom control as the actual body.
|
||||
|
||||
const ContainerId = "mx_PersistedElement";
|
||||
|
||||
function getOrCreateContainer() {
|
||||
let container = document.getElementById(ContainerId);
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = ContainerId;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// Greater than that of the ContextualMenu
|
||||
const PE_Z_INDEX = 3000;
|
||||
|
||||
/*
|
||||
* Class of component that renders its children in a separate ReactDOM virtual tree
|
||||
* in a container element appended to document.body.
|
||||
*
|
||||
* This prevents the children from being unmounted when the parent of PersistedElement
|
||||
* unmounts, allowing them to persist.
|
||||
*
|
||||
* When PE is unmounted, it hides the children using CSS. When mounted or updated, the
|
||||
* children are made visible and are positioned into a div that is given the same
|
||||
* bounding rect as the parent of PE.
|
||||
*/
|
||||
export default class PersistedElement extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.collectChildContainer = this.collectChildContainer.bind(this);
|
||||
this.collectChild = this.collectChild.bind(this);
|
||||
}
|
||||
|
||||
collectChildContainer(ref) {
|
||||
this.childContainer = ref;
|
||||
}
|
||||
|
||||
collectChild(ref) {
|
||||
this.child = ref;
|
||||
this.updateChild();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateChild();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateChild();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.updateChildVisibility(this.child, false);
|
||||
}
|
||||
|
||||
updateChild() {
|
||||
this.updateChildPosition(this.child, this.childContainer);
|
||||
this.updateChildVisibility(this.child, true);
|
||||
}
|
||||
|
||||
updateChildVisibility(child, visible) {
|
||||
if (!child) return;
|
||||
child.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
updateChildPosition(child, parent) {
|
||||
if (!child || !parent) return;
|
||||
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
Object.assign(child.style, {
|
||||
position: 'absolute',
|
||||
top: parentRect.top + 'px',
|
||||
left: parentRect.left + 'px',
|
||||
width: parentRect.width + 'px',
|
||||
height: parentRect.height + 'px',
|
||||
zIndex: PE_Z_INDEX,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = <div ref={this.collectChild}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
|
||||
ReactDOM.render(content, getOrCreateContainer());
|
||||
|
||||
return <div ref={this.collectChildContainer}></div>;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import React from 'react';
|
|||
import sdk from '../../../index';
|
||||
import dis from '../../../dispatcher';
|
||||
import classNames from 'classnames';
|
||||
import { Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { Room, RoomMember, MatrixClient } from 'matrix-js-sdk';
|
||||
import PropTypes from 'prop-types';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix';
|
||||
|
@ -61,6 +61,17 @@ const Pill = React.createClass({
|
|||
shouldShowPillAvatar: PropTypes.bool,
|
||||
},
|
||||
|
||||
|
||||
childContextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient),
|
||||
},
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
matrixClient: this._matrixClient,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
// ID/alias of the room/user
|
||||
|
@ -135,6 +146,7 @@ const Pill = React.createClass({
|
|||
|
||||
componentWillMount() {
|
||||
this._unmounted = false;
|
||||
this._matrixClient = MatrixClientPeg.get();
|
||||
this.componentWillReceiveProps(this.props);
|
||||
},
|
||||
|
||||
|
@ -195,6 +207,7 @@ const Pill = React.createClass({
|
|||
const member = this.state.member;
|
||||
if (member) {
|
||||
userId = member.userId;
|
||||
member.rawDisplayName = member.rawDisplayName || '';
|
||||
linkText = member.rawDisplayName.replace(' (IRC)', ''); // FIXME when groups are done
|
||||
if (this.props.shouldShowPillAvatar) {
|
||||
avatar = <MemberAvatar member={member} width={16} height={16} />;
|
||||
|
|
|
@ -42,6 +42,9 @@ module.exports = React.createClass({
|
|||
// should the user be able to change the value? false by default.
|
||||
disabled: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
|
||||
// Optional key to pass as the second argument to `onChange`
|
||||
powerLevelKey: PropTypes.string,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -84,17 +87,17 @@ module.exports = React.createClass({
|
|||
onSelectChange: function(event) {
|
||||
this.setState({ custom: event.target.value === "SELECT_VALUE_CUSTOM" });
|
||||
if (event.target.value !== "SELECT_VALUE_CUSTOM") {
|
||||
this.props.onChange(event.target.value);
|
||||
this.props.onChange(event.target.value, this.props.powerLevelKey);
|
||||
}
|
||||
},
|
||||
|
||||
onCustomBlur: function(event) {
|
||||
this.props.onChange(parseInt(this.refs.custom.value));
|
||||
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
|
||||
},
|
||||
|
||||
onCustomKeyDown: function(event) {
|
||||
if (event.key == "Enter") {
|
||||
this.props.onChange(parseInt(this.refs.custom.value));
|
||||
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue