Merge branch 'clearnet' into brand-redesign

pull/748/head
Audric Ackermann 5 years ago
commit 7867f4dceb
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -2,7 +2,7 @@
[![Build Status](https://travis-ci.org/loki-project/loki-messenger.svg?branch=development)](https://travis-ci.org/loki-project/loki-messenger)
Session Messenger allows for truly decentralized, end to end, and private encrypted chats. Session Messenger is built to handle both online and fully Asynchronous offline messages. Loki messenger implements the Signal protocol for message encryption. Our Client interface is a fork of [Session Messenger](https://signal.org/). All communication that passes through Loki messenger is routed through [Lokinet](https://github.com/loki-project/loki-network).
Session Messenger allows for truly decentralized, end to end, and private encrypted chats. Session Messenger is built to handle both online and fully Asynchronous offline messages. Loki messenger implements the Signal protocol for message encryption. Our Client interface is a fork of [Signal Messenger](https://signal.org/). All communication that passes through Loki messenger is routed through [Lokinet](https://github.com/loki-project/loki-network).
## Summary

@ -1417,10 +1417,18 @@
"message": "Enable the sending and receiving of read receipts",
"description": "Description of the read receipts setting"
},
"readReceiptSettingTitle": {
"message": "Read Receipts",
"description": "Title of the read receipts setting"
},
"typingIndicatorsSettingDescription": {
"message": "Enable the sending and receiving of typing indicators",
"description": "Description of the typing indicators setting"
},
"typingIndicatorsSettingTitle": {
"message": "Typing Indicators",
"description": "Title of the typing indicators setting"
},
"messageTTL": {
"message": "Message TTL",
"description": "Title of the Message TTL setting"
@ -2082,54 +2090,52 @@
"message": "Note: Your display name will be visible to your contacts",
"description": "Shown to the user as a warning about setting display name"
},
"copyPublicKey": {
"message": "Copy Public Key",
"description":
"Button action that the user can click to copy their public keys"
},
"banUser": {
"message": "Ban User",
"description": "Ban user from public chat by public key."
},
"banUserConfirm": {
"message": "Are you sure you want to ban user?",
"description": "Message shown when confirming user ban."
},
"userBanned": {
"message": "User successfully banned",
"description": "Toast on succesful user ban."
},
"userBanFailed": {
"message": "User ban failed!",
"description": "Toast on unsuccesful user ban."
},
"copyChatId": {
"message": "Copy Chat ID"
},
"updateGroup": {
"message": "Update Group",
"description":
"Button action that the user can click to rename the group or add a new member"
},
"leaveGroup": {
"message": "Leave Group",
"description": "Button action that the user can click to leave the group"
},
"leaveOpenGroup": {
"message": "Leave Open Group",
"description": "Button action that the user can click to leave the group"
},
"leaveClosedGroup": {
"message": "Leave Closed Group",
"description": "Button action that the user can click to leave the group"
},
"leaveGroupDialogTitle": {
"message": "Are you sure you want to leave this group?",
"description":
"Title shown to the user to confirm they want to leave the group"
},
"copiedPublicKey": {
"message": "Copied public key",
"description": "A toast message telling the user that the key was copied"
@ -2156,18 +2162,20 @@
"message": "Edit Profile",
"description": "Button action that the user can click to edit their profile"
},
"createGroupDialogTitle": {
"message": "Creating a Private Group Chat",
"description": "Title for the dialog box used to create a new private group"
},
"updateGroupDialogTitle": {
"message": "Updating a Private Group Chat",
"description":
"Title for the dialog box used to update an existing private group"
},
"updatePublicGroupDialogTitle": {
"message": "Updating a Public Chat Channel",
"description":
"Title for the dialog box used to update an existing public chat channel"
},
"showSeed": {
"message": "Show Seed",
"description":
@ -2247,7 +2255,7 @@
"message": "Server URL",
"description": "Placeholder for server URL input"
},
"noServerUrl": {
"noServerURL": {
"message": "Please enter a server URL",
"description": "Error message when no server url entered"
},
@ -2256,19 +2264,16 @@
"description":
"Title for the dialog box used to connect to a new public server"
},
"createPrivateGroup": {
"message": "Create Private Group",
"description":
"Button action that the user can click to show a dialog for creating a new private group chat"
},
"seedViewTitle": {
"message":
"Please save the seed below in a safe location. They can be used to restore your account if you lose access or migrate to a new device.",
"description": "The title shown when the user views their seeds"
},
"copiedMnemonic": {
"message": "Copied seed to clipboard",
"description":
@ -2279,7 +2284,6 @@
"description":
"A toast message telling the user that their Session ID was copied"
},
"passwordViewTitle": {
"message": "Type In Your Password",
"description":
@ -2414,7 +2418,6 @@
"message": "Invalid Pubkey Format",
"description": "Error string shown when user types an invalid pubkey format"
},
"conversationsTab": {
"message": "Conversations",
"description": "conversation tab title"
@ -2463,6 +2466,18 @@
"inviteFriends": {
"message": "Invite Friends"
},
"manageModerators": {
"message": "Manage Moderators"
},
"addModerators": {
"message": "Add Moderators"
},
"removeModerators": {
"message": "Remove Moderators"
},
"add": {
"message": "Add"
},
"groupInvitation": {
"message": "Group Invitation"
},
@ -2472,6 +2487,9 @@
"noFriendsToAdd": {
"message": "No friends to add"
},
"noModeratorsToRemove": {
"message": "no moderators to remove"
},
"couldNotDecryptMessage": {
"message": "Couldn't decrypt a message"
},

@ -16,17 +16,12 @@ exports.createTemplate = (options, messages) => {
setupWithImport,
showAbout,
showDebugLog,
showSettings,
} = options;
const template = [
{
label: messages.mainMenuFile.message,
submenu: [
{
label: messages.mainMenuSettings.message,
click: showSettings,
},
{
type: 'separator',
},
@ -193,7 +188,6 @@ function updateForMac(template, messages, options) {
setupAsStandalone,
setupWithImport,
showAbout,
showSettings,
showWindow,
} = options;
@ -242,11 +236,6 @@ function updateForMac(template, messages, options) {
{
type: 'separator',
},
{
label: messages.mainMenuSettings.message,
accelerator: 'CommandOrControl+,',
click: showSettings,
},
{
type: 'separator',
},

@ -2339,6 +2339,7 @@ async function getUnreadByConversation(conversationId) {
return map(rows, row => jsonToObject(row.json));
}
// Note: Sorting here is necessary for getting the last message (with limit 1)
async function getMessagesByConversation(
conversationId,
{ limit = 100, receivedAt = Number.MAX_VALUE, type = '%' } = {}
@ -2349,7 +2350,7 @@ async function getMessagesByConversation(
conversationId = $conversationId AND
received_at < $received_at AND
type LIKE $type
ORDER BY received_at DESC
ORDER BY sent_at DESC
LIMIT $limit;
`,
{

@ -109,7 +109,9 @@
<form class='send clearfix file-input'>
<div class='flex'>
<button class='emoji' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
<textarea maxlength='2000' class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></textarea>
<div class='send-message-container'>
<textarea maxlength='2000' class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></textarea>
</div>
<div class='capture-audio'>
<button class='microphone' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
</div>
@ -580,6 +582,8 @@
<script type='text/javascript' src='js/views/confirm_session_reset_view.js'></script>
<script type='text/javascript' src='js/views/edit_profile_dialog_view.js'></script>
<script type='text/javascript' src='js/views/invite_friends_dialog_view.js'></script>
<script type='text/javascript' src='js/views/moderators_add_dialog_view.js'></script>
<script type='text/javascript' src='js/views/moderators_remove_dialog_view.js'></script>
<script type='text/javascript' src='js/views/user_details_dialog_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script>

@ -6,6 +6,10 @@
"localServerPort": "8081",
"defaultPoWDifficulty": "1",
"seedNodeList": [
{
"ip": "public.loki.foundation",
"port": "22023"
},
{
"ip": "storage.seed1.loki.network",
"port": "22023"

@ -2,6 +2,10 @@
"storageProfile": "development1",
"localServerPort": "8082",
"seedNodeList": [
{
"ip": "public.loki.foundation",
"port": "38157"
},
{
"ip": "storage.testnetseed1.loki.network",
"port": "38157"

@ -2,6 +2,10 @@
"storageProfile": "development2",
"localServerPort": "8083",
"seedNodeList": [
{
"ip": "public.loki.foundation",
"port": "38157"
},
{
"ip": "storage.testnetseed1.loki.network",
"port": "38157"

@ -1,6 +1,10 @@
{
"storageProfile": "development",
"seedNodeList": [
{
"ip": "public.loki.foundation",
"port": "38157"
},
{
"ip": "storage.testnetseed1.loki.network",
"port": "38157"

@ -321,7 +321,7 @@
window.Events = {
getDeviceName: () => textsecure.storage.user.getDeviceName(),
getThemeSetting: () => storage.get('theme-setting', 'dark'),
getThemeSetting: () => 'dark', // storage.get('theme-setting', 'dark')
setThemeSetting: value => {
storage.put('theme-setting', value);
onChangeTheme();
@ -644,9 +644,9 @@
Whisper.events.on('registration_done', async () => {
window.log.info('handling registration event');
// Enable link previews as default
// Disable link previews as default per Kee 20/01/28
storage.onready(async () => {
storage.put('linkPreviews', true);
storage.put('linkPreviews', false);
});
// listeners
@ -728,6 +728,7 @@
ev.data = {
source: ourKey,
timestamp: Date.now(),
message: {
group: {
id: groupId,
@ -744,6 +745,20 @@
'group'
);
if (convo.isPublic()) {
const API = await convo.getPublicSendData();
if (await API.setChannelName(groupName)) {
// queue update from server
// and let that set the conversation
API.pollForChannelOnce();
// or we could just directly call
// convo.setGroupName(groupName);
// but gut is saying let the server be the definitive storage of the state
// and trickle down from there
}
return;
}
const avatar = '';
const options = {};
@ -1009,8 +1024,13 @@
};
window.toggleMenuBar = () => {
const newValue = !window.getSettingValue('hide-menu-bar');
window.Events.setHideMenuBar(newValue);
const current = window.getSettingValue('hide-menu-bar');
if (current === undefined) {
window.Events.setHideMenuBar(false);
return;
}
window.Events.setHideMenuBar(!current);
};
window.toggleSpellCheck = () => {
@ -1063,6 +1083,18 @@
}
});
Whisper.events.on('addModerators', async groupConvo => {
if (appView) {
appView.showAddModeratorsDialog(groupConvo);
}
});
Whisper.events.on('removeModerators', async groupConvo => {
if (appView) {
appView.showRemoveModeratorsDialog(groupConvo);
}
});
Whisper.events.on(
'publicChatInvitationAccepted',
async (serverAddress, channelId) => {
@ -1134,6 +1166,13 @@
});
Whisper.events.on('onShowUserDetails', async ({ userPubKey }) => {
const isMe = userPubKey === textsecure.storage.user.getNumber();
if (isMe) {
Whisper.events.trigger('onEditProfile');
return;
}
const conversation = await ConversationController.getOrCreateAndWait(
userPubKey,
'private'

@ -1,6 +1,7 @@
/* global
$,
_,
log,
i18n,
Backbone,
ConversationController,
@ -210,13 +211,13 @@
return this.id === window.storage.get('primaryDevicePubKey');
},
isPublic() {
return this.id && this.id.match(/^publicChat:/);
return !!(this.id && this.id.match(/^publicChat:/));
},
isClosable() {
return !this.isRss() || this.get('closable');
},
isRss() {
return this.id && this.id.match(/^rss:/);
return !!(this.id && this.id.match(/^rss:/));
},
isBlocked() {
return BlockedNumberController.isBlocked(this.id);
@ -2415,14 +2416,21 @@
// maybe "Backend" instead of "Source"?
async setPublicSource(newServer, newChannelId) {
if (!this.isPublic()) {
log.warn(
`trying to setPublicSource on non public chat conversation ${this.id}`
);
return;
}
if (
this.get('server') !== newServer ||
this.get('channelId') !== newChannelId
) {
this.set({ server: newServer });
this.set({ channelId: newChannelId });
// mark active so it's not in the friends list but the conversation list
this.set({
server: newServer,
channelId: newChannelId,
active_at: Date.now(),
});
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
@ -2430,6 +2438,9 @@
},
getPublicSource() {
if (!this.isPublic()) {
log.warn(
`trying to getPublicSource on non public chat conversation ${this.id}`
);
return null;
}
return {
@ -2439,18 +2450,8 @@
};
},
async getPublicSendData() {
const serverAPI = await lokiPublicChatAPI.findOrCreateServer(
this.get('server')
);
if (!serverAPI) {
window.log.warn(
`Failed to get serverAPI (${this.get('server')}) for conversation (${
this.id
})`
);
return null;
}
const channelAPI = await serverAPI.findOrCreateChannel(
const channelAPI = await lokiPublicChatAPI.findOrCreateChannel(
this.get('server'),
this.get('channelId'),
this.id
);
@ -2539,6 +2540,10 @@
});
}
},
async setSubscriberCount(count) {
this.set({ subscriberCount: count });
// Not sure if we care about updating the database
},
async setGroupNameAndAvatar(name, avatarPath) {
const currentName = this.get('name');
const profileAvatar = this.get('profileAvatar');

@ -2523,11 +2523,11 @@
Whisper.MessageCollection = Backbone.Collection.extend({
model: Whisper.Message,
comparator(left, right) {
if (left.get('received_at') === right.get('received_at')) {
return (left.get('sent_at') || 0) - (right.get('sent_at') || 0);
if (left.get('sent_at') === right.get('sent_at')) {
return (left.get('received_at') || 0) - (right.get('received_at') || 0);
}
return (left.get('received_at') || 0) - (right.get('received_at') || 0);
return (left.get('sent_at') || 0) - (right.get('sent_at') || 0);
},
initialize(models, options) {
if (options) {

@ -1,7 +1,6 @@
/* global log, textsecure, libloki, Signal, Whisper, Headers, ConversationController,
clearTimeout, MessageController, libsignal, StringView, window, _,
dcodeIO, Buffer */
const EventEmitter = require('events');
const nodeFetch = require('node-fetch');
const { URL, URLSearchParams } = require('url');
const FormData = require('form-data');
@ -15,180 +14,19 @@ const PUBLICCHAT_MIN_TIME_BETWEEN_DUPLICATE_MESSAGES = 10 * 1000; // 10s
const HOMESERVER_USER_ANNOTATION_TYPE = 'network.loki.messenger.homeserver';
const AVATAR_USER_ANNOTATION_TYPE = 'network.loki.messenger.avatar';
const SETTINGS_CHANNEL_ANNOTATION_TYPE = 'net.patter-app.settings';
const MESSAGE_ATTACHMENT_TYPE = 'net.app.core.oembed';
const LOKI_ATTACHMENT_TYPE = 'attachment';
const LOKI_PREVIEW_TYPE = 'preview';
// not quite a singleton yet (one for chat and one per file server)
class LokiAppDotNetAPI extends EventEmitter {
constructor(ourKey) {
super();
this.ourKey = ourKey;
this.servers = [];
this.myPrivateKey = false;
this.allMembers = [];
// Multidevice states
this.primaryUserProfileName = {};
}
async close() {
await Promise.all(this.servers.map(server => server.close()));
}
async getPrivateKey() {
if (!this.myPrivateKey) {
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
this.myPrivateKey = myKeyPair.privKey;
}
return this.myPrivateKey;
}
// server getter/factory
async findOrCreateServer(serverUrl) {
let thisServer = this.servers.find(
server => server.baseServerUrl === serverUrl
);
if (!thisServer) {
log.info(`LokiAppDotNetAPI creating ${serverUrl}`);
thisServer = new LokiAppDotNetServerAPI(this, serverUrl);
const gotToken = await thisServer.getOrRefreshServerToken();
if (!gotToken) {
log.warn(`Invalid server ${serverUrl}`);
return null;
}
log.info(`set token ${thisServer.token}`);
this.servers.push(thisServer);
}
return thisServer;
}
static async getServerTime() {
const url = `${window.getDefaultFileServer()}/loki/v1/time`;
let timestamp = NaN;
try {
const res = await nodeFetch(url);
if (res.ok) {
timestamp = await res.text();
}
} catch (e) {
return timestamp;
}
return Number(timestamp);
}
static async getTimeDifferential() {
// Get time differential between server and client in seconds
const serverTime = await this.getServerTime();
const clientTime = Math.ceil(Date.now() / 1000);
if (Number.isNaN(serverTime)) {
return 0;
}
return serverTime - clientTime;
}
static async setClockParams() {
// Set server-client time difference
const maxTimeDifferential = 30;
const timeDifferential = await this.getTimeDifferential();
window.clientClockSynced = Math.abs(timeDifferential) < maxTimeDifferential;
return window.clientClockSynced;
}
// channel getter/factory
async findOrCreateChannel(serverUrl, channelId, conversationId) {
const server = await this.findOrCreateServer(serverUrl);
if (!server) {
log.error(`Failed to create server for: ${serverUrl}`);
return null;
}
return server.findOrCreateChannel(channelId, conversationId);
}
// deallocate resources server uses
unregisterChannel(serverUrl, channelId) {
let thisServer;
let i = 0;
for (; i < this.servers.length; i += 1) {
if (this.servers[i].baseServerUrl === serverUrl) {
thisServer = this.servers[i];
break;
}
}
if (!thisServer) {
log.warn(`Tried to unregister from nonexistent server ${serverUrl}`);
return;
}
thisServer.unregisterChannel(channelId);
this.servers.splice(i, 1);
}
// shouldn't this be scoped per conversation?
async getListOfMembers() {
// enable in the next release
/*
let members = [];
await Promise.all(this.servers.map(async server => {
await Promise.all(server.channels.map(async channel => {
const newMembers = await channel.getSubscribers();
members = [...members, ...newMembers];
}));
}));
const results = members.map(member => {
return { authorPhoneNumber: member.username };
});
*/
return this.allMembers;
}
// TODO: make this private (or remove altogether) when
// we switch to polling the server for group members
setListOfMembers(members) {
this.allMembers = members;
}
async setProfileName(profileName) {
await Promise.all(
this.servers.map(async server => {
await server.setProfileName(profileName);
})
);
}
async setHomeServer(homeServer) {
await Promise.all(
this.servers.map(async server => {
// this may fail
// but we can't create a sql table to remember to retry forever
// I think we just silently fail for now
await server.setHomeServer(homeServer);
})
);
}
async setAvatar(url, profileKey) {
await Promise.all(
this.servers.map(async server => {
// this may fail
// but we can't create a sql table to remember to retry forever
// I think we just silently fail for now
await server.setAvatar(url, profileKey);
})
);
}
}
// the core ADN class that handles all communication with a specific server
class LokiAppDotNetServerAPI {
constructor(chatAPI, url) {
this.chatAPI = chatAPI;
constructor(ourKey, url) {
this.ourKey = ourKey;
this.channels = [];
this.tokenPromise = null;
this.baseServerUrl = url;
log.info(`LokiAppDotNetAPI registered server ${url}`);
}
async close() {
@ -199,18 +37,22 @@ class LokiAppDotNetServerAPI {
}
// channel getter/factory
async findOrCreateChannel(channelId, conversationId) {
async findOrCreateChannel(chatAPI, channelId, conversationId) {
let thisChannel = this.channels.find(
channel => channel.channelId === channelId
);
if (!thisChannel) {
log.info(`LokiAppDotNetAPI registering channel ${conversationId}`);
// make sure we're subscribed
// eventually we'll need to move to account registration/add server
await this.serverRequest(`channels/${channelId}/subscribe`, {
method: 'POST',
});
thisChannel = new LokiPublicChannelAPI(this, channelId, conversationId);
thisChannel = new LokiPublicChannelAPI(
chatAPI,
this,
channelId,
conversationId
);
this.channels.push(thisChannel);
}
return thisChannel;
@ -243,7 +85,7 @@ class LokiAppDotNetServerAPI {
async setProfileName(profileName) {
// when we add an annotation, may need this
/*
const privKey = await this.serverAPI.chatAPI.getPrivateKey();
const privKey = await this.getPrivateKey();
// we might need an annotation that sets the homeserver for media
// better to include this with each attachment...
const objToSign = {
@ -303,7 +145,7 @@ class LokiAppDotNetServerAPI {
}
async setAvatar(url, profileKey) {
let value = null;
let value; // undefined will save bandwidth on the annotation if we don't need it (no avatar)
if (url && profileKey) {
value = { url, profileKey };
}
@ -342,6 +184,7 @@ class LokiAppDotNetServerAPI {
tokenRes.response.data.user
) {
// get our profile name
// FIXME: should this be window.storage.get('primaryDevicePubKey')?
const ourNumber = textsecure.storage.user.getNumber();
const profileConvo = ConversationController.get(ourNumber);
const profileName = profileConvo.getProfileName();
@ -351,6 +194,24 @@ class LokiAppDotNetServerAPI {
this.setProfileName(profileName);
}
}
if (tokenRes.err) {
log.error(`token err`, tokenRes);
// didn't already try && this specific error
if (
!forceRefresh &&
tokenRes.response &&
tokenRes.response.meta &&
tokenRes.response.meta.code === 401
) {
// this token is not good
this.token = ''; // remove from object
await Signal.Data.savePublicServerToken({
serverUrl: this.baseServerUrl,
token: '',
});
token = await this.getOrRefreshServerToken(true);
}
}
return token;
}
@ -390,7 +251,7 @@ class LokiAppDotNetServerAPI {
try {
const url = new URL(`${this.baseServerUrl}/loki/v1/get_challenge`);
const params = {
pubKey: this.chatAPI.ourKey,
pubKey: this.ourKey,
};
url.search = new URLSearchParams(params);
@ -414,7 +275,7 @@ class LokiAppDotNetServerAPI {
'Content-Type': 'application/json',
},
body: JSON.stringify({
pubKey: this.chatAPI.ourKey,
pubKey: this.ourKey,
token,
}),
};
@ -444,18 +305,15 @@ class LokiAppDotNetServerAPI {
url.search = new URLSearchParams(params);
}
let result;
const token = await this.getOrRefreshServerToken();
if (!token) {
log.error('NO TOKEN');
return {
err: 'noToken',
};
}
try {
const fetchOptions = {};
const headers = {
Authorization: `Bearer ${this.token}`,
};
const headers = {};
if (forceFreshToken) {
await this.getOrRefreshServerToken(true);
}
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
if (method) {
fetchOptions.method = method;
}
@ -477,7 +335,7 @@ class LokiAppDotNetServerAPI {
try {
response = await result.json();
} catch (e) {
log.warn(`serverRequest json arpse ${e}`);
log.warn(`serverRequest json parse ${e}`);
return {
err: e,
statusCode: result.status,
@ -486,7 +344,7 @@ class LokiAppDotNetServerAPI {
// if it's a response style with a meta
if (result.status !== 200) {
if (!forceFreshToken && response.meta.code === 401) {
if (!forceFreshToken && (!response.meta || response.meta.code === 401)) {
// copy options because lint complains if we modify this directly
const updatedOptions = options;
// force it this time
@ -528,6 +386,60 @@ class LokiAppDotNetServerAPI {
return res.response.data.annotations || [];
}
async getModerators(channelId) {
if (!channelId) {
log.warn('No channelId provided to getModerators!');
return [];
}
const res = await this.serverRequest(
`loki/v1/channels/${channelId}/moderators`
);
return (!res.err && res.response && res.response.moderators) || [];
}
async addModerators(pubKeysParam) {
let pubKeys = pubKeysParam;
if (!Array.isArray(pubKeys)) {
pubKeys = [pubKeys];
}
pubKeys = pubKeys.map(key => `@${key}`);
const users = await this.getUsers(pubKeys);
const validUsers = users.filter(user => !!user.id);
const results = await Promise.all(
validUsers.map(async user => {
log.info(`POSTing loki/v1/moderators/${user.id}`);
const res = await this.serverRequest(`loki/v1/moderators/${user.id}`, {
method: 'POST',
});
return !!(!res.err && res.response && res.response.data);
})
);
const anyFailures = results.some(test => !test);
return anyFailures ? results : true; // return failures or total success
}
async removeModerators(pubKeysParam) {
let pubKeys = pubKeysParam;
if (!Array.isArray(pubKeys)) {
pubKeys = [pubKeys];
}
pubKeys = pubKeys.map(key => `@${key}`);
const users = await this.getUsers(pubKeys);
const validUsers = users.filter(user => !!user.id);
const results = await Promise.all(
validUsers.map(async user => {
const res = await this.serverRequest(`loki/v1/moderators/${user.id}`, {
method: 'DELETE',
});
return !!(!res.err && res.response && res.response.data);
})
);
const anyFailures = results.some(test => !test);
return anyFailures ? results : true; // return failures or total success
}
async getSubscribers(channelId, wantObjects) {
if (!channelId) {
log.warn('No channelId provided to getSubscribers!');
@ -714,9 +626,11 @@ class LokiAppDotNetServerAPI {
}
}
// functions to a specific ADN channel on an ADN server
class LokiPublicChannelAPI {
constructor(serverAPI, channelId, conversationId) {
constructor(chatAPI, serverAPI, channelId, conversationId) {
// properties
this.chatAPI = chatAPI;
this.serverAPI = serverAPI;
this.channelId = channelId;
this.baseChannelUrl = `channels/${this.channelId}`;
@ -727,6 +641,7 @@ class LokiPublicChannelAPI {
this.deleteLastId = 1;
this.timers = {};
this.running = true;
this.myPrivateKey = false;
// can escalated to SQL if it start uses too much memory
this.logMop = {};
@ -735,7 +650,11 @@ class LokiPublicChannelAPI {
// end properties
log.info(`registered LokiPublicChannel ${channelId}`);
log.info(
`registered LokiPublicChannel ${channelId} on ${
this.serverAPI.baseServerUrl
}`
);
// start polling
this.pollForMessages();
this.pollForDeletions();
@ -745,6 +664,14 @@ class LokiPublicChannelAPI {
// TODO: poll for group members here?
}
async getPrivateKey() {
if (!this.myPrivateKey) {
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
this.myPrivateKey = myKeyPair.privKey;
}
return this.myPrivateKey;
}
async banUser(pubkey) {
const res = await this.serverRequest(
`loki/v1/moderation/blacklist/@${pubkey}`,
@ -787,6 +714,10 @@ class LokiPublicChannelAPI {
return this.serverAPI.getSubscribers(this.channelId, true);
}
getModerators() {
return this.serverAPI.getModerators(this.channelId);
}
// get moderation actions
async pollForModerators() {
try {
@ -805,8 +736,9 @@ class LokiPublicChannelAPI {
async pollOnceForModerators() {
// get moderator status
const res = await this.serverRequest(
`loki/v1/channel/${this.channelId}/get_moderators`
`loki/v1/channels/${this.channelId}/moderators`
);
// FIXME: should this be window.storage.get('primaryDevicePubKey')?
const ourNumber = textsecure.storage.user.getNumber();
// Get the list of moderators if no errors occurred
@ -820,6 +752,70 @@ class LokiPublicChannelAPI {
await this.conversation.setModerators(moderators || []);
}
async setChannelSettings(settings) {
if (!this.modStatus) {
// need moderator access to set this
log.warn('Need moderator access to setChannelName');
return false;
}
// racy!
const res = await this.serverRequest(this.baseChannelUrl, {
params: { include_annotations: 1 },
});
if (res.err) {
// state unknown
log.warn(`public chat channel state unknown, skipping set: ${res.err}`);
return false;
}
let notes =
res.response && res.response.data && res.response.data.annotations;
if (!notes) {
// ok if nothing is set yet
notes = [];
}
let settingNotes = notes.filter(
note => note.type === SETTINGS_CHANNEL_ANNOTATION_TYPE
);
if (!settingNotes) {
// default name, description, avatar
settingNotes = [
{
type: SETTINGS_CHANNEL_ANNOTATION_TYPE,
value: {
name: 'Your Public Chat',
description: 'Your public chat room',
avatar: 'images/group_default.png',
},
},
];
}
// update settings
settingNotes[0].value = Object.assign(settingNotes[0].value, settings);
// commit settings
const updateRes = await this.serverRequest(
`loki/v1/${this.baseChannelUrl}`,
{ method: 'PUT', objBody: { annotations: settingNotes } }
);
if (updateRes.err || !updateRes.response || !updateRes.response.data) {
if (updateRes.err) {
log.error(`Error ${updateRes.err}`);
}
return false;
}
return true;
}
// Do we need this? They definitely make it more clear...
setChannelName(name) {
return this.setChannelSettings({ name });
}
setChannelDescription(description) {
return this.setChannelSettings({ description });
}
setChannelAvatar(avatar) {
return this.setChannelSettings({ avatar });
}
// delete messages on the server
async deleteMessages(serverIds, canThrow = false) {
const res = await this.serverRequest(
@ -893,24 +889,33 @@ class LokiPublicChannelAPI {
include_annotations: 1,
},
});
if (
!res.err &&
res.response &&
res.response.data.annotations &&
res.response.data.annotations.length
) {
res.response.data.annotations.forEach(note => {
if (note.type === 'net.patter-app.settings') {
// note.value.description only needed for directory
if (note.value && note.value.name) {
this.conversation.setGroupName(note.value.name);
}
if (note.value && note.value.avatar) {
this.conversation.setProfileAvatar(note.value.avatar);
}
// else could set a default in case of server problems...
}
});
if (res.err || !res.response || !res.response.data) {
return;
}
const { data } = res.response;
if (data.annotations && data.annotations.length) {
// get our setting note
const settingNotes = data.annotations.filter(
note => note.type === SETTINGS_CHANNEL_ANNOTATION_TYPE
);
const note = settingNotes && settingNotes.length ? settingNotes[0] : {};
// setting_note.value.description only needed for directory
if (note.value && note.value.name) {
this.conversation.setGroupName(note.value.name);
}
if (note.value && note.value.avatar) {
this.conversation.setProfileAvatar(note.value.avatar);
}
// is it mutable?
// who are the moderators?
// else could set a default in case of server problems...
}
if (data.counts && Number.isInteger(data.counts.subscribers)) {
this.conversation.setSubscriberCount(data.counts.subscribers);
}
}
@ -1132,6 +1137,7 @@ class LokiPublicChannelAPI {
let pendingMessages = [];
// get our profile name
// FIXME: should this be window.storage.get('primaryDevicePubKey')?
const ourNumber = textsecure.storage.user.getNumber();
let lastProfileName = false;
@ -1314,7 +1320,7 @@ class LokiPublicChannelAPI {
);
// process primary devices' message directly
primaryMessages.forEach(message =>
this.serverAPI.chatAPI.emit('publicMessage', {
this.chatAPI.emit('publicMessage', {
message,
})
);
@ -1350,7 +1356,9 @@ class LokiPublicChannelAPI {
/* eslint-enable no-param-reassign */
// process remaining messages
Object.keys(slaveMessages).forEach(slaveKey => {
slaveMessages.forEach(messageData => {
const slaveKey = messageData.source;
// prevent our own device sent messages from coming back in
if (slaveKey === ourNumber) {
// we originally sent these
@ -1361,21 +1369,18 @@ class LokiPublicChannelAPI {
const primaryPubKey = slavePrimaryMap[slaveKey];
// send out remaining messages for this merged identity
slaveMessages[slaveKey].forEach(messageDataP => {
const messageData = messageDataP; // for linter
if (slavePrimaryMap[messageData.source]) {
// rewrite source, profile
messageData.source = primaryPubKey;
const { name, avatar, profileKey } = this.primaryUserProfileName[
primaryPubKey
];
messageData.message.profile.displayName = name;
messageData.message.profile.avatar = avatar;
messageData.message.profileKey = profileKey;
}
this.serverAPI.chatAPI.emit('publicMessage', {
message: messageData,
});
if (slavePrimaryMap[slaveKey]) {
// rewrite source, profile
messageData.source = primaryPubKey;
const { name, avatar, profileKey } = this.primaryUserProfileName[
primaryPubKey
];
messageData.message.profile.displayName = name;
messageData.message.profile.avatar = avatar;
messageData.message.profileKey = profileKey;
}
this.chatAPI.emit('publicMessage', {
message: messageData,
});
});
@ -1518,7 +1523,7 @@ class LokiPublicChannelAPI {
}
}
}
const privKey = await this.serverAPI.chatAPI.getPrivateKey();
const privKey = await this.getPrivateKey();
const sigVer = 1;
const mockAdnMessage = { text };
if (payload.reply_to) {
@ -1545,6 +1550,16 @@ class LokiPublicChannelAPI {
window.mixpanel.track('Public Message Sent');
return res.response.data.id;
}
if (res.err) {
log.error(`POST ${this.baseChannelUrl}/messages failed`);
if (res.response && res.response.meta && res.response.meta.code === 401) {
log.error(`Got invalid token for ${this.serverAPI.token}`);
}
log.error(res.err);
log.error(res.response);
} else {
log.warn(res.response);
}
// there's no retry on desktop
// this is supposed to be after retries
window.mixpanel.track('Failed to Send Public Message');
@ -1552,4 +1567,4 @@ class LokiPublicChannelAPI {
}
}
module.exports = LokiAppDotNetAPI;
module.exports = LokiAppDotNetServerAPI;

@ -13,20 +13,19 @@ const DEVICE_MAPPING_USER_ANNOTATION_TYPE =
class LokiFileServerInstance {
constructor(ourKey) {
this.ourKey = ourKey;
// why don't we extend this?
this._adnApi = new LokiAppDotNetAPI(ourKey);
this.avatarMap = {};
}
// FIXME: this is not file-server specific
// and is currently called by LokiAppDotNetAPI.
// LokiAppDotNetAPI (base) should not know about LokiFileServer.
async establishConnection(serverUrl) {
// FIXME: we don't always need a token...
this._server = await this._adnApi.findOrCreateServer(serverUrl);
// why don't we extend this?
this._server = new LokiAppDotNetAPI(this.ourKey, serverUrl);
// get a token for multidevice
const gotToken = await this._server.getOrRefreshServerToken();
// TODO: Handle this failure gracefully
if (!this._server) {
log.error('Failed to establish connection to file server');
if (!gotToken) {
log.error('You are blacklisted form this home server');
}
}
async getUserDeviceMapping(pubKey) {
@ -45,10 +44,6 @@ class LokiFileServerInstance {
await Promise.all(
users.map(async user => {
let found = false;
// if this user has an avatar set, copy it into the map
this.avatarMap[user.username] = user.avatar_image
? user.avatar_image.url
: false;
if (!user.annotations || !user.annotations.length) {
log.info(
`verifyUserObjectDeviceMap no annotation for ${user.username}`

@ -1,5 +1,159 @@
/* global log, window */
const EventEmitter = require('events');
const nodeFetch = require('node-fetch');
const LokiAppDotNetAPI = require('./loki_app_dot_net_api');
class LokiPublicChatAPI extends LokiAppDotNetAPI {}
class LokiPublicChatFactoryAPI extends EventEmitter {
constructor(ourKey) {
super();
this.ourKey = ourKey;
this.servers = [];
this.allMembers = [];
// Multidevice states
this.primaryUserProfileName = {};
}
module.exports = LokiPublicChatAPI;
async close() {
await Promise.all(this.servers.map(server => server.close()));
}
// server getter/factory
async findOrCreateServer(serverUrl) {
let thisServer = this.servers.find(
server => server.baseServerUrl === serverUrl
);
if (!thisServer) {
log.info(`LokiAppDotNetAPI creating ${serverUrl}`);
thisServer = new LokiAppDotNetAPI(this.ourKey, serverUrl);
const gotToken = await thisServer.getOrRefreshServerToken();
if (!gotToken) {
log.warn(`Invalid server ${serverUrl}`);
return null;
}
log.info(`set token ${thisServer.token} for ${serverUrl}`);
this.servers.push(thisServer);
}
return thisServer;
}
static async getServerTime() {
const url = `${window.getDefaultFileServer()}/loki/v1/time`;
let timestamp = NaN;
try {
const res = await nodeFetch(url);
if (res.ok) {
timestamp = await res.text();
}
} catch (e) {
return timestamp;
}
return Number(timestamp);
}
static async getTimeDifferential() {
// Get time differential between server and client in seconds
const serverTime = await this.getServerTime();
const clientTime = Math.ceil(Date.now() / 1000);
if (Number.isNaN(serverTime)) {
return 0;
}
return serverTime - clientTime;
}
static async setClockParams() {
// Set server-client time difference
const maxTimeDifferential = 30;
const timeDifferential = await this.getTimeDifferential();
window.clientClockSynced = Math.abs(timeDifferential) < maxTimeDifferential;
return window.clientClockSynced;
}
// channel getter/factory
async findOrCreateChannel(serverUrl, channelId, conversationId) {
const server = await this.findOrCreateServer(serverUrl);
if (!server) {
log.error(`Failed to create server for: ${serverUrl}`);
return null;
}
return server.findOrCreateChannel(this, channelId, conversationId);
}
// deallocate resources server uses
unregisterChannel(serverUrl, channelId) {
const i = this.servers.findIndex(
server => server.baseServerUrl === serverUrl
);
if (i === -1) {
log.warn(`Tried to unregister from nonexistent server ${serverUrl}`);
return;
}
const thisServer = this.servers[i];
if (!thisServer) {
log.warn(`Tried to unregister from nonexistent server ${i}`);
return;
}
thisServer.unregisterChannel(channelId);
this.servers.splice(i, 1);
}
// shouldn't this be scoped per conversation?
async getListOfMembers() {
// enable in the next release
/*
let members = [];
await Promise.all(this.servers.map(async server => {
await Promise.all(server.channels.map(async channel => {
const newMembers = await channel.getSubscribers();
members = [...members, ...newMembers];
}));
}));
const results = members.map(member => {
return { authorPhoneNumber: member.username };
});
*/
return this.allMembers;
}
// TODO: make this private (or remove altogether) when
// we switch to polling the server for group members
setListOfMembers(members) {
this.allMembers = members;
}
async setProfileName(profileName) {
await Promise.all(
this.servers.map(async server => {
await server.setProfileName(profileName);
})
);
}
async setHomeServer(homeServer) {
await Promise.all(
this.servers.map(async server => {
// this may fail
// but we can't create a sql table to remember to retry forever
// I think we just silently fail for now
await server.setHomeServer(homeServer);
})
);
}
async setAvatar(url, profileKey) {
await Promise.all(
this.servers.map(async server => {
// this may fail
// but we can't create a sql table to remember to retry forever
// I think we just silently fail for now
await server.setAvatar(url, profileKey);
})
);
}
}
module.exports = LokiPublicChatFactoryAPI;

@ -55,12 +55,14 @@ class LokiRssAPI extends EventEmitter {
async getFeed() {
let response;
let success = true;
try {
response = await nodeFetch(this.feedUrl);
} catch (e) {
log.error('fetcherror', e);
success = false;
return;
}
if (!response) {
return;
}
const responseXML = await response.text();
let feedDOM = {};
@ -71,9 +73,6 @@ class LokiRssAPI extends EventEmitter {
);
} catch (e) {
log.error('xmlerror', e);
success = false;
}
if (!success) {
return;
}
const feedObj = xml2json(feedDOM);

@ -98,6 +98,14 @@ const {
const {
InviteFriendsDialog,
} = require('../../ts/components/conversation/InviteFriendsDialog');
const {
AddModeratorsDialog,
} = require('../../ts/components/conversation/ModeratorsAddDialog');
const {
RemoveModeratorsDialog,
} = require('../../ts/components/conversation/ModeratorsRemoveDialog');
const {
GroupInvitation,
} = require('../../ts/components/conversation/GroupInvitation');
@ -287,6 +295,8 @@ exports.setup = (options = {}) => {
ConfirmDialog,
UpdateGroupDialog,
InviteFriendsDialog,
AddModeratorsDialog,
RemoveModeratorsDialog,
GroupInvitation,
BulkEdit,
SessionToast,

@ -25,7 +25,7 @@
},
applyTheme() {
const iOS = storage.get('userAgent') === 'OWI';
const theme = storage.get('theme-setting') || 'dark';
const theme = 'dark'; // storage.get('theme-setting') || 'dark';
this.$el
.removeClass('light-theme')
.removeClass('dark-theme')
@ -38,7 +38,7 @@
}
},
applyHideMenu() {
const hideMenuBar = storage.get('hide-menu-bar', false);
const hideMenuBar = storage.get('hide-menu-bar', true);
window.setAutoHideMenuBar(hideMenuBar);
window.setMenuBarVisibility(!hideMenuBar);
},
@ -224,7 +224,7 @@
},
showCreateGroup() {
// TODO: make it impossible to open 2 dialogs as once
// Curretnly, if the button is in focus, it is possible to
// Currently, if the button is in focus, it is possible to
// create a new dialog by pressing 'Enter'
const dialog = new Whisper.CreateGroupDialogView();
this.el.append(dialog.el);
@ -256,5 +256,13 @@
const dialog = new Whisper.InviteFriendsDialogView(groupConvo);
this.el.append(dialog.el);
},
showAddModeratorsDialog(groupConvo) {
const dialog = new Whisper.AddModeratorsDialogView(groupConvo);
this.el.append(dialog.el);
},
showRemoveModeratorsDialog(groupConvo) {
const dialog = new Whisper.RemoveModeratorsDialogView(groupConvo);
this.el.append(dialog.el);
},
});
})();

@ -1,4 +1,4 @@
/* global Whisper, i18n, lokiPublicChatAPI, ConversationController, friends */
/* global Whisper, i18n, ConversationController, friends */
// eslint-disable-next-line func-names
(function() {
@ -36,23 +36,19 @@
return this.resolveWith({ errorCode: i18n('publicChatExists') });
}
const serverAPI = await lokiPublicChatAPI.findOrCreateServer(
sslServerUrl
);
if (!serverAPI) {
// Url incorrect or server not compatible
return this.resolveWith({ errorCode: i18n('connectToServerFail') });
}
// create conversation
const conversation = await ConversationController.getOrCreateAndWait(
conversationId,
'group'
);
await serverAPI.findOrCreateChannel(channelId, conversationId);
// convert conversation to a public one
await conversation.setPublicSource(sslServerUrl, channelId);
// set friend and appropriate SYNC messages for multidevice
await conversation.setFriendRequestStatus(
friends.friendRequestStatusEnum.friends
);
// and finally activate it
conversation.getPublicSendData(); // may want "await" if you want to use the API
return this.resolveWith({ conversation });
},
resolveWith(result) {

@ -185,7 +185,11 @@
isArchived: this.model.get('isArchived'),
isPublic: this.model.isPublic(),
isRss: this.model.isRss(),
amMod: this.model.isModerator(
window.storage.get('primaryDevicePubKey')
),
members,
subscriberCount: this.model.get('subscriberCount'),
selectedMessages: this.model.selectedMessages,
expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length),
@ -255,6 +259,14 @@
window.Whisper.events.trigger('inviteFriends', this.model);
},
onAddModerators: () => {
window.Whisper.events.trigger('addModerators', this.model);
},
onRemoveModerators: () => {
window.Whisper.events.trigger('removeModerators', this.model);
},
onAvatarClick: pubkey => {
if (this.model.isPrivate()) {
window.Whisper.events.trigger('onShowUserDetails', {
@ -312,7 +324,7 @@
this.titleView = new Whisper.ReactWrapperView({
className: 'title-wrapper',
Component: window.Signal.Components.ConversationHeader,
props: getHeaderProps(this.model),
props: getHeaderProps(),
});
this.updateHeader = () => this.titleView.update(getHeaderProps());
this.listenTo(this.model, 'change', this.updateHeader);

@ -58,6 +58,7 @@
this.cancelText = i18n('cancel');
this.close = this.close.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.isPublic = groupConvo.isPublic();
const ourPK = textsecure.storage.user.getNumber();
@ -74,10 +75,31 @@
this.friendList = allMembers;
// only give members that are not already in the group
const existingMembers = groupConvo.get('members');
let existingMembers = groupConvo.get('members');
// at least make sure it's an array
if (!Array.isArray(existingMembers)) {
existingMembers = [];
}
this.existingMembers = existingMembers;
// public chat settings overrides
if (this.isPublic) {
// fix the title
this.titleText = `${i18n('updatePublicGroupDialogTitle')}: ${
this.groupName
}`;
// I'd much prefer to integrate mods with groupAdmins
// but lets discuss first...
this.isAdmin = groupConvo.isModerator(
window.storage.get('primaryDevicePubKey')
);
// zero out friendList for now
this.friendList = [];
this.existingMembers = [];
}
this.$el.focus();
this.render();
},
@ -89,6 +111,7 @@
titleText: this.titleText,
groupName: this.groupName,
okText: this.okText,
isPublic: this.isPublic,
cancelText: this.cancelText,
existingMembers: this.existingMembers,
friendList: this.friendList,

@ -0,0 +1,66 @@
/* global Whisper, log */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.AddModeratorsDialogView = Whisper.View.extend({
className: 'loki-dialog modal',
async initialize(convo) {
this.close = this.close.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.chatName = convo.get('name');
this.chatServer = convo.get('server');
this.channelId = convo.get('channelId');
// get current list of moderators
this.channelAPI = await convo.getPublicSendData();
const modPubKeys = await this.channelAPI.getModerators();
const convos = window.getConversations().models;
// private friends (not you) that aren't already moderators
const friends = convos.filter(
d =>
!!d &&
d.isFriend() &&
d.isPrivate() &&
!d.isMe() &&
!modPubKeys.includes(d.id)
);
this.friends = friends;
this.$el.focus();
this.render();
},
render() {
const view = new Whisper.ReactWrapperView({
className: 'add-moderators-dialog',
Component: window.Signal.Components.AddModeratorsDialog,
props: {
friendList: this.friends,
chatName: this.chatName,
onSubmit: this.onSubmit,
onClose: this.close,
},
});
this.$el.append(view.el);
return this;
},
close() {
this.remove();
},
async onSubmit(pubKeys) {
log.info(`asked to add ${pubKeys}`);
const res = await this.channelAPI.serverAPI.addModerators(pubKeys);
if (res !== true) {
// we have errors, deal with them...
// how?
}
},
});
})();

@ -0,0 +1,64 @@
/* global Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.RemoveModeratorsDialogView = Whisper.View.extend({
className: 'loki-dialog modal',
async initialize(convo) {
this.close = this.close.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.chatName = convo.get('name');
this.chatServer = convo.get('server');
this.channelId = convo.get('channelId');
// get current list of moderators
this.channelAPI = await convo.getPublicSendData();
const modPubKeys = await this.channelAPI.getModerators();
const convos = window.getConversations().models;
const moderators = modPubKeys
.map(
pubKey =>
convos.find(c => c.id === pubKey) || {
id: pubKey, // memberList need a key
authorPhoneNumber: pubKey,
}
)
.filter(c => !!c);
this.mods = moderators;
this.$el.focus();
this.render();
},
render() {
const view = new Whisper.ReactWrapperView({
className: 'remove-moderators-dialog',
Component: window.Signal.Components.RemoveModeratorsDialog,
props: {
modList: this.mods,
onSubmit: this.onSubmit,
onClose: this.close,
chatName: this.chatName,
},
});
this.$el.append(view.el);
return this;
},
close() {
this.remove();
},
async onSubmit(pubKeys) {
const res = await this.channelAPI.serverAPI.removeModerators(pubKeys);
if (res !== true) {
// we have errors, deal with them...
// how?
}
},
});
})();

@ -226,9 +226,11 @@
// Send
const options = { messageType: 'pairing-request' };
const p = new Promise((resolve, reject) => {
const timestamp = Date.now();
const outgoingMessage = new textsecure.OutgoingMessage(
null, // server
Date.now(), // timestamp,
timestamp,
[recipientPubKey], // numbers
content, // message
true, // silent

@ -1061,8 +1061,9 @@ MessageReceiver.prototype.extend({
const primaryConversation = ConversationController.get(
primaryDevicePubKey
);
//
this.updateProfile(primaryConversation, profile, profileKey);
if (profile) {
this.updateProfile(primaryConversation, profile, profileKey);
}
}
const ev = new Event('sent');

@ -145,17 +145,17 @@ Message.prototype = {
proto.profileKey = this.profileKey;
}
// Only send the display name for now.
// In the future we might want to extend this to send other things.
if (this.profile && this.profile.displayName) {
// Set the loki profile
if (this.profile) {
const profile = new textsecure.protobuf.DataMessage.LokiProfile();
profile.displayName = this.profile.displayName;
if (this.profile.displayName) {
profile.displayName = this.profile.displayName;
}
const conversation = window.ConversationController.get(
textsecure.storage.user.getNumber()
);
const avatarPointer = conversation.get('avatarPointer');
if (avatarPointer) {
profile.avatar = avatarPointer;
}
@ -193,11 +193,8 @@ MessageSender.prototype = {
constructor: MessageSender,
// makeAttachmentPointer :: Attachment -> Promise AttachmentPointerProto
async makeAttachmentPointer(
attachment,
publicServer = null,
{ isRaw = false, isAvatar = false }
) {
async makeAttachmentPointer(attachment, publicServer = null, options = {}) {
const { isRaw = false, isAvatar = false } = options;
if (typeof attachment !== 'object' || attachment == null) {
return Promise.resolve(undefined);
}

@ -187,8 +187,8 @@ function captureClicks(window) {
const DEFAULT_WIDTH = 800;
const DEFAULT_HEIGHT = 710;
const MIN_WIDTH = 640;
const MIN_HEIGHT = 360;
const MIN_WIDTH = 1125;
const MIN_HEIGHT = 750;
const BOUNDS_BUFFER = 100;
function isVisible(window, bounds) {

@ -1,9 +1,9 @@
{
"name": "loki-messenger-desktop",
"name": "session-messenger-desktop",
"productName": "Session Messenger",
"description": "Private messaging from your desktop",
"repository": "https://github.com/loki-project/loki-messenger.git",
"version": "1.0.0-beta9",
"version": "1.0.0-beta10",
"license": "GPL-3.0",
"author": {
"name": "Loki Project",
@ -13,15 +13,15 @@
"scripts": {
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
"start": "electron .",
"start-multi": "NODE_APP_INSTANCE=1 electron .",
"start-multi2": "NODE_APP_INSTANCE=2 electron .",
"start-prod": "NODE_ENV=production NODE_APP_INSTANCE=devprod LOKI_DEV=1 electron .",
"start-prod-multi": "NODE_ENV=production NODE_APP_INSTANCE=devprod1 LOKI_DEV=1 electron .",
"start-multi": "cross-env NODE_APP_INSTANCE=1 electron .",
"start-multi2": "cross-env NODE_APP_INSTANCE=2 electron .",
"start-prod": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod LOKI_DEV=1 electron .",
"start-prod-multi": "cross-env NODE_ENV=production NODE_APP_INSTANCE=devprod1 LOKI_DEV=1 electron .",
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt",
"build": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV",
"build-release": "export SIGNAL_ENV=production && npm run build -- --config.directories.output=release",
"build-release": "cross-env SIGNAL_ENV=production npm run build -- --config.directories.output=release",
"make:linux:x64:appimage": "electron-builder build --linux appimage --x64",
"sign-release": "node ts/updater/generateSignature.js",
"build-module-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
@ -67,6 +67,7 @@
"classnames": "2.2.5",
"color": "^3.1.2",
"config": "1.28.1",
"cross-env": "^6.0.3",
"dompurify": "^2.0.7",
"electron-context-menu": "^0.15.0",
"electron-editor-context-menu": "1.1.1",

@ -29,7 +29,14 @@
margin-left: 10px;
}
/* remove scroll bars */
.loki-dialog .add-moderators-dialog .content {
padding: 1.1em;
}
.invite-friends-dialog,
.add-moderators-dialog,
.remove-moderators-dialog,
.create-group-dialog {
.content {
max-width: 100% !important;
@ -50,6 +57,8 @@
}
.create-group-dialog,
.add-moderators-dialog,
.remove-moderators-dialog,
.invite-friends-dialog {
.no-friends {
text-align: center;
@ -61,6 +70,8 @@
}
.create-group-dialog,
.add-moderators-dialog,
.remove-moderators-dialog,
.edit-profile-dialog {
.error-message {
text-align: center;
@ -129,6 +140,8 @@
.member-list-container,
.create-group-dialog,
.add-moderators-dialog,
.remove-moderators-dialog,
.invite-friends-dialog {
.member-item {
padding: 4px;

@ -2196,6 +2196,27 @@
width: 42px;
}
.module-avatar--300 {
height: 300px;
width: 300px;
img {
height: 300px;
width: 300px;
}
}
.module-avatar__label--300 {
width: 300px;
font-size: 150px;
line-height: 302px;
}
.module-avatar__icon--300 {
height: 158px;
width: 158px;
}
.module-avatar__icon--note-to-self {
width: 70%;
height: 70%;

@ -645,10 +645,6 @@ label {
.session-modal {
animation: fadein $session-transition-duration;
z-index: 150;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
min-width: 300px;
box-sizing: border-box;
@ -940,19 +936,6 @@ label {
}
}
.qr-image {
display: flex;
justify-content: center;
svg {
width: 135px;
height: 135px;
border-radius: 3px;
padding: $session-margin-xs;
background-color: $session-color-white;
}
}
.session-id-section {
display: flex;
align-items: center;
@ -1008,6 +991,19 @@ label {
}
}
.qr-image {
display: flex;
justify-content: center;
svg {
width: 135px;
height: 135px;
border-radius: 3px;
padding: $session-margin-xs;
background-color: $session-color-white;
}
}
.conversation-loader {
position: absolute;
top: 50%;
@ -1246,16 +1242,29 @@ label {
}
}
.bottom-bar textarea.send-message {
height: 60px;
background-color: $session-shade-4;
border: none;
margin: 0px;
padding: 0px $session-margin-lg;
font-size: $session-font-md;
line-height: 60px;
.bottom-bar {
.compose {
min-height: 60px;
max-height: 200px;
}
.send-message-container {
display: flex;
flex-grow: 1;
min-height: 60px;
}
textarea.send-message {
background-color: $session-shade-4;
border: none;
margin: 0px;
padding: 0px $session-margin-lg;
font-size: $session-font-md;
line-height: 1.3em;
align-self: center;
@include session-color-subtle($session-color-white);
@include session-color-subtle($session-color-white);
}
}
.bottom-bar form {
@ -1380,6 +1389,17 @@ input {
}
}
.session-confirm-wrapper {
position: absolute;
height: 100%;
width: 100%;
display: flex;
.session-modal {
margin: auto auto;
}
}
.module-scroll-down {
animation: fadein $session-fadein-duration;
bottom: 15px;

@ -36,6 +36,7 @@
width: inherit;
color: $session-color-white;
text-align: center;
display: none;
}
&-item {
@ -78,7 +79,8 @@
}
.module-attachment-section__items {
justify-content: space-evenly;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.module-media {
@ -116,11 +118,11 @@
.module-media-grid-item {
height: calc(
22vw / 3.5
); //.group-settings is 22vw and we want threee rows with some space so divide it by 3.5
); //.group-settings is 22vw and we want three rows with some space so divide it by 3.5
width: calc(
22vw / 3.5
); //.group-settings is 22vw and we want threee rows with some space so divide it by 3.5
margin-right: 0;
); //.group-settings is 22vw and we want three rows with some space so divide it by 3.5
margin: auto;
}
}
}

@ -178,6 +178,7 @@ $session-compose-margin: 20px;
display: flex;
flex-direction: row;
margin: 15px 7px 14px 0px;
height: 33px;
@at-root .light-theme #{&} {
background-color: $session-color-white;

@ -9,11 +9,6 @@
{
"type": "separator"
},
{
"label": "Preferences…",
"accelerator": "CommandOrControl+,",
"click": null
},
{
"type": "separator"
},

@ -9,11 +9,6 @@
{
"type": "separator"
},
{
"label": "Preferences…",
"accelerator": "CommandOrControl+,",
"click": null
},
{
"type": "separator"
},

@ -13,10 +13,6 @@
{
"type": "separator"
},
{
"label": "Preferences…",
"click": null
},
{
"type": "separator"
},

@ -2,10 +2,6 @@
{
"label": "&File",
"submenu": [
{
"label": "Preferences…",
"click": null
},
{
"type": "separator"
},

@ -551,6 +551,8 @@
<script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/clear_data_view.js'></script>
<script type='text/javascript' src='../js/views/conversation_loading_view.js'></script>
<script type='text/javascript' src='../js/views/create_group_dialog_view.js'></script>
<script type='text/javascript' src='../js/views/confirm_session_reset_view.js'></script>
<script type='text/javascript' src='../js/views/invite_friends_dialog_view.js'></script>

@ -161,7 +161,13 @@ export class Avatar extends React.PureComponent<Props, State> {
const hasAvatar = avatarPath || conversationType === 'direct';
const hasImage = !noteToSelf && hasAvatar && !imageBroken;
if (size !== 28 && size !== 36 && size !== 48 && size !== 80) {
if (
size !== 28 &&
size !== 36 &&
size !== 48 &&
size !== 80 &&
size !== 300
) {
throw new Error(`Size ${size} is not supported!`);
}

@ -1,7 +1,7 @@
import React from 'react';
const styles = {
borderRadius: '50px',
borderRadius: '50%',
display: 'inline-block',
margin: 0,
overflow: 'hidden',

@ -20,7 +20,11 @@ interface Props {
onStartConversation: any;
}
export class UserDetailsDialog extends React.Component<Props> {
interface State {
isEnlargedImageShown: boolean;
}
export class UserDetailsDialog extends React.Component<Props, State> {
constructor(props: any) {
super(props);
@ -28,6 +32,7 @@ export class UserDetailsDialog extends React.Component<Props> {
this.onKeyUp = this.onKeyUp.bind(this);
this.onClickStartConversation = this.onClickStartConversation.bind(this);
window.addEventListener('keyup', this.onKeyUp);
this.state = { isEnlargedImageShown: false };
}
public render() {
@ -61,6 +66,7 @@ export class UserDetailsDialog extends React.Component<Props> {
private renderAvatar() {
const avatarPath = this.props.avatarPath;
const color = this.props.avatarColor;
const size = this.state.isEnlargedImageShown ? 300 : 80;
return (
<Avatar
@ -71,11 +77,17 @@ export class UserDetailsDialog extends React.Component<Props> {
name={this.props.profileName}
phoneNumber={this.props.pubkey}
profileName={this.props.profileName}
size={80}
size={size}
onAvatarClick={this.handleShowEnlargedDialog}
borderWidth={size / 2}
/>
);
}
private readonly handleShowEnlargedDialog = () => {
this.setState({ isEnlargedImageShown: !this.state.isEnlargedImageShown });
};
private onKeyUp(event: any) {
switch (event.key) {
case 'Enter':

@ -31,12 +31,12 @@ class Mention extends React.Component<MentionProps, MentionState> {
}
public componentWillMount() {
const found = this.findMember(this.props.text.slice(1));
this.setState({ found });
this.setState({ found: false });
this.tryRenameMention();
// TODO: give up after some period of time?
this.intervalHandle = setInterval(this.tryRenameMention, 30000);
this.tryRenameMention().ignore();
}
public componentWillUnmount() {
@ -71,8 +71,8 @@ class Mention extends React.Component<MentionProps, MentionState> {
clearInterval(this.intervalHandle);
}
private tryRenameMention() {
const found = this.findMember(this.props.text.slice(1));
private async tryRenameMention() {
const found = await this.findMember(this.props.text.slice(1));
if (found) {
this.setState({ found });
this.clearOurInterval();

@ -42,9 +42,16 @@ interface Props {
isArchived: boolean;
isPublic: boolean;
isRss: boolean;
amMod: boolean;
// We might not always have the full list of members,
// e.g. for open groups where we could have thousands
// of members. We'll keep this for now (for closed chats)
members: Array<any>;
// not equal members.length (see above)
subscriberCount?: number;
expirationSettingName?: string;
showBackButton: boolean;
timerOptions: Array<TimerOption>;
@ -83,7 +90,8 @@ interface Props {
onUpdateGroup: () => void;
onLeaveGroup: () => void;
onAddModerators: () => void;
onRemoveModerators: () => void;
onInviteFriends: () => void;
onAvatarClick?: (userPubKey: string) => void;
@ -132,8 +140,10 @@ export class ConversationHeader extends React.Component<Props> {
profileName,
isFriend,
isGroup,
isPublic,
isRss,
members,
subscriberCount,
isFriendRequestPending,
isMe,
name,
@ -147,13 +157,25 @@ export class ConversationHeader extends React.Component<Props> {
);
}
const memberCount: number = (() => {
if (!isGroup || isRss) {
return 0;
}
if (isPublic) {
return subscriberCount || 0;
} else {
return members.length;
}
})();
let text = '';
if (isFriendRequestPending) {
text = i18n('pendingAcceptance');
} else if (!isFriend && !isGroup) {
text = i18n('notFriends');
} else if (isGroup && !isRss && members.length > 0) {
const count = String(members.length);
} else if (memberCount > 0) {
const count = String(memberCount);
text = i18n('members', [count]);
}
@ -277,16 +299,20 @@ export class ConversationHeader extends React.Component<Props> {
isMe,
isClosable,
isPublic,
isRss,
isGroup,
amMod,
onDeleteMessages,
onDeleteContact,
onCopyPublicKey,
onUpdateGroup,
onLeaveGroup,
onAddModerators,
onRemoveModerators,
onInviteFriends,
} = this.props;
const isPrivateGroup = isGroup && !isPublic;
const isPrivateGroup = isGroup && !isPublic && !isRss;
const copyIdLabel = isGroup ? i18n('copyChatId') : i18n('copyPublicKey');
@ -295,9 +321,17 @@ export class ConversationHeader extends React.Component<Props> {
{this.renderPublicMenuItems()}
<MenuItem onClick={onCopyPublicKey}>{copyIdLabel}</MenuItem>
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
{isPrivateGroup ? (
{isPrivateGroup || amMod ? (
<MenuItem onClick={onUpdateGroup}>{i18n('updateGroup')}</MenuItem>
) : null}
{amMod ? (
<MenuItem onClick={onAddModerators}>{i18n('addModerators')}</MenuItem>
) : null}
{amMod ? (
<MenuItem onClick={onRemoveModerators}>
{i18n('removeModerators')}
</MenuItem>
) : null}
{isPrivateGroup ? (
<MenuItem onClick={onLeaveGroup}>{i18n('leaveGroup')}</MenuItem>
) : null}
@ -347,7 +381,7 @@ export class ConversationHeader extends React.Component<Props> {
public render() {
const { id, isGroup, isPublic } = this.props;
const triggerId = `conversation-${id}`;
const triggerId = `conversation-${id}-${Date.now()}`;
const isPrivateGroup = isGroup && !isPublic;
@ -360,6 +394,7 @@ export class ConversationHeader extends React.Component<Props> {
<div className="module-conversation-header__title-flex">
{this.renderOptions(triggerId)}
{this.renderTitle()}
{/* This might be redundant as we show the title in the title: */}
{isPrivateGroup ? this.renderMemberCount() : null}
</div>
</div>
@ -385,7 +420,9 @@ export class ConversationHeader extends React.Component<Props> {
}
private renderMemberCount() {
const memberCount = this.props.members.length;
const memberCount = this.props.isPublic
? this.props.subscriberCount
: this.props.members.length;
if (memberCount === 0) {
return null;
@ -406,6 +443,7 @@ export class ConversationHeader extends React.Component<Props> {
isGroup,
isArchived,
isPublic,
isRss,
onResetSession,
onSetDisappearingMessages,
// onShowAllMedia,
@ -421,7 +459,7 @@ export class ConversationHeader extends React.Component<Props> {
onChangeNickname,
} = this.props;
if (isPublic) {
if (isPublic || isRss) {
return null;
}

@ -1089,7 +1089,7 @@ export class Message extends React.PureComponent<Props, State> {
const divClasses = ['loki-message-wrapper'];
if (shouldHightlight) {
divClasses.push('message-highlighted');
//divClasses.push('message-highlighted');
}
if (selected) {
divClasses.push('message-selected');

@ -0,0 +1,222 @@
import React from 'react';
import { Contact, MemberList } from './MemberList';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
interface Props {
friendList: Array<any>;
chatName: string;
onSubmit: any;
onClose: any;
}
declare global {
interface Window {
i18n: any;
}
}
interface State {
friendList: Array<Contact>;
inputBoxValue: string;
}
export class AddModeratorsDialog extends React.Component<Props, State> {
private readonly updateSearchBound: (
event: React.FormEvent<HTMLInputElement>
) => void;
private readonly inputRef: React.RefObject<HTMLInputElement>;
constructor(props: any) {
super(props);
this.updateSearchBound = this.updateSearch.bind(this);
this.onMemberClicked = this.onMemberClicked.bind(this);
this.add = this.add.bind(this);
this.closeDialog = this.closeDialog.bind(this);
this.onClickOK = this.onClickOK.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.inputRef = React.createRef();
let friends = this.props.friendList;
friends = friends.map(d => {
const lokiProfile = d.getLokiProfile();
const name = lokiProfile ? lokiProfile.displayName : 'Anonymous';
// TODO: should take existing members into account
const existingMember = false;
return {
id: d.id,
authorPhoneNumber: d.id,
authorProfileName: name,
selected: false,
authorName: name,
authorColor: d.getColor(),
checkmarked: false,
existingMember,
};
});
this.state = {
friendList: friends,
inputBoxValue: '',
};
window.addEventListener('keyup', this.onKeyUp);
}
public updateSearch(event: React.FormEvent<HTMLInputElement>) {
const searchTerm = event.currentTarget.value;
const cleanedTerm = cleanSearchTerm(searchTerm);
if (!cleanedTerm) {
return;
}
this.setState(state => {
return {
...state,
inputBoxValue: searchTerm,
};
});
}
public add() {
// if we have valid data
if (this.state.inputBoxValue.length > 64) {
const weHave = this.state.friendList.some(
user => user.authorPhoneNumber === this.state.inputBoxValue
);
if (!weHave) {
// lookup to verify it's registered?
// convert pubKey into local object...
const friends = this.state.friendList;
friends.push({
id: this.state.inputBoxValue,
authorPhoneNumber: this.state.inputBoxValue,
authorProfileName: this.state.inputBoxValue,
authorAvatarPath: '',
selected: true,
authorName: this.state.inputBoxValue,
authorColor: '#000000',
checkmarked: true,
existingMember: false,
});
this.setState(state => {
return {
...state,
friendList: friends,
};
});
}
//
}
// clear
if (this.inputRef.current) {
this.inputRef.current.value = '';
}
this.setState(state => {
return {
...state,
inputBoxValue: '',
};
});
}
public render() {
const i18n = window.i18n;
const hasFriends = this.state.friendList.length !== 0;
return (
<div className="content">
<p className="titleText">
{i18n('addModerators')} <span>{this.props.chatName}</span>
</p>
<div className="addModeratorBox">
<p>Add Moderator:</p>
<input
type="text"
ref={this.inputRef}
className="module-main-header__search__input"
placeholder={i18n('search')}
dir="auto"
onChange={this.updateSearchBound}
/>
<button className="add" tabIndex={0} onClick={this.add}>
{i18n('add')}
</button>
</div>
<div className="moderatorList">
<p>From friends:</p>
<div className="friend-selection-list">
<MemberList
members={this.state.friendList}
selected={{}}
i18n={i18n}
onMemberClicked={this.onMemberClicked}
/>
</div>
{hasFriends ? null : (
<p className="no-friends">{i18n('noFriendsToAdd')}</p>
)}
</div>
<div className="buttons">
<button className="cancel" tabIndex={0} onClick={this.closeDialog}>
{i18n('cancel')}
</button>
<button className="ok" tabIndex={0} onClick={this.onClickOK}>
{i18n('ok')}
</button>
</div>
</div>
);
}
private onClickOK() {
this.add(); // process inputBox
const selectedFriends = this.state.friendList
.filter(d => d.checkmarked)
.map(d => d.id);
if (selectedFriends.length > 0) {
this.props.onSubmit(selectedFriends);
}
this.closeDialog();
}
private onKeyUp(event: any) {
switch (event.key) {
case 'Enter':
this.onClickOK();
break;
case 'Esc':
case 'Escape':
this.closeDialog();
break;
default:
}
}
private closeDialog() {
window.removeEventListener('keyup', this.onKeyUp);
this.props.onClose();
}
private onMemberClicked(selected: any) {
const updatedFriends = this.state.friendList.map(member => {
if (member.id === selected.id) {
return { ...member, checkmarked: !member.checkmarked };
} else {
return member;
}
});
this.setState(state => {
return {
...state,
friendList: updatedFriends,
};
});
}
}

@ -0,0 +1,141 @@
import React from 'react';
import { Contact, MemberList } from './MemberList';
interface Props {
modList: Array<any>;
chatName: string;
onSubmit: any;
onClose: any;
}
declare global {
interface Window {
i18n: any;
}
}
interface State {
modList: Array<Contact>;
}
export class RemoveModeratorsDialog extends React.Component<Props, State> {
constructor(props: any) {
super(props);
this.onModClicked = this.onModClicked.bind(this);
this.closeDialog = this.closeDialog.bind(this);
this.onClickOK = this.onClickOK.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
let mods = this.props.modList;
mods = mods.map(d => {
let name = '';
if (d.getLokiProfile) {
const lokiProfile = d.getLokiProfile();
name = lokiProfile ? lokiProfile.displayName : 'Anonymous';
}
const authorColor = d.getColor ? d.getColor() : '#000000';
// TODO: should take existing members into account
const existingMember = false;
return {
id: d.id,
authorPhoneNumber: d.id,
authorProfileName: name,
selected: false,
authorName: name,
authorColor,
checkmarked: true,
existingMember,
};
});
this.state = {
modList: mods,
};
window.addEventListener('keyup', this.onKeyUp);
}
public render() {
const i18n = window.i18n;
const hasMods = this.state.modList.length !== 0;
return (
<div className="content">
<p className="titleText">
{i18n('removeModerators')} <span>{this.props.chatName}</span>
</p>
<div className="moderatorList">
<p>Existing moderators:</p>
<div className="friend-selection-list">
<MemberList
members={this.state.modList}
selected={{}}
i18n={i18n}
onMemberClicked={this.onModClicked}
/>
</div>
{hasMods ? null : (
<p className="no-friends">{i18n('noModeratorsToRemove')}</p>
)}
</div>
<div className="buttons">
<button className="cancel" tabIndex={0} onClick={this.closeDialog}>
{i18n('cancel')}
</button>
<button className="ok" tabIndex={0} onClick={this.onClickOK}>
{i18n('ok')}
</button>
</div>
</div>
);
}
private onClickOK() {
const removedMods = this.state.modList
.filter(d => !d.checkmarked)
.map(d => d.id);
if (removedMods.length > 0) {
this.props.onSubmit(removedMods);
}
this.closeDialog();
}
private onKeyUp(event: any) {
switch (event.key) {
case 'Enter':
this.onClickOK();
break;
case 'Esc':
case 'Escape':
this.closeDialog();
break;
default:
}
}
private closeDialog() {
window.removeEventListener('keyup', this.onKeyUp);
this.props.onClose();
}
private onModClicked(selected: any) {
const updatedFriends = this.state.modList.map(member => {
if (member.id === selected.id) {
return { ...member, checkmarked: !member.checkmarked };
} else {
return member;
}
});
this.setState(state => {
return {
...state,
modList: updatedFriends,
};
});
}
}

@ -15,6 +15,7 @@ interface Props {
titleText: string;
groupName: string;
okText: string;
isPublic: boolean;
cancelText: string;
// friends not in the group
friendList: Array<any>;
@ -90,15 +91,25 @@ export class UpdateGroupDialog extends React.Component<Props, State> {
public render() {
const checkMarkedCount = this.getMemberCount(this.state.friendList);
const titleText = `${this.props.titleText} (Members: ${checkMarkedCount})`;
const okText = this.props.okText;
const cancelText = this.props.cancelText;
const noFriendsClasses =
this.state.friendList.length === 0
? 'no-friends'
: classNames('no-friends', 'hidden');
let titleText;
let noFriendsClasses;
if (this.props.isPublic) {
// no member count in title
titleText = `${this.props.titleText}`;
// hide the no-friend message
noFriendsClasses = classNames('no-friends', 'hidden');
} else {
// private group
titleText = `${this.props.titleText} (Members: ${checkMarkedCount})`;
noFriendsClasses =
this.state.friendList.length === 0
? 'no-friends'
: classNames('no-friends', 'hidden');
}
const errorMsg = this.state.errorMessage;
const errorMessageClasses = classNames(

@ -170,9 +170,15 @@ export class LeftPaneChannelSection extends React.Component<Props, State> {
return LeftPane.RENDER_HEADER(labels, null);
}
public render(): JSX.Element {
public componentDidMount() {
MainViewController.renderMessageView();
}
public componentDidUpdate() {
MainViewController.renderMessageView();
}
public render(): JSX.Element {
return (
<div className="session-left-pane-section-content">
{this.renderHeader()}
@ -386,9 +392,10 @@ export class LeftPaneChannelSection extends React.Component<Props, State> {
}
private async attemptConnection(serverURL: string, channelId: number) {
const rawserverURL = serverURL
let rawserverURL = serverURL
.replace(/^https?:\/\//i, '')
.replace(/[/\\]+$/i, '');
rawserverURL = rawserverURL.toLowerCase();
const sslServerURL = `https://${rawserverURL}`;
const conversationId = `publicChat:${channelId}@${rawserverURL}`;
@ -417,12 +424,13 @@ export class LeftPaneChannelSection extends React.Component<Props, State> {
'group'
);
await serverAPI.findOrCreateChannel(channelId, conversationId);
await conversation.setPublicSource(sslServerURL, channelId);
await conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
conversation.getPublicSendData(); // may want "await" if you want to use the API
return conversation;
}
}

@ -94,9 +94,15 @@ export class LeftPaneContactSection extends React.Component<Props, State> {
);
}
public render(): JSX.Element {
public componentDidMount() {
MainViewController.renderMessageView();
}
public componentDidUpdate() {
MainViewController.renderMessageView();
}
public render(): JSX.Element {
return (
<div className="left-pane-contact-section">
{this.renderHeader()}

@ -145,6 +145,14 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
return [list];
}
public componentDidMount() {
MainViewController.renderMessageView();
}
public componentDidUpdate() {
MainViewController.renderMessageView();
}
public renderHeader(): JSX.Element {
const labels = [window.i18n('messagesHeader')];
@ -157,8 +165,6 @@ export class LeftPaneMessageSection extends React.Component<Props, any> {
}
public render(): JSX.Element {
MainViewController.renderMessageView();
return (
<div className="session-left-pane-section-content">
{this.renderHeader()}

@ -30,12 +30,17 @@ export class LeftPaneSettingSection extends React.Component<any, State> {
};
this.setCategory = this.setCategory.bind(this);
this.renderRows = this.renderRows.bind(this);
}
public render(): JSX.Element {
public componentDidMount() {
MainViewController.renderSettingsView(this.state.settingCategory);
}
public componentDidUpdate() {
MainViewController.renderSettingsView(this.state.settingCategory);
}
public render(): JSX.Element {
return (
<div className="left-pane-setting-section">
{this.renderHeader()}
@ -56,53 +61,45 @@ export class LeftPaneSettingSection extends React.Component<any, State> {
);
}
public renderRows(): JSX.Element {
const categories = this.getCategories();
public renderRow(item: any): JSX.Element {
return (
<>
{categories.map(item => (
<>
{!item.hidden && (
<div
key={item.id}
className={classNames(
'left-pane-setting-category-list-item',
item.id === this.state.settingCategory ? 'active' : ''
)}
role="link"
onClick={() => {
this.setCategory(item.id);
}}
>
<div>
<strong>{item.title}</strong>
<br />
<span className="text-subtle">{item.description}</span>
</div>
<div>
{item.id === this.state.settingCategory && (
<SessionIcon
iconSize={SessionIconSize.Medium}
iconType={SessionIconType.Chevron}
iconRotation={270}
/>
)}
</div>
</div>
)}
</>
))}
</>
<div
key={item.id}
className={classNames(
'left-pane-setting-category-list-item',
item.id === this.state.settingCategory ? 'active' : ''
)}
role="link"
onClick={() => {
this.setCategory(item.id);
}}
>
<div>
<strong>{item.title}</strong>
<br />
<span className="text-subtle">{item.description}</span>
</div>
<div>
{item.id === this.state.settingCategory && (
<SessionIcon
iconSize={SessionIconSize.Medium}
iconType={SessionIconType.Chevron}
iconRotation={270}
/>
)}
</div>
</div>
);
}
public renderCategories(): JSX.Element {
const categories = this.getCategories().filter(item => !item.hidden);
return (
<div className="module-left-pane__list" key={0}>
<div className="left-pane-setting-category-list">
{this.renderRows()}
{categories.map(item => this.renderRow(item))}
</div>
</div>
);

@ -217,11 +217,11 @@ export class RegistrationTabs extends React.Component<{}, State> {
private onDisplayNameChanged(val: string) {
const sanitizedName = this.sanitiseNameInput(val);
const trimName = sanitizedName.trim();
this.setState({
displayName: sanitizedName,
displayNameError: !sanitizedName
? window.i18n('displayNameEmpty')
: undefined,
displayNameError: !trimName ? window.i18n('displayNameEmpty') : undefined,
});
}
@ -437,8 +437,9 @@ export class RegistrationTabs extends React.Component<{}, State> {
}
private renderNamePasswordAndVerifyPasswordFields() {
const { password, passwordFieldsMatch } = this.state;
const passwordsDoNotMatch =
!this.state.passwordFieldsMatch && this.state.password
!passwordFieldsMatch && this.state.password
? window.i18n('passwordsDoNotMatch')
: undefined;
@ -472,19 +473,21 @@ export class RegistrationTabs extends React.Component<{}, State> {
}}
/>
<SessionInput
label={window.i18n('verifyPassword')}
error={passwordsDoNotMatch}
type="password"
placeholder={window.i18n('optionalPassword')}
maxLength={window.CONSTANTS.MAX_PASSWORD_LENGTH}
onValueChanged={(val: string) => {
this.onPasswordVerifyChanged(val);
}}
onEnterPressed={() => {
this.handlePressEnter();
}}
/>
{!!password && (
<SessionInput
label={window.i18n('verifyPassword')}
error={passwordsDoNotMatch}
type="password"
placeholder={window.i18n('verifyPassword')}
maxLength={window.CONSTANTS.MAX_PASSWORD_LENGTH}
onValueChanged={(val: string) => {
this.onPasswordVerifyChanged(val);
}}
onEnterPressed={() => {
this.handlePressEnter();
}}
/>
)}
</div>
);
}
@ -553,10 +556,7 @@ export class RegistrationTabs extends React.Component<{}, State> {
}
private renderTermsConditionAgreement() {
// FIXME
window.log.info(
'FIXME: add link to our Terms and Conditions and privacy statement'
);
// FIXME add link to our Terms and Conditions and privacy statement
return (
<div className="session-terms-conditions-agreement">
@ -713,7 +713,7 @@ export class RegistrationTabs extends React.Component<{}, State> {
}
private sanitiseNameInput(val: string) {
return val.trim().replace(window.displayNameRegex, '');
return val.replace(window.displayNameRegex, '');
}
private async resetRegistration() {
@ -738,6 +738,19 @@ export class RegistrationTabs extends React.Component<{}, State> {
passwordFieldsMatch,
} = this.state;
// Make sure the password is valid
const trimName = displayName.trim();
if (!trimName) {
window.pushToast({
title: window.i18n('displayNameEmpty'),
type: 'error',
id: 'invalidDisplayName',
});
return;
}
if (passwordErrorString) {
window.pushToast({
title: window.i18n('invalidPassword'),
@ -761,9 +774,6 @@ export class RegistrationTabs extends React.Component<{}, State> {
if (!mnemonicSeed) {
return;
}
if (!displayName) {
return;
}
// Ensure we clear the secondary device registration status
window.textsecure.storage.remove('secondaryDeviceStatus');
@ -775,7 +785,7 @@ export class RegistrationTabs extends React.Component<{}, State> {
await this.accountManager.registerSingleDevice(
mnemonicSeed,
language,
displayName
trimName
);
trigger('openInbox');
} catch (e) {

@ -17,6 +17,7 @@ interface Props {
description: string;
avatarPath: string;
timerOptions: Array<TimerOption>;
isPublic: boolean;
onGoBack: () => void;
onInviteFriends: () => void;
@ -183,9 +184,12 @@ export class SessionChannelSettings extends React.Component<Props, any> {
}
public render() {
const { memberCount, name, onLeaveGroup } = this.props;
const { memberCount, name, onLeaveGroup, isPublic } = this.props;
const { documents, media, onItemClick } = this.state;
const showMemberCount = !!(memberCount && memberCount > 0);
const leaveGroupString = isPublic
? window.i18n('leaveOpenGroup')
: window.i18n('leaveClosedGroup');
return (
<div className="group-settings">
@ -213,7 +217,7 @@ export class SessionChannelSettings extends React.Component<Props, any> {
onItemClick={onItemClick}
/>
<SessionButton
text={window.i18n('leaveGroup')}
text={leaveGroupString}
buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.SquareOutline}
onClick={onLeaveGroup}

@ -22,8 +22,18 @@ interface Props {
}
export class SessionClosableOverlay extends React.Component<Props> {
private readonly inputRef: React.RefObject<SessionIdEditable>;
public constructor(props: Props) {
super(props);
this.inputRef = React.createRef();
}
public componentDidMount() {
if (this.inputRef.current) {
this.inputRef.current.focus();
}
}
public render(): JSX.Element {
@ -89,6 +99,7 @@ export class SessionClosableOverlay extends React.Component<Props> {
<hr className="green" />
</div>
<SessionIdEditable
ref={this.inputRef}
editable={true}
placeholder={placeholder}
onChange={onChangeSessionID}

@ -8,16 +8,30 @@ interface Props {
}
export class SessionIdEditable extends React.PureComponent<Props> {
private readonly inputRef: React.RefObject<HTMLInputElement>;
public constructor(props: Props) {
super(props);
this.inputRef = React.createRef();
}
public componentWillUnmount() {
//FIXME ugly hack to empty the content editable div used on enter session ID
window.Session.emptyContentEditableDivs();
}
public focus() {
if (this.inputRef.current) {
this.inputRef.current.focus();
}
}
public render() {
const { placeholder, editable, onChange, text } = this.props;
return (
<div
ref={this.inputRef}
className="session-id-editable"
placeholder={placeholder}
contentEditable={editable}

@ -328,7 +328,7 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
type: SessionSettingType.Toggle,
category: SessionSettingCategory.General,
setFn: window.toggleMenuBar,
content: undefined,
content: { defaultValue: true },
comparisonValue: undefined,
onClick: undefined,
},
@ -420,6 +420,30 @@ export class SettingsView extends React.Component<SettingsViewProps, State> {
defaultValue: 24,
},
},
{
id: 'read-receipt-setting',
title: window.i18n('readReceiptSettingTitle'),
description: window.i18n('readReceiptSettingDescription'),
hidden: false,
type: SessionSettingType.Toggle,
category: SessionSettingCategory.Privacy,
setFn: undefined,
comparisonValue: undefined,
onClick: undefined,
content: {},
},
{
id: 'typing-indicators-setting',
title: window.i18n('typingIndicatorsSettingTitle'),
description: window.i18n('typingIndicatorsSettingDescription'),
hidden: false,
type: SessionSettingType.Toggle,
category: SessionSettingCategory.Privacy,
setFn: undefined,
comparisonValue: undefined,
onClick: undefined,
content: {},
},
{
id: 'set-password',
title: window.i18n('setAccountPasswordTitle'),

@ -2265,6 +2265,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
cross-env@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-6.0.3.tgz#4256b71e49b3a40637a0ce70768a6ef5c72ae941"
integrity sha512-+KqxF6LCvfhWvADcDPqo64yVIB31gv/jQulX2NGzKS/g3GEVz6/pt4wjHFtFWsHMddebWD/sDthJemzM4MaAag==
dependencies:
cross-spawn "^7.0.0"
cross-spawn@5.1.0, cross-spawn@^5.0.1, cross-spawn@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
@ -2290,6 +2297,15 @@ cross-spawn@^4:
lru-cache "^4.0.1"
which "^1.2.9"
cross-spawn@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14"
integrity sha512-u7v4o84SwFpD32Z8IIcPZ6z1/ie24O6RU3RbtL5Y316l3KuHVPx9ItBgWQ6VlfAFnRnTtMUrsQ9MUUTuEZjogg==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
which "^2.0.1"
crypto-browserify@^3.11.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec"
@ -7334,6 +7350,11 @@ path-key@^2.0.0:
resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
path-key@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
path-parse@^1.0.5, path-parse@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
@ -9249,11 +9270,23 @@ shebang-command@^1.2.0:
dependencies:
shebang-regex "^1.0.0"
shebang-command@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
dependencies:
shebang-regex "^3.0.0"
shebang-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
shebang-regex@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
shell-quote@1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
@ -10937,6 +10970,13 @@ which@1, which@^1.2.10, which@^1.2.14, which@^1.2.9, which@^1.3.0:
dependencies:
isexe "^2.0.0"
which@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
dependencies:
isexe "^2.0.0"
which@~1.2.1:
version "1.2.14"
resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5"

Loading…
Cancel
Save