Finish new Message component, integrate into application
Also: - New schema version 8 with video/image thumbnails, screenshots, sizes - Upgrade messages not at current schema version when loading messages to show in conversation - New MessageDetail react component - New ConversationHeader react component
164
Gruntfile.js
|
@ -311,17 +311,21 @@ module.exports = grunt => {
|
|||
});
|
||||
}
|
||||
|
||||
grunt.registerTask('unit-tests', 'Run unit tests w/Electron', () => {
|
||||
const environment = grunt.option('env') || 'test';
|
||||
const done = this.async();
|
||||
grunt.registerTask(
|
||||
'unit-tests',
|
||||
'Run unit tests w/Electron',
|
||||
function thisNeeded() {
|
||||
const environment = grunt.option('env') || 'test';
|
||||
const done = this.async();
|
||||
|
||||
runTests(environment, done);
|
||||
});
|
||||
runTests(environment, done);
|
||||
}
|
||||
);
|
||||
|
||||
grunt.registerTask(
|
||||
'lib-unit-tests',
|
||||
'Run libtextsecure unit tests w/Electron',
|
||||
() => {
|
||||
function thisNeeded() {
|
||||
const environment = grunt.option('env') || 'test-lib';
|
||||
const done = this.async();
|
||||
|
||||
|
@ -329,82 +333,86 @@ module.exports = grunt => {
|
|||
}
|
||||
);
|
||||
|
||||
grunt.registerMultiTask('test-release', 'Test packaged releases', () => {
|
||||
const dir = grunt.option('dir') || 'dist';
|
||||
const environment = grunt.option('env') || 'production';
|
||||
const config = this.data;
|
||||
const archive = [dir, config.archive].join('/');
|
||||
const files = [
|
||||
'config/default.json',
|
||||
`config/${environment}.json`,
|
||||
`config/local-${environment}.json`,
|
||||
];
|
||||
grunt.registerMultiTask(
|
||||
'test-release',
|
||||
'Test packaged releases',
|
||||
function thisNeeded() {
|
||||
const dir = grunt.option('dir') || 'dist';
|
||||
const environment = grunt.option('env') || 'production';
|
||||
const config = this.data;
|
||||
const archive = [dir, config.archive].join('/');
|
||||
const files = [
|
||||
'config/default.json',
|
||||
`config/${environment}.json`,
|
||||
`config/local-${environment}.json`,
|
||||
];
|
||||
|
||||
console.log(this.target, archive);
|
||||
const releaseFiles = files.concat(config.files || []);
|
||||
releaseFiles.forEach(fileName => {
|
||||
console.log(fileName);
|
||||
try {
|
||||
asar.statFile(archive, fileName);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw new Error(`Missing file ${fileName}`);
|
||||
}
|
||||
});
|
||||
console.log(this.target, archive);
|
||||
const releaseFiles = files.concat(config.files || []);
|
||||
releaseFiles.forEach(fileName => {
|
||||
console.log(fileName);
|
||||
try {
|
||||
asar.statFile(archive, fileName);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw new Error(`Missing file ${fileName}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (config.appUpdateYML) {
|
||||
const appUpdateYML = [dir, config.appUpdateYML].join('/');
|
||||
if (fs.existsSync(appUpdateYML)) {
|
||||
console.log('auto update ok');
|
||||
} else {
|
||||
throw new Error(`Missing auto update config ${appUpdateYML}`);
|
||||
if (config.appUpdateYML) {
|
||||
const appUpdateYML = [dir, config.appUpdateYML].join('/');
|
||||
if (fs.existsSync(appUpdateYML)) {
|
||||
console.log('auto update ok');
|
||||
} else {
|
||||
throw new Error(`Missing auto update config ${appUpdateYML}`);
|
||||
}
|
||||
}
|
||||
|
||||
const done = this.async();
|
||||
// A simple test to verify a visible window is opened with a title
|
||||
const { Application } = spectron;
|
||||
|
||||
const app = new Application({
|
||||
path: [dir, config.exe].join('/'),
|
||||
requireName: 'unused',
|
||||
});
|
||||
|
||||
app
|
||||
.start()
|
||||
.then(() => app.client.getWindowCount())
|
||||
.then(count => {
|
||||
assert.equal(count, 1);
|
||||
console.log('window opened');
|
||||
})
|
||||
.then(() =>
|
||||
// Get the window's title
|
||||
app.client.getTitle()
|
||||
)
|
||||
.then(title => {
|
||||
// Verify the window's title
|
||||
assert.equal(title, packageJson.productName);
|
||||
console.log('title ok');
|
||||
})
|
||||
.then(() => {
|
||||
assert(
|
||||
app.chromeDriver.logLines.indexOf(`NODE_ENV ${environment}`) > -1
|
||||
);
|
||||
console.log('environment ok');
|
||||
})
|
||||
.then(
|
||||
() =>
|
||||
// Successfully completed test
|
||||
app.stop(),
|
||||
error =>
|
||||
// Test failed!
|
||||
app.stop().then(() => {
|
||||
grunt.fail.fatal(`Test failed: ${error.message} ${error.stack}`);
|
||||
})
|
||||
)
|
||||
.then(done);
|
||||
}
|
||||
|
||||
const done = this.async();
|
||||
// A simple test to verify a visible window is opened with a title
|
||||
const { Application } = spectron;
|
||||
|
||||
const app = new Application({
|
||||
path: [dir, config.exe].join('/'),
|
||||
requireName: 'unused',
|
||||
});
|
||||
|
||||
app
|
||||
.start()
|
||||
.then(() => app.client.getWindowCount())
|
||||
.then(count => {
|
||||
assert.equal(count, 1);
|
||||
console.log('window opened');
|
||||
})
|
||||
.then(() =>
|
||||
// Get the window's title
|
||||
app.client.getTitle()
|
||||
)
|
||||
.then(title => {
|
||||
// Verify the window's title
|
||||
assert.equal(title, packageJson.productName);
|
||||
console.log('title ok');
|
||||
})
|
||||
.then(() => {
|
||||
assert(
|
||||
app.chromeDriver.logLines.indexOf(`NODE_ENV ${environment}`) > -1
|
||||
);
|
||||
console.log('environment ok');
|
||||
})
|
||||
.then(
|
||||
() =>
|
||||
// Successfully completed test
|
||||
app.stop(),
|
||||
error =>
|
||||
// Test failed!
|
||||
app.stop().then(() => {
|
||||
grunt.fail.fatal(`Test failed: ${error.message} ${error.stack}`);
|
||||
})
|
||||
)
|
||||
.then(done);
|
||||
});
|
||||
);
|
||||
|
||||
grunt.registerTask('tx', ['exec:tx-pull', 'locale-patch']);
|
||||
grunt.registerTask('dev', ['default', 'watch']);
|
||||
|
|
|
@ -279,7 +279,7 @@
|
|||
}
|
||||
},
|
||||
"youMarkedAsVerified": {
|
||||
"message": "You marked your safety number with $name$ as verified.",
|
||||
"message": "You marked your Safety Number with $name$ as verified",
|
||||
"description":
|
||||
"Shown in the conversation history when the user marks a contact as verified.",
|
||||
"placeholders": {
|
||||
|
@ -290,9 +290,9 @@
|
|||
}
|
||||
},
|
||||
"youMarkedAsNotVerified": {
|
||||
"message": "You marked your safety number with $name$ as unverified.",
|
||||
"message": "You marked your Safety Number with $name$ as not verified",
|
||||
"description":
|
||||
"Shown in the conversation history when the user marks a contact as not verified, whether on the safety number screen or by dismissing a banner or dialog.",
|
||||
"Shown in the conversation history when the user marks a contact as not verified, whether on the Safety Number screen or by dismissing a banner or dialog.",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
|
@ -302,7 +302,7 @@
|
|||
},
|
||||
"youMarkedAsVerifiedOtherDevice": {
|
||||
"message":
|
||||
"You marked your safety number with $name$ as verified from another device.",
|
||||
"You marked your Safety Number with $name$ as verified from another device",
|
||||
"description":
|
||||
"Shown in the conversation history when we discover that the user marked a contact as verified on another device.",
|
||||
"placeholders": {
|
||||
|
@ -314,7 +314,7 @@
|
|||
},
|
||||
"youMarkedAsNotVerifiedOtherDevice": {
|
||||
"message":
|
||||
"You marked your safety number with $name$ as not verified from another device.",
|
||||
"You marked your Safety Number with $name$ as not verified from another device",
|
||||
"description":
|
||||
"Shown in the conversation history when we discover that the user marked a contact as not verified on another device.",
|
||||
"placeholders": {
|
||||
|
@ -473,7 +473,7 @@
|
|||
"Your safety number with this contact has changed. This could either mean that someone is trying to intercept your communication, or this contact simply reinstalled Signal. You may wish to verify the new safety number below."
|
||||
},
|
||||
"incomingError": {
|
||||
"message": "Error handling incoming message."
|
||||
"message": "Error handling incoming message"
|
||||
},
|
||||
"media": {
|
||||
"message": "Media",
|
||||
|
@ -754,9 +754,6 @@
|
|||
"error": {
|
||||
"message": "Error"
|
||||
},
|
||||
"resend": {
|
||||
"message": "Resend"
|
||||
},
|
||||
"messageDetail": {
|
||||
"message": "Message Detail"
|
||||
},
|
||||
|
@ -767,7 +764,7 @@
|
|||
"message":
|
||||
"Are you sure? Clicking 'delete' will permanently remove this message from this device."
|
||||
},
|
||||
"deleteMessage": {
|
||||
"deleteThisMessage": {
|
||||
"message": "Delete this message"
|
||||
},
|
||||
"from": {
|
||||
|
@ -823,6 +820,21 @@
|
|||
"message":
|
||||
"You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message."
|
||||
},
|
||||
"moreInfo": {
|
||||
"message": "More Info...",
|
||||
"description":
|
||||
"Shown on the drop-down menu for an individual message, takes you to message detail screen"
|
||||
},
|
||||
"retrySend": {
|
||||
"message": "Retry Send",
|
||||
"description":
|
||||
"Shown on the drop-down menu for an indinvidaul message, but only if it is an outgoing message that failed to send"
|
||||
},
|
||||
"deleteMessage": {
|
||||
"message": "Delete Message",
|
||||
"description":
|
||||
"Shown on the drop-down menu for an individual message, deletes single message"
|
||||
},
|
||||
"deleteMessages": {
|
||||
"message": "Delete messages",
|
||||
"description": "Menu item for deleting messages, title case."
|
||||
|
@ -842,10 +854,23 @@
|
|||
"description":
|
||||
"Used in alt tag of thumbnail images inside of an embedded message quote"
|
||||
},
|
||||
"imageFailedToLoad": {
|
||||
"message": "Image failed to load",
|
||||
"description": "When an image attachment is missing, this message is shown"
|
||||
},
|
||||
"videoScreenshotFailedToLoad": {
|
||||
"message": "Video screenshot failed to load",
|
||||
"description":
|
||||
"When a attachment video screenshot is missing, this message is shown"
|
||||
},
|
||||
"imageAttachmentAlt": {
|
||||
"message": "Image attached to message",
|
||||
"description": "Used in alt tag of image attachment"
|
||||
},
|
||||
"videoAttachmentAlt": {
|
||||
"message": "Screenshot of video attached to message",
|
||||
"description": "Used in alt tag of video attachment preview"
|
||||
},
|
||||
"lightboxImageAlt": {
|
||||
"message": "Image sent in conversation",
|
||||
"description":
|
||||
|
@ -866,11 +891,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"noContents": {
|
||||
"message": "No message contents",
|
||||
"description":
|
||||
"Shown in a message bubble if we have nothing in the message to display, or a quote and nothing else"
|
||||
},
|
||||
"installWelcome": {
|
||||
"message": "Welcome to Signal Desktop",
|
||||
"description": "Welcome title on the install page"
|
||||
|
@ -1032,15 +1052,9 @@
|
|||
"description":
|
||||
"Displayed in notifications when setting is 'name and message' and more than one message is waiting"
|
||||
},
|
||||
"messageNotSent": {
|
||||
"message": "Message not sent.",
|
||||
"description":
|
||||
"Informational label, appears on messages that failed to send"
|
||||
},
|
||||
"someRecipientsFailed": {
|
||||
"message": "Some recipients failed.",
|
||||
"description":
|
||||
"When you send to multiple recipients via a group, and the message went to some recipients but not others."
|
||||
"sendFailed": {
|
||||
"message": "Send failed",
|
||||
"description": "Shown on outgoing message if it fails to send"
|
||||
},
|
||||
"showMore": {
|
||||
"message": "Details",
|
||||
|
@ -1159,7 +1173,7 @@
|
|||
"description": "Brief message shown when trying to message a blocked number"
|
||||
},
|
||||
"youChangedTheTimer": {
|
||||
"message": "You set the timer to $time$.",
|
||||
"message": "You set the disappearing message timer to $time$",
|
||||
"description":
|
||||
"Message displayed when you change the message expiration timer in a conversation.",
|
||||
"placeholders": {
|
||||
|
@ -1170,7 +1184,7 @@
|
|||
}
|
||||
},
|
||||
"timerSetOnSync": {
|
||||
"message": "Updating timer to $time$.",
|
||||
"message": "Updated disappearing message timer to $time$",
|
||||
"description":
|
||||
"Message displayed when timer is set on initial link of desktop device.",
|
||||
"placeholders": {
|
||||
|
@ -1181,7 +1195,7 @@
|
|||
}
|
||||
},
|
||||
"theyChangedTheTimer": {
|
||||
"message": "$name$ set the timer to $time$.",
|
||||
"message": "$name$ set the disappearing message timer to $time$",
|
||||
"description":
|
||||
"Message displayed when someone else changes the message expiration timer in a conversation.",
|
||||
"placeholders": {
|
||||
|
@ -1334,9 +1348,15 @@
|
|||
"message": "Play audio notification",
|
||||
"description": "Description for audio notification setting"
|
||||
},
|
||||
"keychanged": {
|
||||
"message": "Your safety number with $name$ has changed. Click to show.",
|
||||
"description": "",
|
||||
"safetyNumberChanged": {
|
||||
"message": "Safety Number has changed",
|
||||
"description":
|
||||
"A notification shown in the conversation when a contact reinstalls"
|
||||
},
|
||||
"safetyNumberChangedGroup": {
|
||||
"message": "Safety Number with $name$ has changed",
|
||||
"description":
|
||||
"A notification shown in a group conversation when a contact reinstalls, showing the contact name",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
|
@ -1344,6 +1364,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"verifyNewNumber": {
|
||||
"message": "Verify New Number",
|
||||
"description":
|
||||
"Label on button included with safety number change notification in the conversation"
|
||||
},
|
||||
"yourSafetyNumberWith": {
|
||||
"message": "Your safety number with $name$:",
|
||||
"description": "Heading for safety number view",
|
||||
|
@ -1405,7 +1430,7 @@
|
|||
"message": "Later"
|
||||
},
|
||||
"leftTheGroup": {
|
||||
"message": "$name$ left the group.",
|
||||
"message": "$name$ left the group",
|
||||
"description":
|
||||
"Shown in the conversation history when a single person leaves the group",
|
||||
"placeholders": {
|
||||
|
@ -1415,13 +1440,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"multipleLeftTheGroup": {
|
||||
"message": "$name$ left the group",
|
||||
"description":
|
||||
"Shown in the conversation history when multiple people leave the group",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "Alice, Bob"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedTheGroup": {
|
||||
"message": "Updated the group.",
|
||||
"message": "Group updated",
|
||||
"description":
|
||||
"Shown in the conversation history when someone updates the group"
|
||||
},
|
||||
"titleIsNow": {
|
||||
"message": "Title is now '$name$'.",
|
||||
"message": "Title is now '$name$'",
|
||||
"description":
|
||||
"Shown in the conversation history when someone changes the title of the group",
|
||||
"placeholders": {
|
||||
|
@ -1432,7 +1468,7 @@
|
|||
}
|
||||
},
|
||||
"joinedTheGroup": {
|
||||
"message": "$name$ joined the group.",
|
||||
"message": "$name$ joined the group",
|
||||
"description":
|
||||
"Shown in the conversation history when a single person joins the group",
|
||||
"placeholders": {
|
||||
|
@ -1443,7 +1479,7 @@
|
|||
}
|
||||
},
|
||||
"multipleJoinedTheGroup": {
|
||||
"message": "$names$ joined the group.",
|
||||
"message": "$names$ joined the group",
|
||||
"description":
|
||||
"Shown in the conversation history when more than one person joins the group",
|
||||
"placeholders": {
|
||||
|
|
|
@ -136,7 +136,7 @@ exports.getRelativePath = name => {
|
|||
return path.join(prefix, name);
|
||||
};
|
||||
|
||||
// createAbsolutePathGetter :: RoothPath -> RelativePath -> AbsolutePath
|
||||
// createAbsolutePathGetter :: RootPath -> RelativePath -> AbsolutePath
|
||||
exports.createAbsolutePathGetter = rootPath => relativePath => {
|
||||
const absolutePath = path.join(rootPath, relativePath);
|
||||
const normalized = path.normalize(absolutePath);
|
||||
|
|
222
background.html
|
@ -23,9 +23,8 @@
|
|||
<link href='images/icon_128.png' rel='shortcut icon'>
|
||||
<link href="stylesheets/manifest.css" rel="stylesheet" type="text/css" />
|
||||
|
||||
<!-- When making changes to these templates, be sure to update these two places:
|
||||
1) test/styleguide/legacy_templates.js
|
||||
2) test/index.html
|
||||
<!--
|
||||
When making changes to these templates, be sure to update test/index.html as well
|
||||
-->
|
||||
|
||||
<script type='text/x-tmpl-mustache' id='app-loading-screen'>
|
||||
|
@ -51,20 +50,20 @@
|
|||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='two-column'>
|
||||
<div class='gutter'>
|
||||
<div class='network-status-container'></div>
|
||||
<div class='title-bar active' id='header'>
|
||||
<h1>Signal</h1>
|
||||
<div class='tool-bar clearfix'>
|
||||
<input type='search' class='search' placeholder='{{ searchForPeopleOrGroups }}' dir='auto'>
|
||||
<span class='search-icon'></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='content'>
|
||||
<div class='conversations inbox'></div>
|
||||
<div class='conversations search-results hide'>
|
||||
<div class='new-contact contact hide'></div>
|
||||
</div>
|
||||
<div class='network-status-container'></div>
|
||||
<div class='title-bar active'>
|
||||
<div class='logo'>Signal</div>
|
||||
</div>
|
||||
<div class='tool-bar clearfix'>
|
||||
<input type='search' class='search' placeholder='{{ searchForPeopleOrGroups }}' dir='auto'>
|
||||
<span class='search-icon'></span>
|
||||
</div>
|
||||
<div class='content'>
|
||||
<div class='conversations inbox'></div>
|
||||
<div class='conversations search-results hide'>
|
||||
<div class='new-contact contact hide'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='conversation-stack'>
|
||||
<div class='conversation placeholder'>
|
||||
|
@ -81,15 +80,14 @@
|
|||
<div class='lightbox-container'></div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='scroll-down-button-view'>
|
||||
<button class='text {{ cssClass }}' alt='{{ moreBelow }}'>
|
||||
<div class='icon'></div>
|
||||
<button class='text module-scroll-down__button {{ buttonClass }}' alt='{{ moreBelow }}'>
|
||||
<div class='module-scroll-down__icon'></div>
|
||||
</button>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='last-seen-indicator-view'>
|
||||
<div class='bar'>
|
||||
<div class='text'>
|
||||
{{ unreadMessages }}
|
||||
</div>
|
||||
<div class='module-last-seen-indicator__bar'/>
|
||||
<div class='module-last-seen-indicator__text'>
|
||||
{{ unreadMessages }}
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='expired_alert'>
|
||||
|
@ -112,46 +110,7 @@
|
|||
<p> {{ content }}</p>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='conversation'>
|
||||
<div class='conversation-header {{ avatar.color }}'>
|
||||
<div class='header-buttons left'>
|
||||
<div class='vertical-align'>
|
||||
<button class='back hide'></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class='header-buttons right'>
|
||||
<div class='vertical-align'>
|
||||
<div class='conversation-menu menu'>
|
||||
<button class='hamburger' alt='conversation menu'></button>
|
||||
<ul class='menu-list'>
|
||||
<li class='disappearing-messages'>{{ disappearing-messages }}</li>
|
||||
<li class='view-all-media'>{{ view-all-media }}</li>
|
||||
{{#group}}
|
||||
<li class='show-members'>{{ show-members }}</li>
|
||||
<!-- <li class='update-group'>Update group</li> -->
|
||||
<!-- <li class='leave-group'>Leave group</li> -->
|
||||
{{/group}}
|
||||
{{^group}}
|
||||
{{ ^isMe }}
|
||||
<li class='show-identity'>{{ show-identity }}</li>
|
||||
{{ /isMe }}
|
||||
<li class='end-session'>{{ end-session }}</li>
|
||||
{{/group}}
|
||||
<li class='destroy'>{{ destroy }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='timer-menu menu'>
|
||||
<button class='clock' alt='timer menu'></button>
|
||||
<ul class='menu-list'>
|
||||
{{ #timer_options }}
|
||||
<li data-seconds={{ attributes.seconds }}>{{ getName }}</li>
|
||||
{{ /timer_options }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class='conversation-title'></span>
|
||||
{{> avatar }}
|
||||
</div>
|
||||
<div class='conversation-header'></div>
|
||||
<div class='main panel'>
|
||||
<div class='discussion-container'>
|
||||
<div class='bar-container hide'>
|
||||
|
@ -217,65 +176,6 @@
|
|||
<div class='fileSize'>{{ fileSize }}</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='hasRetry'>
|
||||
{{ messageNotSent }}
|
||||
<span href='#' class='retry'>{{ resend }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='some-failed'>
|
||||
{{ someFailed }}
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='keychange'>
|
||||
<span class='content' dir='auto'><span class='shield icon'></span> {{ content }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='verified-change'>
|
||||
<span class='content' dir='auto'><span class='{{ icon }} icon'></span> {{ content }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='message'>
|
||||
{{> avatar }}
|
||||
<div class='bubble {{ avatar.color }}'>
|
||||
<div class='sender' dir='auto'>
|
||||
{{ sender }}
|
||||
{{ #profileName }}
|
||||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
{{ #hasAttachments }}
|
||||
<div class='attachments'></div>
|
||||
{{ /hasAttachments }}
|
||||
{{ #hasBody }}
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}
|
||||
<div class='body'></div>
|
||||
{{ /message }}
|
||||
</div>
|
||||
{{ /hasBody }}
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
{{ #hoverIcon }}
|
||||
<div class='menu-container menu'>
|
||||
<div class='menu-anchor'>
|
||||
<span class='dots-horizontal-icon'></span>
|
||||
<ul class='menu-list'>
|
||||
<li class='reply'>{{ reply }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ /hoverIcon }}
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='hourglass'>
|
||||
<span class='hourglass'><span class='sand'></span></span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='expirationTimerUpdate'>
|
||||
<span class='content'><span class='icon clock'></span> {{ content }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='new-group-update'>
|
||||
<div class='conversation-header'>
|
||||
<button class='back'></button>
|
||||
|
@ -357,51 +257,6 @@
|
|||
<script type='text/x-tmpl-mustache' id='attachment-type-modal'>
|
||||
Sorry, your attachment has a type, {{type}}, that is not currently supported.
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='message-detail'>
|
||||
<div class='container'>
|
||||
<div class='message-container'></div>
|
||||
<div class='info'>
|
||||
<table>
|
||||
{{ #errors }}
|
||||
<tr>
|
||||
<td class='label'>{{ errorLabel }}</td>
|
||||
<td> <span class='error-message'>{{message}}</span> </td>
|
||||
</tr>
|
||||
{{ /errors }}
|
||||
<tr>
|
||||
<td class='label'>{{ sent }}</td>
|
||||
<td> {{ sent_at }}</td>
|
||||
</tr>
|
||||
{{ #received_at }}
|
||||
<tr>
|
||||
<td class='label'>{{ received }}</td>
|
||||
<td> {{ received_at }}</td>
|
||||
</tr>
|
||||
{{ /received_at }}
|
||||
<tr> <td class='tofrom label'>{{tofrom}}</td> </tr>
|
||||
</table>
|
||||
<div class='contacts'>
|
||||
</div>
|
||||
</div>
|
||||
<div class='delete-container'>
|
||||
<button class='delete grey'>{{ deleteLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='identity-key-send-error'>
|
||||
<div class='container'>
|
||||
<div class='explanation'>
|
||||
{{ errorExplanation }}
|
||||
</div>
|
||||
<div class='safety-number'>
|
||||
<button class='show-safety-number grey'>{{ showSafetyNumber }}</button>
|
||||
</div>
|
||||
<div class='actions'>
|
||||
<button class='send-anyway grey'>{{ sendAnyway }}</button>
|
||||
<button class='cancel grey'>{{ cancel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='group-member-list'>
|
||||
<div class='container'>
|
||||
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
|
||||
|
@ -478,39 +333,6 @@
|
|||
<span class='error-message'>{{message}}</span>
|
||||
{{ /message }}
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='contact-detail'>
|
||||
<div class='clearfix'>
|
||||
{{> avatar }}
|
||||
<div class='contact-details'>
|
||||
{{ #errors }}
|
||||
<div class='error-icon-container'>
|
||||
{{ #showErrorButton }}
|
||||
<button class='error'>
|
||||
<span class='icon error'></span>
|
||||
{{ errorButtonLabel }}
|
||||
</button>
|
||||
{{ /showErrorButton }}
|
||||
{{ ^showErrorButton }}
|
||||
<span class='error-icon'></span>
|
||||
{{ /showErrorButton }}
|
||||
</div>
|
||||
{{ /errors }}
|
||||
|
||||
{{ ^errors }}
|
||||
<div class='status-icon-container {{ status }}'>
|
||||
<span class='status'></span>
|
||||
</div>
|
||||
{{ /errors }}
|
||||
|
||||
<span class='name' dir='auto'>{{ name }}</span>
|
||||
{{ #errors }}
|
||||
{{ #message }}
|
||||
<p class='error-message'>{{message}}</p>
|
||||
{{ /message }}
|
||||
{{ /errors }}
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='link_to_support'>
|
||||
<a href='http://support.signal.org/hc/articles/213134107' target='_blank'>
|
||||
{{ learnMore }}
|
||||
|
@ -805,7 +627,6 @@
|
|||
<script type='text/javascript' src='js/views/timestamp_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/message_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/key_verification_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/message_detail_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/message_list_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/group_member_list_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/recorder_view.js'></script>
|
||||
|
@ -818,7 +639,6 @@
|
|||
<script type='text/javascript' src='js/views/identicon_svg_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/install_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/banner_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/identity_key_send_error_view.js'></script>
|
||||
<script type="text/javascript" src="js/views/phone-input-view.js"></script>
|
||||
<script type='text/javascript' src='js/views/standalone_registration_view.js'></script>
|
||||
<script type='text/javascript' src='js/views/app_view.js'></script>
|
||||
|
|
22
images/download.svg
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Icons/Download/download-24</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path d="M19,9 L15,9 L15,3 L9,3 L9,9 L5,9 L12,16 L19,9 Z M5,18 L5,20 L19,20 L19,18 L5,18 Z" id="path-1"></path>
|
||||
<rect id="path-3" x="0" y="0" width="24" height="24"></rect>
|
||||
</defs>
|
||||
<g id="Icons/Download/download-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<g id="Shape" fill-rule="nonzero"></g>
|
||||
<g id="Color/Dark/Black" mask="url(#mask-2)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3"></use>
|
||||
</mask>
|
||||
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
22
images/ellipsis.svg
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Icons/Ellipses/ellipses-24</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path d="M6,10 C4.9,10 4,10.9 4,12 C4,13.1 4.9,14 6,14 C7.1,14 8,13.1 8,12 C8,10.9 7.1,10 6,10 Z M18,10 C16.9,10 16,10.9 16,12 C16,13.1 16.9,14 18,14 C19.1,14 20,13.1 20,12 C20,10.9 19.1,10 18,10 Z M12,10 C10.9,10 10,10.9 10,12 C10,13.1 10.9,14 12,14 C13.1,14 14,13.1 14,12 C14,10.9 13.1,10 12,10 Z" id="path-1"></path>
|
||||
<rect id="path-3" x="0" y="0" width="24" height="24"></rect>
|
||||
</defs>
|
||||
<g id="Icons/Ellipses/ellipses-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<use id="Shape" fill="#62656A" fill-rule="nonzero" xlink:href="#path-1"></use>
|
||||
<g id="Color/Dark/Black" mask="url(#mask-2)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3"></use>
|
||||
</mask>
|
||||
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
10
images/error.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Icons/Error/error-20</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Icons/Error/error-20" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M10,0 C15.52,0 20,4.48 20,10 C20,15.52 15.52,20 10,20 C4.48,20 0,15.52 0,10 C0,4.48 4.48,0 10,0 Z M10,1.5 C5.308,1.5 1.5,5.308 1.5,10 C1.5,14.692 5.308,18.5 10,18.5 C14.692,18.5 18.5,14.692 18.5,10 C18.5,5.308 14.692,1.5 10,1.5 Z M9.25,5 L10.75,5 L10.75,12 L9.25,12 L9.25,5 Z M9.25,13.5 L10.75,13.5 L10.75,15 L9.25,15 L9.25,13.5 Z" id="Combined-Shape" fill="#000000" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 887 B |
22
images/gear.svg
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 51 (57462) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Gear/gear-20</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path d="M17.43,10.98 C17.47,10.66 17.5,10.34 17.5,10 C17.5,9.66 17.47,9.34 17.43,9.02 L19.54,7.37 C19.73,7.22 19.78,6.95 19.66,6.73 L17.66,3.27 C17.54,3.05 17.27,2.97 17.05,3.05 L14.56,4.05 C14.04,3.65 13.48,3.32 12.87,3.07 L12.49,0.42 C12.46,0.18 12.25,0 12,0 L8,0 C7.75,0 7.54,0.18 7.51,0.42 L7.13,3.07 C6.52,3.32 5.96,3.66 5.44,4.05 L2.95,3.05 C2.72,2.96 2.46,3.05 2.34,3.27 L0.34,6.73 C0.21,6.95 0.27,7.22 0.46,7.37 L2.57,9.02 C2.53,9.34 2.5,9.67 2.5,10 C2.5,10.33 2.53,10.66 2.57,10.98 L0.46,12.63 C0.27,12.78 0.22,13.05 0.34,13.27 L2.34,16.73 C2.46,16.95 2.73,17.03 2.95,16.95 L5.44,15.95 C5.96,16.35 6.52,16.68 7.13,16.93 L7.51,19.58 C7.54,19.82 7.75,20 8,20 L12,20 C12.25,20 12.46,19.82 12.49,19.58 L12.87,16.93 C13.48,16.68 14.04,16.34 14.56,15.95 L17.05,16.95 C17.28,17.04 17.54,16.95 17.66,16.73 L19.66,13.27 C19.78,13.05 19.73,12.78 19.54,12.63 L17.43,10.98 Z M10,13.5 C8.07,13.5 6.5,11.93 6.5,10 C6.5,8.07 8.07,6.5 10,6.5 C11.93,6.5 13.5,8.07 13.5,10 C13.5,11.93 11.93,13.5 10,13.5 Z" id="path-1"></path>
|
||||
<rect id="path-3" x="0" y="0" width="40" height="40"></rect>
|
||||
</defs>
|
||||
<g id="Gear/gear-20" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<g id="Shape" fill-rule="nonzero"></g>
|
||||
<g id="Primitives/Color/Black" mask="url(#mask-2)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3"></use>
|
||||
</mask>
|
||||
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
22
images/read.svg
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="18px" height="12px" viewBox="0 0 18 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Icons/Read/read-18x12</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path d="M7.91731278,0.313257194 C6.15053376,1.58392424 5,3.65760134 5,6 C5,6.343797 5.0247846,6.68180525 5.07266453,7.01233547 L5,7.085 L3.205,5.295 L2.5,6 L5,8.5 L5.33970233,8.16029767 C5.80439817,9.59399486 6.71914823,10.8250231 7.91731278,11.6867428 C7.31518343,11.8898758 6.67037399,12 6,12 C2.688,12 0,9.312 0,6 C0,2.688 2.688,0 6,0 C6.67037399,0 7.31518343,0.110124239 7.91731278,0.313257194 Z M12,0 C15.312,0 18,2.688 18,6 C18,9.312 15.312,12 12,12 C8.688,12 6,9.312 6,6 C6,2.688 8.688,0 12,0 Z M11,8.5 L15.5,4 L14.795,3.29 L11,7.085 L9.205,5.295 L8.5,6 L11,8.5 Z" id="path-1"></path>
|
||||
<rect id="path-3" x="0" y="0" width="18" height="12"></rect>
|
||||
</defs>
|
||||
<g id="Icons/Read/read-18x12" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<g id="double-check" fill-rule="nonzero"></g>
|
||||
<g id="Color/Dark/Black" mask="url(#mask-2)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3"></use>
|
||||
</mask>
|
||||
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
22
images/reply.svg
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Icons/Reply/reply-24</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path d="M10,8 L10,4 L3,11 L10,18 L10,13.9 C15,13.9 18.5,15.5 21,19 C20,14 17,9 10,8 Z" id="path-1"></path>
|
||||
<rect id="path-3" x="0" y="0" width="24" height="24"></rect>
|
||||
</defs>
|
||||
<g id="Icons/Reply/reply-24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<g id="Shape" fill-rule="nonzero"></g>
|
||||
<g id="Color/Dark/Black" mask="url(#mask-2)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3"></use>
|
||||
</mask>
|
||||
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
14
images/timer.svg
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20">
|
||||
<defs>
|
||||
<path id="a" d="M10 1.5c-.338 0-.672.02-1 .058V.05C9.329.017 9.663 0 10 0c5.523 0 10 4.477 10 10s-4.477 10-10 10S0 15.523 0 10A10 10 0 0 1 5.658.99l.487.843.005-.002 4.5 7.794-1.3.75-4.233-7.333A8.5 8.5 0 1 0 10 1.5z"/>
|
||||
<path id="c" d="M0 0h40v40H0z"/>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<mask id="b" fill="#fff">
|
||||
<use xlink:href="#a"/>
|
||||
</mask>
|
||||
<g mask="url(#b)">
|
||||
<use fill="#62656A" xlink:href="#c"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 650 B |
|
@ -116,6 +116,9 @@
|
|||
|
||||
function mapOldThemeToNew(theme) {
|
||||
switch (theme) {
|
||||
case 'dark':
|
||||
case 'light':
|
||||
return theme;
|
||||
case 'android-dark':
|
||||
return 'dark';
|
||||
case 'android':
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
/* global ConversationController: false */
|
||||
/* global libsignal: false */
|
||||
/* global Signal: false */
|
||||
/* global storage: false */
|
||||
/* global textsecure: false */
|
||||
/* global Whisper: false */
|
||||
|
@ -19,7 +18,15 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { Message } = window.Signal.Types;
|
||||
const { Util } = window.Signal;
|
||||
const { GoogleChrome } = Util;
|
||||
const {
|
||||
Conversation,
|
||||
Contact,
|
||||
Errors,
|
||||
Message,
|
||||
VisualAttachment,
|
||||
} = window.Signal.Types;
|
||||
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
|
||||
|
||||
// TODO: Factor out private and group subclasses of Conversation
|
||||
|
@ -108,7 +115,10 @@
|
|||
this.on('change:profileKey', this.onChangeProfileKey);
|
||||
this.on('destroy', this.revokeAvatarUrl);
|
||||
|
||||
// Listening for out-of-band data updates
|
||||
this.on('newmessage', this.addSingleMessage);
|
||||
this.on('delivered', this.updateMessage);
|
||||
this.on('read', this.updateMessage);
|
||||
this.on('expired', this.onExpired);
|
||||
this.listenTo(
|
||||
this.messageCollection,
|
||||
|
@ -127,6 +137,7 @@
|
|||
mine.trigger('expired', mine);
|
||||
}
|
||||
},
|
||||
|
||||
async onExpiredCollection(message) {
|
||||
console.log('onExpiredCollection', message.attributes);
|
||||
const removeMessage = () => {
|
||||
|
@ -144,6 +155,12 @@
|
|||
removeMessage();
|
||||
},
|
||||
|
||||
// Used to update existing messages when updated from out-of-band db access,
|
||||
// like read and delivery receipts.
|
||||
updateMessage(message) {
|
||||
this.messageCollection.add(message, { merge: true });
|
||||
},
|
||||
|
||||
addSingleMessage(message) {
|
||||
const model = this.messageCollection.add(message, { merge: true });
|
||||
model.setToExpire();
|
||||
|
@ -716,24 +733,23 @@
|
|||
},
|
||||
|
||||
async makeThumbnailAttachment(attachment) {
|
||||
const { arrayBufferToObjectURL } = Util;
|
||||
const attachmentWithData = await loadAttachmentData(attachment);
|
||||
const { data, contentType } = attachmentWithData;
|
||||
const objectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
const objectUrl = arrayBufferToObjectURL({
|
||||
data,
|
||||
type: contentType,
|
||||
});
|
||||
|
||||
const thumbnail = Signal.Util.GoogleChrome.isImageTypeSupported(
|
||||
contentType
|
||||
)
|
||||
? await Whisper.FileInputView.makeImageThumbnail(128, objectUrl)
|
||||
: await Whisper.FileInputView.makeVideoThumbnail(128, objectUrl);
|
||||
const thumbnail = GoogleChrome.isImageTypeSupported(contentType)
|
||||
? await VisualAttachment.makeImageThumbnail(128, objectUrl)
|
||||
: await VisualAttachment.makeVideoThumbnail(128, objectUrl);
|
||||
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
|
||||
const finalContentType = 'image/png';
|
||||
const finalObjectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
const finalObjectUrl = arrayBufferToObjectURL({
|
||||
data: arrayBuffer,
|
||||
type: finalContentType,
|
||||
});
|
||||
|
@ -746,7 +762,7 @@
|
|||
},
|
||||
|
||||
async makeQuote(quotedMessage) {
|
||||
const { getName } = Signal.Types.Contact;
|
||||
const { getName } = Contact;
|
||||
const contact = quotedMessage.getContact();
|
||||
const attachments = quotedMessage.get('attachments');
|
||||
|
||||
|
@ -765,8 +781,8 @@
|
|||
(attachments || []).map(async attachment => {
|
||||
const { contentType } = attachment;
|
||||
const willMakeThumbnail =
|
||||
Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
|
||||
GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
GoogleChrome.isVideoTypeSupported(contentType);
|
||||
const makeThumbnail = async () => {
|
||||
try {
|
||||
if (willMakeThumbnail) {
|
||||
|
@ -873,16 +889,14 @@
|
|||
const lastMessage = collection.at(0);
|
||||
|
||||
const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null;
|
||||
const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate(
|
||||
{
|
||||
currentLastMessageText: this.get('lastMessage') || null,
|
||||
currentTimestamp: this.get('timestamp') || null,
|
||||
lastMessage: lastMessageJSON,
|
||||
lastMessageNotificationText: lastMessage
|
||||
? lastMessage.getNotificationText()
|
||||
: null,
|
||||
}
|
||||
);
|
||||
const lastMessageUpdate = Conversation.createLastMessageUpdate({
|
||||
currentLastMessageText: this.get('lastMessage') || null,
|
||||
currentTimestamp: this.get('timestamp') || null,
|
||||
lastMessage: lastMessageJSON,
|
||||
lastMessageNotificationText: lastMessage
|
||||
? lastMessage.getNotificationText()
|
||||
: null,
|
||||
});
|
||||
|
||||
console.log('Conversation: Update last message:', {
|
||||
id: this.idForLogging() || null,
|
||||
|
@ -1284,8 +1298,8 @@
|
|||
|
||||
return (
|
||||
thumbnail ||
|
||||
Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
|
||||
GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
GoogleChrome.isVideoTypeSupported(contentType)
|
||||
);
|
||||
},
|
||||
forceRender(message) {
|
||||
|
@ -1323,8 +1337,8 @@
|
|||
}
|
||||
|
||||
if (
|
||||
!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
!GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
@ -1352,7 +1366,7 @@
|
|||
} catch (error) {
|
||||
console.log(
|
||||
'Problem loading attachment data for quoted message from database',
|
||||
Signal.Types.Errors.toLogFormat(error)
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
@ -1370,8 +1384,8 @@
|
|||
}
|
||||
|
||||
if (
|
||||
!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
!GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
@ -1410,7 +1424,7 @@
|
|||
try {
|
||||
const thumbnailWithData = await loadAttachmentData(thumbnail);
|
||||
const { data, contentType } = thumbnailWithData;
|
||||
thumbnailWithData.objectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
thumbnailWithData.objectUrl = Util.arrayBufferToObjectURL({
|
||||
data,
|
||||
type: contentType,
|
||||
});
|
||||
|
@ -1489,10 +1503,30 @@
|
|||
return Promise.all(promises);
|
||||
},
|
||||
|
||||
async upgradeMessages(messages) {
|
||||
for (let max = messages.length, i = 0; i < max; i += 1) {
|
||||
const message = messages.at(i);
|
||||
const { attributes } = message;
|
||||
const { schemaVersion } = attributes;
|
||||
|
||||
if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) {
|
||||
const upgradedMessage = upgradeMessageSchema(attributes);
|
||||
message.set(upgradedMessage);
|
||||
// Yep, we really do want to wait for each of these
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await wrapDeferred(message.save());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMessages() {
|
||||
if (!this.id) {
|
||||
throw new Error('This conversation has no id!');
|
||||
}
|
||||
if (this.inProgressFetch) {
|
||||
console.log('Attempting to start a parallel fetchMessages() call');
|
||||
return;
|
||||
}
|
||||
|
||||
this.inProgressFetch = this.messageCollection.fetchConversation(
|
||||
this.id,
|
||||
|
@ -1501,11 +1535,24 @@
|
|||
);
|
||||
|
||||
await this.inProgressFetch;
|
||||
this.inProgressFetch = null;
|
||||
|
||||
try {
|
||||
// We are now doing the work to upgrade messages before considering the load from
|
||||
// the database complete. Note that we do save messages back, so it is a
|
||||
// one-time hit. We do this so we have guarantees about message structure.
|
||||
await this.upgradeMessages(this.messageCollection);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'fetchMessages: failed to upgrade messages',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
|
||||
// We kick this process off, but don't wait for it. If async updates happen on a
|
||||
// given Message, 'change' will be triggered
|
||||
this.processQuotes(this.messageCollection);
|
||||
|
||||
this.inProgressFetch = null;
|
||||
},
|
||||
|
||||
hasMember(number) {
|
||||
|
@ -1534,28 +1581,36 @@
|
|||
});
|
||||
},
|
||||
|
||||
destroyMessages() {
|
||||
this.messageCollection
|
||||
.fetch({
|
||||
index: {
|
||||
// 'conversation' index on [conversationId, received_at]
|
||||
name: 'conversation',
|
||||
lower: [this.id],
|
||||
upper: [this.id, Number.MAX_VALUE],
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const { models } = this.messageCollection;
|
||||
this.messageCollection.reset([]);
|
||||
_.each(models, message => {
|
||||
message.destroy();
|
||||
});
|
||||
this.save({
|
||||
lastMessage: null,
|
||||
timestamp: null,
|
||||
active_at: null,
|
||||
});
|
||||
async destroyMessages() {
|
||||
let loaded;
|
||||
do {
|
||||
// Yes, we really want the await in the loop. We're deleting 100 at a
|
||||
// time so we don't use too much memory.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await wrapDeferred(
|
||||
this.messageCollection.fetch({
|
||||
limit: 100,
|
||||
index: {
|
||||
// 'conversation' index on [conversationId, received_at]
|
||||
name: 'conversation',
|
||||
lower: [this.id],
|
||||
upper: [this.id, Number.MAX_VALUE],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
loaded = this.messageCollection.models;
|
||||
this.messageCollection.reset([]);
|
||||
_.each(loaded, message => {
|
||||
message.destroy();
|
||||
});
|
||||
} while (loaded.length > 0);
|
||||
|
||||
this.save({
|
||||
lastMessage: null,
|
||||
timestamp: null,
|
||||
active_at: null,
|
||||
});
|
||||
},
|
||||
|
||||
getName() {
|
||||
|
@ -1646,20 +1701,8 @@
|
|||
}
|
||||
},
|
||||
getColor() {
|
||||
const title = this.get('name');
|
||||
let color = this.get('color');
|
||||
if (!color) {
|
||||
if (this.isPrivate()) {
|
||||
if (title) {
|
||||
color = COLORS[Math.abs(this.hashCode()) % 15];
|
||||
} else {
|
||||
color = 'grey';
|
||||
}
|
||||
} else {
|
||||
color = 'default';
|
||||
}
|
||||
}
|
||||
return color;
|
||||
const { migrateColor } = Util;
|
||||
return migrateColor(this.get('color'));
|
||||
},
|
||||
getAvatar() {
|
||||
if (this.avatarUrl === undefined) {
|
||||
|
@ -1705,9 +1748,7 @@
|
|||
const messageJSON = message.toJSON();
|
||||
const messageSentAt = messageJSON.sent_at;
|
||||
const messageId = message.id;
|
||||
const isExpiringMessage = Signal.Types.Message.hasExpiration(
|
||||
messageJSON
|
||||
);
|
||||
const isExpiringMessage = Message.hasExpiration(messageJSON);
|
||||
|
||||
console.log('Add notification', {
|
||||
conversationId: this.idForLogging(),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/* global _: false */
|
||||
/* global Backbone: false */
|
||||
|
||||
/* global storage: false */
|
||||
/* global filesize: false */
|
||||
/* global ConversationController: false */
|
||||
/* global getAccountManager: false */
|
||||
/* global i18n: false */
|
||||
|
@ -17,8 +18,41 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { Message: TypedMessage, Contact } = Signal.Types;
|
||||
const { deleteAttachmentData } = Signal.Migrations;
|
||||
const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types;
|
||||
const {
|
||||
// loadAttachmentData,
|
||||
deleteAttachmentData,
|
||||
getAbsoluteAttachmentPath,
|
||||
} = Signal.Migrations;
|
||||
|
||||
window.AccountCache = Object.create(null);
|
||||
window.AccountJobs = Object.create(null);
|
||||
|
||||
window.doesAcountCheckJobExist = number =>
|
||||
Boolean(window.AccountJobs[number]);
|
||||
window.checkForSignalAccount = number => {
|
||||
if (window.AccountJobs[number]) {
|
||||
return window.AccountJobs[number];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line more/no-then
|
||||
const job = textsecure.messaging
|
||||
.getProfile(number)
|
||||
.then(() => {
|
||||
window.AccountCache[number] = true;
|
||||
})
|
||||
.catch(() => {
|
||||
window.AccountCache[number] = false;
|
||||
});
|
||||
|
||||
window.AccountJobs[number] = job;
|
||||
|
||||
return job;
|
||||
};
|
||||
|
||||
window.isSignalAccountCheckComplete = number =>
|
||||
window.AccountCache[number] !== undefined;
|
||||
window.hasSignalAccount = number => window.AccountCache[number];
|
||||
|
||||
window.Whisper.Message = Backbone.Model.extend({
|
||||
database: Whisper.Database,
|
||||
|
@ -28,6 +62,8 @@
|
|||
this.set(TypedMessage.initializeSchemaVersion(attributes));
|
||||
}
|
||||
|
||||
this.OUR_NUMBER = textsecure.storage.user.getNumber();
|
||||
|
||||
this.on('change:attachments', this.updateImageUrl);
|
||||
this.on('destroy', this.onDestroy);
|
||||
this.on('change:expirationStartTimestamp', this.setToExpire);
|
||||
|
@ -113,7 +149,7 @@
|
|||
return i18n('leftTheGroup', this.getNameForNumber(groupUpdate.left));
|
||||
}
|
||||
|
||||
const messages = [i18n('updatedTheGroup')];
|
||||
const messages = [];
|
||||
if (groupUpdate.name) {
|
||||
messages.push(i18n('titleIsNow', groupUpdate.name));
|
||||
}
|
||||
|
@ -129,7 +165,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
return messages.join(' ');
|
||||
return messages.join(', ');
|
||||
}
|
||||
if (this.isEndSession()) {
|
||||
return i18n('sessionEnded');
|
||||
|
@ -139,6 +175,9 @@
|
|||
}
|
||||
return this.get('body');
|
||||
},
|
||||
isVerifiedChange() {
|
||||
return this.get('type') === 'verified-change';
|
||||
},
|
||||
isKeyChange() {
|
||||
return this.get('type') === 'keychange';
|
||||
},
|
||||
|
@ -158,8 +197,12 @@
|
|||
);
|
||||
}
|
||||
if (this.isKeyChange()) {
|
||||
const conversation = this.getModelForKeyChange();
|
||||
return i18n('keychanged', conversation.getTitle());
|
||||
const phoneNumber = this.get('key_changed');
|
||||
const conversation = this.findContact(phoneNumber);
|
||||
return i18n(
|
||||
'safetyNumberChangedGroup',
|
||||
conversation ? conversation.getTitle() : null
|
||||
);
|
||||
}
|
||||
const contacts = this.get('contact');
|
||||
if (contacts && contacts.length) {
|
||||
|
@ -252,6 +295,268 @@
|
|||
thumbnail: thumbnailWithObjectUrl,
|
||||
});
|
||||
},
|
||||
getPropsForTimerNotification() {
|
||||
const { expireTimer, fromSync, source } = this.get(
|
||||
'expirationTimerUpdate'
|
||||
);
|
||||
const timespan = Whisper.ExpirationTimerOptions.getName(expireTimer || 0);
|
||||
|
||||
const basicProps = {
|
||||
type: 'fromOther',
|
||||
...this.findAndFormatContact(source),
|
||||
timespan,
|
||||
};
|
||||
|
||||
if (source === this.OUR_NUMBER) {
|
||||
return {
|
||||
...basicProps,
|
||||
type: 'fromMe',
|
||||
};
|
||||
} else if (fromSync) {
|
||||
return {
|
||||
...basicProps,
|
||||
type: 'fromSync',
|
||||
};
|
||||
}
|
||||
|
||||
return basicProps;
|
||||
},
|
||||
getPropsForSafetyNumberNotification() {
|
||||
const conversation = this.getConversation();
|
||||
const isGroup = conversation && !conversation.isPrivate();
|
||||
const phoneNumber = this.get('key_changed');
|
||||
const onVerify = () =>
|
||||
this.trigger('show-identity', this.findContact(phoneNumber));
|
||||
|
||||
return {
|
||||
isGroup,
|
||||
contact: this.findAndFormatContact(phoneNumber),
|
||||
onVerify,
|
||||
};
|
||||
},
|
||||
getPropsForVerificationNotification() {
|
||||
const type = this.get('verified') ? 'markVerified' : 'markNotVerified';
|
||||
const isLocal = this.get('local');
|
||||
const phoneNumber = this.get('verifiedChanged');
|
||||
|
||||
return {
|
||||
type,
|
||||
isLocal,
|
||||
contact: this.findAndFormatContact(phoneNumber),
|
||||
};
|
||||
},
|
||||
getPropsForResetSessionNotification() {
|
||||
// It doesn't need anything right now!
|
||||
return {};
|
||||
},
|
||||
findContact(phoneNumber) {
|
||||
return ConversationController.get(phoneNumber);
|
||||
},
|
||||
findAndFormatContact(phoneNumber) {
|
||||
const { format } = PhoneNumber;
|
||||
const regionCode = storage.get('regionCode');
|
||||
|
||||
const contactModel = this.findContact(phoneNumber);
|
||||
const avatar = contactModel ? contactModel.getAvatar() : null;
|
||||
const color = contactModel ? contactModel.getColor() : null;
|
||||
|
||||
return {
|
||||
phoneNumber: format(phoneNumber, {
|
||||
ourRegionCode: regionCode,
|
||||
}),
|
||||
color,
|
||||
avatarPath: avatar ? avatar.url : null,
|
||||
name: contactModel ? contactModel.getName() : null,
|
||||
profileName: contactModel ? contactModel.getProfileName() : null,
|
||||
title: contactModel ? contactModel.getTitle() : null,
|
||||
};
|
||||
},
|
||||
getPropsForGroupNotification() {
|
||||
const groupUpdate = this.get('group_update');
|
||||
const changes = [];
|
||||
|
||||
if (!groupUpdate.name && !groupUpdate.left && !groupUpdate.joined) {
|
||||
changes.push({
|
||||
type: 'general',
|
||||
});
|
||||
}
|
||||
|
||||
if (groupUpdate.joined) {
|
||||
changes.push({
|
||||
type: 'add',
|
||||
contacts: _.map(
|
||||
Array.isArray(groupUpdate.joined)
|
||||
? groupUpdate.joined
|
||||
: [groupUpdate.joined],
|
||||
phoneNumber => this.findAndFormatContact(phoneNumber)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (groupUpdate.left === 'You') {
|
||||
changes.push({
|
||||
type: 'remove',
|
||||
isMe: true,
|
||||
});
|
||||
} else if (groupUpdate.left) {
|
||||
changes.push({
|
||||
type: 'remove',
|
||||
contacts: _.map(
|
||||
Array.isArray(groupUpdate.left)
|
||||
? groupUpdate.left
|
||||
: [groupUpdate.left],
|
||||
phoneNumber => this.findAndFormatContact(phoneNumber)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (groupUpdate.name) {
|
||||
changes.push({
|
||||
type: 'name',
|
||||
newName: groupUpdate.name,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
};
|
||||
},
|
||||
getMessagePropStatus() {
|
||||
if (this.hasErrors()) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const readBy = this.get('read_by') || [];
|
||||
if (readBy.length > 0) {
|
||||
return 'read';
|
||||
}
|
||||
const delivered = this.get('delivered');
|
||||
const deliveredTo = this.get('delivered_to') || [];
|
||||
if (delivered || deliveredTo.length > 0) {
|
||||
return 'delivered';
|
||||
}
|
||||
const sent = this.get('sent');
|
||||
const sentTo = this.get('sent_to') || [];
|
||||
if (sent || sentTo.length > 0) {
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
return 'sending';
|
||||
},
|
||||
getPropsForMessage() {
|
||||
const phoneNumber = this.getSource();
|
||||
const contact = this.findAndFormatContact(phoneNumber);
|
||||
const contactModel = this.findContact(phoneNumber);
|
||||
|
||||
const authorColor = contactModel ? contactModel.getColor() : null;
|
||||
const authorAvatar = contactModel ? contactModel.getAvatar() : null;
|
||||
const authorAvatarPath = authorAvatar.url;
|
||||
|
||||
const expirationLength = this.get('expireTimer') * 1000;
|
||||
const expireTimerStart = this.get('expirationStartTimestamp');
|
||||
const expirationTimestamp =
|
||||
expirationLength && expireTimerStart
|
||||
? expireTimerStart + expirationLength
|
||||
: null;
|
||||
|
||||
const conversation = this.getConversation();
|
||||
const isGroup = conversation && !conversation.isPrivate();
|
||||
|
||||
const attachments = this.get('attachments');
|
||||
const firstAttachment = attachments && attachments[0];
|
||||
|
||||
return {
|
||||
text: this.createNonBreakingLastSeparator(this.get('body')),
|
||||
id: this.id,
|
||||
direction: this.isIncoming() ? 'incoming' : 'outgoing',
|
||||
timestamp: this.get('sent_at'),
|
||||
status: this.getMessagePropStatus(),
|
||||
contact: this.getPropsForEmbeddedContact(),
|
||||
authorName: contact.name,
|
||||
authorProfileName: contact.profileName,
|
||||
authorPhoneNumber: contact.phoneNumber,
|
||||
authorColor,
|
||||
conversationType: isGroup ? 'group' : 'direct',
|
||||
attachment: this.getPropsForAttachment(firstAttachment),
|
||||
quote: this.getPropsForQuote(),
|
||||
authorAvatarPath,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
onReply: () => this.trigger('reply', this),
|
||||
onRetrySend: () => this.retrySend(),
|
||||
onShowDetail: () => this.trigger('show-message-detail', this),
|
||||
onDelete: () => this.trigger('delete', this),
|
||||
onClickAttachment: () =>
|
||||
this.trigger('show-lightbox', {
|
||||
attachment: firstAttachment,
|
||||
message: this,
|
||||
}),
|
||||
|
||||
onDownload: () =>
|
||||
this.trigger('download', {
|
||||
attachment: firstAttachment,
|
||||
message: this,
|
||||
}),
|
||||
};
|
||||
},
|
||||
createNonBreakingLastSeparator(text) {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nbsp = '\xa0';
|
||||
const regex = /(\S)( +)(\S+\s*)$/;
|
||||
return text.replace(regex, (match, start, spaces, end) => {
|
||||
const newSpaces = _.reduce(
|
||||
spaces,
|
||||
accumulator => accumulator + nbsp,
|
||||
''
|
||||
);
|
||||
return `${start}${newSpaces}${end}`;
|
||||
});
|
||||
},
|
||||
getPropsForEmbeddedContact() {
|
||||
const regionCode = storage.get('regionCode');
|
||||
const { contactSelector } = Contact;
|
||||
|
||||
const contacts = this.get('contact');
|
||||
if (!contacts || !contacts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contact = contacts[0];
|
||||
const firstNumber =
|
||||
contact.number && contact.number[0] && contact.number[0].value;
|
||||
const onSendMessage = firstNumber
|
||||
? () => {
|
||||
this.trigger('open-conversation', firstNumber);
|
||||
}
|
||||
: null;
|
||||
const onClick = async () => {
|
||||
// First let's be sure that the signal account check is complete.
|
||||
await window.checkForSignalAccount(firstNumber);
|
||||
|
||||
this.trigger('show-contact-detail', {
|
||||
contact,
|
||||
hasSignalAccount: window.hasSignalAccount(firstNumber),
|
||||
});
|
||||
};
|
||||
|
||||
// Would be nice to do this before render, on initial load of message
|
||||
if (!window.isSignalAccountCheckComplete(firstNumber)) {
|
||||
window.checkForSignalAccount(firstNumber).then(() => {
|
||||
this.trigger('change');
|
||||
});
|
||||
}
|
||||
|
||||
return contactSelector(contact, {
|
||||
regionCode,
|
||||
getAbsoluteAttachmentPath,
|
||||
onSendMessage,
|
||||
onClick,
|
||||
hasSignalAccount: window.hasSignalAccount(firstNumber),
|
||||
});
|
||||
},
|
||||
getPropsForQuote() {
|
||||
const quote = this.get('quote');
|
||||
if (!quote) {
|
||||
|
@ -259,15 +564,14 @@
|
|||
}
|
||||
|
||||
const objectUrl = this.getQuoteObjectUrl();
|
||||
const OUR_NUMBER = textsecure.storage.user.getNumber();
|
||||
const { author } = quote;
|
||||
const contact = this.getQuoteContact();
|
||||
|
||||
const authorTitle = contact ? contact.getTitle() : author;
|
||||
const authorPhoneNumber = author;
|
||||
const authorProfileName = contact ? contact.getProfileName() : null;
|
||||
const authorName = contact ? contact.getName() : null;
|
||||
const authorColor = contact ? contact.getColor() : 'grey';
|
||||
const isFromMe = contact ? contact.id === OUR_NUMBER : false;
|
||||
const isIncoming = this.isIncoming();
|
||||
const isFromMe = contact ? contact.id === this.OUR_NUMBER : false;
|
||||
const onClick = () => {
|
||||
const { quotedMessage } = this;
|
||||
if (quotedMessage) {
|
||||
|
@ -275,55 +579,150 @@
|
|||
}
|
||||
};
|
||||
|
||||
const firstAttachment = quote.attachments && quote.attachments[1];
|
||||
|
||||
return {
|
||||
attachments: (quote.attachments || []).map(attachment =>
|
||||
this.processAttachment(attachment, objectUrl)
|
||||
),
|
||||
authorColor,
|
||||
authorProfileName,
|
||||
authorTitle,
|
||||
text: this.createNonBreakingLastSeparator(quote.text),
|
||||
attachment: firstAttachment
|
||||
? this.processAttachment(firstAttachment, objectUrl)
|
||||
: null,
|
||||
isFromMe,
|
||||
isIncoming,
|
||||
authorPhoneNumber,
|
||||
authorProfileName,
|
||||
authorName,
|
||||
authorColor,
|
||||
onClick: this.quotedMessage ? onClick : null,
|
||||
text: quote.text,
|
||||
};
|
||||
},
|
||||
getPropsForAttachment(attachment) {
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { path, flags, size, screenshot, thumbnail } = attachment;
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
fileSize: size ? filesize(size) : null,
|
||||
isVoiceMessage:
|
||||
flags &&
|
||||
// eslint-disable-next-line no-bitwise
|
||||
flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
|
||||
url: getAbsoluteAttachmentPath(path),
|
||||
screenshot: screenshot
|
||||
? {
|
||||
...screenshot,
|
||||
url: getAbsoluteAttachmentPath(screenshot.path),
|
||||
}
|
||||
: null,
|
||||
thumbnail: thumbnail
|
||||
? {
|
||||
...thumbnail,
|
||||
url: getAbsoluteAttachmentPath(thumbnail.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
getPropsForMessageDetail() {
|
||||
const newIdentity = i18n('newIdentity');
|
||||
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
|
||||
|
||||
// Older messages don't have the recipients included on the message, so we fall
|
||||
// back to the conversation's current recipients
|
||||
const phoneNumbers = this.isIncoming()
|
||||
? [this.get('source')]
|
||||
: this.get('recipients') || this.conversation.getRecipients();
|
||||
|
||||
// This will make the error message for outgoing key errors a bit nicer
|
||||
const allErrors = (this.get('errors') || []).map(error => {
|
||||
if (error.name === OUTGOING_KEY_ERROR) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.message = newIdentity;
|
||||
}
|
||||
|
||||
return error;
|
||||
});
|
||||
|
||||
// If an error has a specific number it's associated with, we'll show it next to
|
||||
// that contact. Otherwise, it will be a standalone entry.
|
||||
const errors = _.reject(allErrors, error => Boolean(error.number));
|
||||
const errorsGroupedById = _.groupBy(allErrors, 'number');
|
||||
const finalContacts = (phoneNumbers || []).map(id => {
|
||||
const errorsForContact = errorsGroupedById[id];
|
||||
const isOutgoingKeyError = Boolean(
|
||||
_.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR)
|
||||
);
|
||||
|
||||
return {
|
||||
...this.findAndFormatContact(id),
|
||||
status: this.getStatus(id),
|
||||
errors: errorsForContact,
|
||||
isOutgoingKeyError,
|
||||
onSendAnyway: () =>
|
||||
this.trigger('force-send', {
|
||||
contact: this.findContact(id),
|
||||
message: this,
|
||||
}),
|
||||
onShowSafetyNumber: () =>
|
||||
this.trigger('show-identity', this.findContact(id)),
|
||||
};
|
||||
});
|
||||
|
||||
// The prefix created here ensures that contacts with errors are listed
|
||||
// first; otherwise it's alphabetical
|
||||
const sortedContacts = _.sortBy(
|
||||
finalContacts,
|
||||
contact => `${contact.errors ? '0' : '1'}${contact.title}`
|
||||
);
|
||||
|
||||
return {
|
||||
sentAt: this.get('sent_at'),
|
||||
receivedAt: this.get('received_at'),
|
||||
message: {
|
||||
...this.getPropsForMessage(),
|
||||
disableMenu: true,
|
||||
// To ensure that group avatar doesn't show up
|
||||
conversationType: 'direct',
|
||||
},
|
||||
errors,
|
||||
contacts: sortedContacts,
|
||||
};
|
||||
},
|
||||
retrySend() {
|
||||
const retries = _.filter(
|
||||
this.get('errors'),
|
||||
this.isReplayableError.bind(this)
|
||||
);
|
||||
_.map(retries, 'number').forEach(number => {
|
||||
this.resend(number);
|
||||
});
|
||||
},
|
||||
getConversation() {
|
||||
// This needs to be an unsafe call, because this method is called during
|
||||
// initial module setup. We may be in the middle of the initial fetch to
|
||||
// the database.
|
||||
return ConversationController.getUnsafe(this.get('conversationId'));
|
||||
},
|
||||
getExpirationTimerUpdateSource() {
|
||||
if (!this.isExpirationTimerUpdate()) {
|
||||
throw new Error('Message is not a timer update!');
|
||||
getIncomingContact() {
|
||||
if (!this.isIncoming()) {
|
||||
return null;
|
||||
}
|
||||
const source = this.get('source');
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conversationId = this.get('expirationTimerUpdate').source;
|
||||
return ConversationController.getOrCreate(conversationId, 'private');
|
||||
return ConversationController.getOrCreate(source, 'private');
|
||||
},
|
||||
getSource() {
|
||||
if (this.isIncoming()) {
|
||||
return this.get('source');
|
||||
}
|
||||
|
||||
return this.OUR_NUMBER;
|
||||
},
|
||||
getContact() {
|
||||
let conversationId = this.get('source');
|
||||
if (!this.isIncoming()) {
|
||||
conversationId = textsecure.storage.user.getNumber();
|
||||
}
|
||||
return ConversationController.getOrCreate(conversationId, 'private');
|
||||
},
|
||||
getModelForKeyChange() {
|
||||
const id = this.get('key_changed');
|
||||
if (!this.modelForKeyChange) {
|
||||
const c = ConversationController.getOrCreate(id, 'private');
|
||||
this.modelForKeyChange = c;
|
||||
}
|
||||
return this.modelForKeyChange;
|
||||
},
|
||||
getModelForVerifiedChange() {
|
||||
const id = this.get('verifiedChanged');
|
||||
if (!this.modelForVerifiedChange) {
|
||||
const c = ConversationController.getOrCreate(id, 'private');
|
||||
this.modelForVerifiedChange = c;
|
||||
}
|
||||
return this.modelForVerifiedChange;
|
||||
return ConversationController.getOrCreate(this.getSource(), 'private');
|
||||
},
|
||||
isOutgoing() {
|
||||
return this.get('type') === 'outgoing';
|
||||
|
|
|
@ -4,7 +4,6 @@ const Backbone = require('../../ts/backbone');
|
|||
const Crypto = require('./crypto');
|
||||
const Database = require('./database');
|
||||
const Emoji = require('../../ts/util/emoji');
|
||||
const Message = require('./types/message');
|
||||
const Notifications = require('../../ts/notifications');
|
||||
const OS = require('../../ts/OS');
|
||||
const Settings = require('./settings');
|
||||
|
@ -18,19 +17,38 @@ const {
|
|||
const { ContactListItem } = require('../../ts/components/ContactListItem');
|
||||
const { ContactName } = require('../../ts/components/conversation/ContactName');
|
||||
const {
|
||||
ConversationTitle,
|
||||
} = require('../../ts/components/conversation/ConversationTitle');
|
||||
ConversationHeader,
|
||||
} = require('../../ts/components/conversation/ConversationHeader');
|
||||
const {
|
||||
EmbeddedContact,
|
||||
} = require('../../ts/components/conversation/EmbeddedContact');
|
||||
const { Emojify } = require('../../ts/components/conversation/Emojify');
|
||||
const {
|
||||
GroupNotification,
|
||||
} = require('../../ts/components/conversation/GroupNotification');
|
||||
const { Lightbox } = require('../../ts/components/Lightbox');
|
||||
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
|
||||
const {
|
||||
MediaGallery,
|
||||
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
|
||||
const { Message } = require('../../ts/components/conversation/Message');
|
||||
const { MessageBody } = require('../../ts/components/conversation/MessageBody');
|
||||
const {
|
||||
MessageDetail,
|
||||
} = require('../../ts/components/conversation/MessageDetail');
|
||||
const { Quote } = require('../../ts/components/conversation/Quote');
|
||||
const {
|
||||
ResetSessionNotification,
|
||||
} = require('../../ts/components/conversation/ResetSessionNotification');
|
||||
const {
|
||||
SafetyNumberNotification,
|
||||
} = require('../../ts/components/conversation/SafetyNumberNotification');
|
||||
const {
|
||||
TimerNotification,
|
||||
} = require('../../ts/components/conversation/TimerNotification');
|
||||
const {
|
||||
VerificationNotification,
|
||||
} = require('../../ts/components/conversation/VerificationNotification');
|
||||
|
||||
// Migrations
|
||||
const {
|
||||
|
@ -42,11 +60,14 @@ const Migrations1DatabaseWithoutAttachmentData = require('./migrations/migration
|
|||
|
||||
// Types
|
||||
const AttachmentType = require('./types/attachment');
|
||||
const VisualAttachment = require('./types/visual_attachment');
|
||||
const Contact = require('../../ts/types/Contact');
|
||||
const Conversation = require('../../ts/types/Conversation');
|
||||
const Errors = require('./types/errors');
|
||||
const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message');
|
||||
const MessageType = require('./types/message');
|
||||
const MIME = require('../../ts/types/MIME');
|
||||
const PhoneNumber = require('../../ts/types/PhoneNumber');
|
||||
const SettingsType = require('../../ts/types/Settings');
|
||||
|
||||
// Views
|
||||
|
@ -57,39 +78,59 @@ const { IdleDetector } = require('./idle_detector');
|
|||
const MessageDataMigrator = require('./messages_data_migrator');
|
||||
|
||||
function initializeMigrations({
|
||||
Attachments,
|
||||
userDataPath,
|
||||
Type,
|
||||
getRegionCode,
|
||||
Attachments,
|
||||
Type,
|
||||
VisualType,
|
||||
}) {
|
||||
if (!Attachments) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
getPath,
|
||||
createReader,
|
||||
createAbsolutePathGetter,
|
||||
createWriterForNew,
|
||||
createWriterForExisting,
|
||||
} = Attachments;
|
||||
const {
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
} = VisualType;
|
||||
|
||||
const attachmentsPath = Attachments.getPath(userDataPath);
|
||||
const readAttachmentData = Attachments.createReader(attachmentsPath);
|
||||
const attachmentsPath = getPath(userDataPath);
|
||||
const readAttachmentData = createReader(attachmentsPath);
|
||||
const loadAttachmentData = Type.loadData(readAttachmentData);
|
||||
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||
|
||||
return {
|
||||
attachmentsPath,
|
||||
deleteAttachmentData: Type.deleteData(
|
||||
Attachments.createDeleter(attachmentsPath)
|
||||
),
|
||||
getAbsoluteAttachmentPath: Attachments.createAbsolutePathGetter(
|
||||
attachmentsPath
|
||||
),
|
||||
getAbsoluteAttachmentPath,
|
||||
getPlaceholderMigrations,
|
||||
loadAttachmentData,
|
||||
loadMessage: Message.createAttachmentLoader(loadAttachmentData),
|
||||
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
||||
Migrations0DatabaseWithAttachmentData,
|
||||
Migrations1DatabaseWithoutAttachmentData,
|
||||
upgradeMessageSchema: message =>
|
||||
Message.upgradeSchema(message, {
|
||||
writeNewAttachmentData: Attachments.createWriterForNew(attachmentsPath),
|
||||
MessageType.upgradeSchema(message, {
|
||||
writeNewAttachmentData: createWriterForNew(attachmentsPath),
|
||||
getRegionCode,
|
||||
getAbsoluteAttachmentPath,
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
}),
|
||||
writeMessageAttachments: Message.createAttachmentDataWriter(
|
||||
Attachments.createWriterForExisting(attachmentsPath)
|
||||
writeMessageAttachments: MessageType.createAttachmentDataWriter(
|
||||
createWriterForExisting(attachmentsPath)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -98,27 +139,35 @@ exports.setup = (options = {}) => {
|
|||
const { Attachments, userDataPath, getRegionCode } = options;
|
||||
|
||||
const Migrations = initializeMigrations({
|
||||
Attachments,
|
||||
userDataPath,
|
||||
Type: AttachmentType,
|
||||
getRegionCode,
|
||||
Attachments,
|
||||
Type: AttachmentType,
|
||||
VisualType: VisualAttachment,
|
||||
});
|
||||
|
||||
const Components = {
|
||||
ContactDetail,
|
||||
ContactListItem,
|
||||
ContactName,
|
||||
ConversationTitle,
|
||||
ConversationHeader,
|
||||
EmbeddedContact,
|
||||
Emojify,
|
||||
GroupNotification,
|
||||
Lightbox,
|
||||
LightboxGallery,
|
||||
MediaGallery,
|
||||
Message,
|
||||
MessageBody,
|
||||
MessageDetail,
|
||||
Quote,
|
||||
ResetSessionNotification,
|
||||
SafetyNumberNotification,
|
||||
TimerNotification,
|
||||
Types: {
|
||||
Message: MediaGalleryMessage,
|
||||
},
|
||||
Quote,
|
||||
VerificationNotification,
|
||||
};
|
||||
|
||||
const Types = {
|
||||
|
@ -126,9 +175,11 @@ exports.setup = (options = {}) => {
|
|||
Contact,
|
||||
Conversation,
|
||||
Errors,
|
||||
Message,
|
||||
Message: MessageType,
|
||||
MIME,
|
||||
PhoneNumber,
|
||||
Settings: SettingsType,
|
||||
VisualAttachment,
|
||||
};
|
||||
|
||||
const Views = {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
const is = require('@sindresorhus/is');
|
||||
|
||||
const AttachmentTS = require('../../../ts/types/Attachment');
|
||||
const GoogleChrome = require('../../../ts/util/GoogleChrome');
|
||||
const MIME = require('../../../ts/types/MIME');
|
||||
const { toLogFormat } = require('./errors');
|
||||
const {
|
||||
arrayBufferToBlob,
|
||||
blobToArrayBuffer,
|
||||
|
@ -181,3 +183,112 @@ exports.deleteData = deleteAttachmentData => {
|
|||
|
||||
exports.isVoiceMessage = AttachmentTS.isVoiceMessage;
|
||||
exports.save = AttachmentTS.save;
|
||||
|
||||
const THUMBNAIL_SIZE = 150;
|
||||
const THUMBNAIL_CONTENT_TYPE = 'image/png';
|
||||
|
||||
exports.captureDimensionsAndScreenshot = async (
|
||||
attachment,
|
||||
{
|
||||
writeNewAttachmentData,
|
||||
getAbsoluteAttachmentPath,
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
}
|
||||
) => {
|
||||
const { contentType } = attachment;
|
||||
|
||||
if (
|
||||
!GoogleChrome.isImageTypeSupported(contentType) &&
|
||||
!GoogleChrome.isVideoTypeSupported(contentType)
|
||||
) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
const absolutePath = await getAbsoluteAttachmentPath(attachment.path);
|
||||
|
||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||
try {
|
||||
const { width, height } = await getImageDimensions(absolutePath);
|
||||
const thumbnailBuffer = await blobToArrayBuffer(
|
||||
await makeImageThumbnail(
|
||||
THUMBNAIL_SIZE,
|
||||
absolutePath,
|
||||
THUMBNAIL_CONTENT_TYPE
|
||||
)
|
||||
);
|
||||
|
||||
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
|
||||
return {
|
||||
...attachment,
|
||||
width,
|
||||
height,
|
||||
thumbnail: {
|
||||
path: thumbnailPath,
|
||||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||||
width: THUMBNAIL_SIZE,
|
||||
height: THUMBNAIL_SIZE,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'captureDimensionsAndScreenshot:',
|
||||
'error processing image; skipping screenshot generation',
|
||||
toLogFormat(error)
|
||||
);
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
|
||||
let screenshotObjectUrl;
|
||||
try {
|
||||
const screenshotBuffer = await blobToArrayBuffer(
|
||||
await makeVideoScreenshot(absolutePath, THUMBNAIL_CONTENT_TYPE)
|
||||
);
|
||||
screenshotObjectUrl = makeObjectUrl(
|
||||
screenshotBuffer,
|
||||
THUMBNAIL_CONTENT_TYPE
|
||||
);
|
||||
const { width, height } = await getImageDimensions(screenshotObjectUrl);
|
||||
const screenshotPath = await writeNewAttachmentData(screenshotBuffer);
|
||||
|
||||
const thumbnailBuffer = await blobToArrayBuffer(
|
||||
await makeImageThumbnail(
|
||||
THUMBNAIL_SIZE,
|
||||
screenshotObjectUrl,
|
||||
THUMBNAIL_CONTENT_TYPE
|
||||
)
|
||||
);
|
||||
|
||||
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
screenshot: {
|
||||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||||
path: screenshotPath,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
thumbnail: {
|
||||
path: thumbnailPath,
|
||||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||||
width: THUMBNAIL_SIZE,
|
||||
height: THUMBNAIL_SIZE,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation',
|
||||
toLogFormat(error)
|
||||
);
|
||||
return attachment;
|
||||
} finally {
|
||||
revokeObjectUrl(screenshotObjectUrl);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -41,6 +41,9 @@ const PRIVATE = 'private';
|
|||
// - `hasVisualMediaAttachments`: Include all images and video regardless of
|
||||
// whether Chromium can render it or not.
|
||||
// - `hasFileAttachments`: Exclude voice messages.
|
||||
// Version 8
|
||||
// - Attachments: Capture video/image dimensions and thumbnails, as well as a
|
||||
// full-size screenshot for video.
|
||||
|
||||
const INITIAL_SCHEMA_VERSION = 0;
|
||||
|
||||
|
@ -128,7 +131,7 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => {
|
|||
upgradedMessage = await upgrade(message, context);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Message._withSchemaVersion: error:',
|
||||
`Message._withSchemaVersion: error updating message ${message.id}:`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return message;
|
||||
|
@ -242,6 +245,11 @@ const toVersion6 = exports._withSchemaVersion(
|
|||
// classified:
|
||||
const toVersion7 = exports._withSchemaVersion(7, initializeAttachmentMetadata);
|
||||
|
||||
const toVersion8 = exports._withSchemaVersion(
|
||||
8,
|
||||
exports._mapAttachments(Attachment.captureDimensionsAndScreenshot)
|
||||
);
|
||||
|
||||
const VERSIONS = [
|
||||
toVersion0,
|
||||
toVersion1,
|
||||
|
@ -251,19 +259,47 @@ const VERSIONS = [
|
|||
toVersion5,
|
||||
toVersion6,
|
||||
toVersion7,
|
||||
toVersion8,
|
||||
];
|
||||
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
|
||||
|
||||
// UpgradeStep
|
||||
exports.upgradeSchema = async (
|
||||
rawMessage,
|
||||
{ writeNewAttachmentData, getRegionCode } = {}
|
||||
{
|
||||
writeNewAttachmentData,
|
||||
getRegionCode,
|
||||
getAbsoluteAttachmentPath,
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
} = {}
|
||||
) => {
|
||||
if (!isFunction(writeNewAttachmentData)) {
|
||||
throw new TypeError('`context.writeNewAttachmentData` is required');
|
||||
throw new TypeError('context.writeNewAttachmentData is required');
|
||||
}
|
||||
if (!isFunction(getRegionCode)) {
|
||||
throw new TypeError('`context.getRegionCode` is required');
|
||||
throw new TypeError('context.getRegionCode is required');
|
||||
}
|
||||
if (!isFunction(getAbsoluteAttachmentPath)) {
|
||||
throw new TypeError('context.getAbsoluteAttachmentPath is required');
|
||||
}
|
||||
if (!isFunction(makeObjectUrl)) {
|
||||
throw new TypeError('context.makeObjectUrl is required');
|
||||
}
|
||||
if (!isFunction(revokeObjectUrl)) {
|
||||
throw new TypeError('context.revokeObjectUrl is required');
|
||||
}
|
||||
if (!isFunction(getImageDimensions)) {
|
||||
throw new TypeError('context.getImageDimensions is required');
|
||||
}
|
||||
if (!isFunction(makeImageThumbnail)) {
|
||||
throw new TypeError('context.makeImageThumbnail is required');
|
||||
}
|
||||
if (!isFunction(makeVideoScreenshot)) {
|
||||
throw new TypeError('context.makeVideoScreenshot is required');
|
||||
}
|
||||
|
||||
let message = rawMessage;
|
||||
|
@ -275,6 +311,12 @@ exports.upgradeSchema = async (
|
|||
message = await currentVersion(message, {
|
||||
writeNewAttachmentData,
|
||||
regionCode: getRegionCode(),
|
||||
getAbsoluteAttachmentPath,
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
126
js/modules/types/visual_attachment.js
Normal file
|
@ -0,0 +1,126 @@
|
|||
/* global document, URL, Blob */
|
||||
|
||||
const loadImage = require('blueimp-load-image');
|
||||
const { toLogFormat } = require('./errors');
|
||||
const dataURLToBlobSync = require('blueimp-canvas-to-blob');
|
||||
const { blobToArrayBuffer } = require('blob-util');
|
||||
const {
|
||||
arrayBufferToObjectURL,
|
||||
} = require('../../../ts/util/arrayBufferToObjectURL');
|
||||
|
||||
exports.blobToArrayBuffer = blobToArrayBuffer;
|
||||
|
||||
exports.getImageDimensions = objectUrl =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = document.createElement('img');
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
resolve({
|
||||
height: image.naturalHeight,
|
||||
width: image.naturalWidth,
|
||||
});
|
||||
});
|
||||
image.addEventListener('error', error => {
|
||||
console.log('getImageDimensions error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
|
||||
image.src = objectUrl;
|
||||
});
|
||||
|
||||
exports.makeImageThumbnail = (size, objectUrl, contentType = 'image/png') =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = document.createElement('img');
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
// using components/blueimp-load-image
|
||||
|
||||
// first, make the correct size
|
||||
let canvas = loadImage.scale(image, {
|
||||
canvas: true,
|
||||
cover: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
// then crop
|
||||
canvas = loadImage.scale(canvas, {
|
||||
canvas: true,
|
||||
crop: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
const blob = dataURLToBlobSync(canvas.toDataURL(contentType));
|
||||
|
||||
resolve(blob);
|
||||
});
|
||||
|
||||
image.addEventListener('error', error => {
|
||||
console.log('makeImageThumbnail error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
|
||||
image.src = objectUrl;
|
||||
});
|
||||
|
||||
exports.makeVideoScreenshot = (objectUrl, contentType = 'image/png') =>
|
||||
new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
|
||||
function capture() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas
|
||||
.getContext('2d')
|
||||
.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const image = dataURLToBlobSync(canvas.toDataURL(contentType));
|
||||
|
||||
video.removeEventListener('canplay', capture);
|
||||
|
||||
resolve(image);
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', capture);
|
||||
video.addEventListener('error', error => {
|
||||
console.log('makeVideoThumbnail error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
|
||||
video.src = objectUrl;
|
||||
});
|
||||
|
||||
exports.makeVideoThumbnail = async (size, videoObjectUrl) => {
|
||||
let screenshotObjectUrl;
|
||||
try {
|
||||
const type = 'image/png';
|
||||
const blob = await exports.makeVideoScreenshot(videoObjectUrl, type);
|
||||
const data = await blobToArrayBuffer(blob);
|
||||
screenshotObjectUrl = arrayBufferToObjectURL({
|
||||
data,
|
||||
type,
|
||||
});
|
||||
|
||||
return exports.makeImageThumbnail(size, screenshotObjectUrl);
|
||||
} finally {
|
||||
exports.revokeObjectUrl(screenshotObjectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
exports.makeObjectUrl = (data, contentType) => {
|
||||
const blob = new Blob([data], {
|
||||
type: contentType,
|
||||
});
|
||||
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
exports.revokeObjectUrl = objectUrl => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
|
@ -13,9 +13,6 @@
|
|||
tagName: 'div',
|
||||
className: 'contact',
|
||||
templateName: 'contact',
|
||||
events: {
|
||||
click: 'showIdentity',
|
||||
},
|
||||
initialize(options) {
|
||||
this.ourNumber = textsecure.storage.user.getNumber();
|
||||
this.listenBack = options.listenBack;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* global Whisper, getInboxCollection */
|
||||
/* global Whisper, getInboxCollection, $ */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
/* global _: false */
|
||||
/* global emojiData: false */
|
||||
/* global EmojiPanel: false */
|
||||
/* global moment: false */
|
||||
/* global extension: false */
|
||||
/* global i18n: false */
|
||||
/* global Signal: false */
|
||||
|
@ -14,6 +13,7 @@
|
|||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
const { Migrations } = Signal;
|
||||
|
||||
Whisper.ExpiredToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
|
@ -31,42 +31,6 @@
|
|||
},
|
||||
});
|
||||
|
||||
const MenuView = Whisper.View.extend({
|
||||
toggleMenu() {
|
||||
this.$('.menu-list').toggle();
|
||||
},
|
||||
});
|
||||
|
||||
const TimerMenuView = MenuView.extend({
|
||||
initialize() {
|
||||
this.render();
|
||||
this.listenTo(this.model, 'change:expireTimer', this.render);
|
||||
},
|
||||
events: {
|
||||
'click button': 'toggleMenu',
|
||||
'click li': 'setTimer',
|
||||
},
|
||||
setTimer(e) {
|
||||
const { seconds } = this.$(e.target).data();
|
||||
if (seconds > 0) {
|
||||
this.model.updateExpirationTimer(seconds);
|
||||
} else {
|
||||
this.model.updateExpirationTimer(null);
|
||||
}
|
||||
},
|
||||
render() {
|
||||
const seconds = this.model.get('expireTimer');
|
||||
if (seconds) {
|
||||
const s = Whisper.ExpirationTimerOptions.getAbbreviated(seconds);
|
||||
this.$el.attr('data-time', s);
|
||||
this.$el.show();
|
||||
} else {
|
||||
this.$el.attr('data-time', null);
|
||||
this.$el.hide();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.ConversationLoadingScreen = Whisper.View.extend({
|
||||
templateName: 'conversation-loading-screen',
|
||||
className: 'conversation-loading-screen',
|
||||
|
@ -82,35 +46,23 @@
|
|||
template: $('#conversation').html(),
|
||||
render_attributes() {
|
||||
return {
|
||||
group: this.model.get('type') === 'group',
|
||||
isMe: this.model.isMe(),
|
||||
avatar: this.model.getAvatar(),
|
||||
expireTimer: this.model.get('expireTimer'),
|
||||
'show-members': i18n('showMembers'),
|
||||
'end-session': i18n('resetSession'),
|
||||
'show-identity': i18n('showSafetyNumber'),
|
||||
destroy: i18n('deleteMessages'),
|
||||
'send-message': i18n('sendMessage'),
|
||||
'disappearing-messages': i18n('disappearingMessages'),
|
||||
'android-length-warning': i18n('androidMessageLengthWarning'),
|
||||
timer_options: Whisper.ExpirationTimerOptions.models,
|
||||
'view-all-media': i18n('viewAllMedia'),
|
||||
};
|
||||
},
|
||||
initialize(options) {
|
||||
this.listenTo(this.model, 'destroy', this.stopListening);
|
||||
this.listenTo(this.model, 'change:verified', this.onVerifiedChange);
|
||||
this.listenTo(this.model, 'change:color', this.updateColor);
|
||||
this.listenTo(
|
||||
this.model,
|
||||
'change:avatar change:profileAvatar',
|
||||
this.updateAvatar
|
||||
);
|
||||
this.listenTo(this.model, 'newmessage', this.addMessage);
|
||||
this.listenTo(this.model, 'delivered', this.updateMessage);
|
||||
this.listenTo(this.model, 'read', this.updateMessage);
|
||||
this.listenTo(this.model, 'opened', this.onOpened);
|
||||
this.listenTo(this.model, 'prune', this.onPrune);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'show-identity',
|
||||
this.showSafetyNumber
|
||||
);
|
||||
this.listenTo(this.model.messageCollection, 'force-send', this.forceSend);
|
||||
this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'scroll-to-message',
|
||||
|
@ -126,11 +78,26 @@
|
|||
'show-contact-detail',
|
||||
this.showContactDetail
|
||||
);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'show-lightbox',
|
||||
this.showLightbox
|
||||
);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'download',
|
||||
this.downloadAttachment
|
||||
);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'open-conversation',
|
||||
this.openConversation
|
||||
);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'show-message-detail',
|
||||
this.showMessageDetail
|
||||
);
|
||||
|
||||
this.lazyUpdateVerified = _.debounce(
|
||||
this.model.updateVerified.bind(this.model),
|
||||
|
@ -145,12 +112,7 @@
|
|||
|
||||
this.loadingScreen = new Whisper.ConversationLoadingScreen();
|
||||
this.loadingScreen.render();
|
||||
this.loadingScreen.$el.prependTo(this.el);
|
||||
|
||||
this.timerMenu = new TimerMenuView({
|
||||
el: this.$('.timer-menu'),
|
||||
model: this.model,
|
||||
});
|
||||
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
|
||||
|
||||
this.window = options.window;
|
||||
this.fileInput = new Whisper.FileInputView({
|
||||
|
@ -158,21 +120,64 @@
|
|||
window: this.window,
|
||||
});
|
||||
|
||||
const getTitleProps = model => ({
|
||||
isVerified: model.isVerified(),
|
||||
name: model.getName(),
|
||||
phoneNumber: model.getNumber(),
|
||||
profileName: model.getProfileName(),
|
||||
});
|
||||
const getHeaderProps = () => {
|
||||
const avatar = this.model.getAvatar();
|
||||
const avatarPath = avatar ? avatar.url : null;
|
||||
const expireTimer = this.model.get('expireTimer');
|
||||
const expirationSettingName = expireTimer
|
||||
? Whisper.ExpirationTimerOptions.getName(expireTimer || 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: this.model.id,
|
||||
name: this.model.getName(),
|
||||
phoneNumber: this.model.getNumber(),
|
||||
profileName: this.model.getProfileName(),
|
||||
color: this.model.getColor(),
|
||||
avatarPath,
|
||||
isVerified: this.model.isVerified(),
|
||||
isMe: this.model.isMe(),
|
||||
isGroup: !this.model.isPrivate(),
|
||||
expirationSettingName,
|
||||
showBackButton: Boolean(this.panels && this.panels.length),
|
||||
timerOptions: Whisper.ExpirationTimerOptions.map(item => ({
|
||||
name: item.getName(),
|
||||
value: item.get('seconds'),
|
||||
})),
|
||||
|
||||
onSetDisappearingMessages: seconds =>
|
||||
this.setDisappearingMessages(seconds),
|
||||
onDeleteMessages: () => this.destroyMessages(),
|
||||
onResetSession: () => this.endSession(),
|
||||
|
||||
// These are view only and done update the Conversation model, so they
|
||||
// need a manual update call.
|
||||
onShowSafetyNumber: () => {
|
||||
this.showSafetyNumber();
|
||||
this.updateHeader();
|
||||
},
|
||||
onShowAllMedia: async () => {
|
||||
await this.showAllMedia();
|
||||
this.updateHeader();
|
||||
},
|
||||
onShowGroupMembers: () => {
|
||||
this.showMembers();
|
||||
this.updateHeader();
|
||||
},
|
||||
onGoBack: () => {
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
};
|
||||
};
|
||||
this.titleView = new Whisper.ReactWrapperView({
|
||||
className: 'title-wrapper',
|
||||
Component: window.Signal.Components.ConversationTitle,
|
||||
props: getTitleProps(this.model),
|
||||
Component: window.Signal.Components.ConversationHeader,
|
||||
props: getHeaderProps(this.model),
|
||||
});
|
||||
this.listenTo(this.model, 'change', () =>
|
||||
this.titleView.update(getTitleProps(this.model))
|
||||
);
|
||||
this.$('.conversation-title').prepend(this.titleView.el);
|
||||
this.updateHeader = () => this.titleView.update(getHeaderProps());
|
||||
this.listenTo(this.model, 'change', this.updateHeader);
|
||||
this.$('.conversation-header').append(this.titleView.el);
|
||||
|
||||
this.view = new Whisper.MessageListView({
|
||||
collection: this.model.messageCollection,
|
||||
|
@ -210,20 +215,10 @@
|
|||
'submit .send': 'checkUnverifiedSendMessage',
|
||||
'input .send-message': 'updateMessageFieldSize',
|
||||
'keydown .send-message': 'updateMessageFieldSize',
|
||||
'click .destroy': 'destroyMessages',
|
||||
'click .end-session': 'endSession',
|
||||
'click .leave-group': 'leaveGroup',
|
||||
'click .update-group': 'newGroupUpdate',
|
||||
'click .show-identity': 'showSafetyNumber',
|
||||
'click .show-members': 'showMembers',
|
||||
'click .view-all-media': 'viewAllMedia',
|
||||
'click .conversation-menu .hamburger': 'toggleMenu',
|
||||
click: 'onClick',
|
||||
'click .bottom-bar': 'focusMessageField',
|
||||
'click .back': 'resetPanel',
|
||||
'click .capture-audio .microphone': 'captureAudio',
|
||||
'click .disappearing-messages': 'enableDisappearingMessages',
|
||||
'click .scroll-down-button-view': 'scrollToBottom',
|
||||
'click .module-scroll-down': 'scrollToBottom',
|
||||
'click button.emoji': 'toggleEmojiPanel',
|
||||
'focus .send-message': 'focusBottomBar',
|
||||
'change .file-input': 'toggleMicrophone',
|
||||
|
@ -233,10 +228,7 @@
|
|||
'atBottom .message-list': 'removeScrollDownButton',
|
||||
'farFromBottom .message-list': 'addScrollDownButton',
|
||||
'lazyScroll .message-list': 'onLazyScroll',
|
||||
'close .menu': 'closeMenu',
|
||||
'select .message-list .entry': 'messageDetail',
|
||||
'force-resize': 'forceUpdateMessageFieldSize',
|
||||
'show-identity': 'showSafetyNumber',
|
||||
dragover: 'sendToFileInput',
|
||||
drop: 'sendToFileInput',
|
||||
dragleave: 'sendToFileInput',
|
||||
|
@ -269,7 +261,6 @@
|
|||
reason
|
||||
);
|
||||
|
||||
this.timerMenu.remove();
|
||||
this.fileInput.remove();
|
||||
this.titleView.remove();
|
||||
|
||||
|
@ -288,6 +279,9 @@
|
|||
if (this.quoteView) {
|
||||
this.quoteView.remove();
|
||||
}
|
||||
if (this.lightBoxView) {
|
||||
this.lightBoxView.remove();
|
||||
}
|
||||
if (this.lightboxGalleryView) {
|
||||
this.lightboxGalleryView.remove();
|
||||
}
|
||||
|
@ -362,7 +356,7 @@
|
|||
|
||||
openSafetyNumberScreens(unverified) {
|
||||
if (unverified.length === 1) {
|
||||
this.showSafetyNumber(null, unverified.at(0));
|
||||
this.showSafetyNumber(unverified.at(0));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -406,11 +400,6 @@
|
|||
}
|
||||
},
|
||||
|
||||
enableDisappearingMessages() {
|
||||
if (!this.model.get('expireTimer')) {
|
||||
this.model.updateExpirationTimer(moment.duration(1, 'day').asSeconds());
|
||||
}
|
||||
},
|
||||
toggleMicrophone() {
|
||||
if (
|
||||
this.$('.send-message').val().length > 0 ||
|
||||
|
@ -591,11 +580,7 @@
|
|||
el[0].scrollIntoView();
|
||||
},
|
||||
|
||||
async viewAllMedia() {
|
||||
// We have to do this manually, since our React component will not propagate click
|
||||
// events up to its parent elements in the DOM.
|
||||
this.closeMenu();
|
||||
|
||||
async showAllMedia() {
|
||||
// We fetch more documents than media as they don’t require to be loaded
|
||||
// into memory right away. Revisit this once we have infinite scrolling:
|
||||
const DEFAULT_MEDIA_FETCH_COUNT = 50;
|
||||
|
@ -620,7 +605,7 @@
|
|||
|
||||
// NOTE: Could we show grid previews from disk as well?
|
||||
const loadMessages = Signal.Components.Types.Message.loadWithObjectURL(
|
||||
Signal.Migrations.loadMessage
|
||||
Migrations.loadMessage
|
||||
);
|
||||
const media = await loadMessages(rawMedia);
|
||||
|
||||
|
@ -655,6 +640,7 @@
|
|||
mediaMessage => mediaMessage.id === message.id
|
||||
);
|
||||
this.lightboxGalleryView = new Whisper.ReactWrapperView({
|
||||
className: 'lightbox-wrapper',
|
||||
Component: Signal.Components.LightboxGallery,
|
||||
props: {
|
||||
messages: mediaWithObjectURL,
|
||||
|
@ -673,6 +659,7 @@
|
|||
};
|
||||
|
||||
const view = new Whisper.ReactWrapperView({
|
||||
className: 'panel-wrapper',
|
||||
Component: Signal.Components.MediaGallery,
|
||||
props: {
|
||||
documents,
|
||||
|
@ -840,14 +827,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
updateMessage(message) {
|
||||
this.model.messageCollection.add(message, { merge: true });
|
||||
},
|
||||
|
||||
onClick(e) {
|
||||
onClick() {
|
||||
// If there are sub-panels open, we don't want to respond to clicks
|
||||
if (!this.panels || !this.panels.length) {
|
||||
this.closeMenu(e);
|
||||
this.markRead();
|
||||
}
|
||||
},
|
||||
|
@ -930,7 +913,31 @@
|
|||
this.listenBack(view);
|
||||
},
|
||||
|
||||
showSafetyNumber(e, providedModel) {
|
||||
forceSend({ contact, message }) {
|
||||
const dialog = new Whisper.ConfirmationDialogView({
|
||||
message: i18n('identityKeyErrorOnSend'),
|
||||
okText: i18n('sendAnyway'),
|
||||
resolve: async () => {
|
||||
await contact.updateVerified();
|
||||
|
||||
if (contact.isUnverified()) {
|
||||
await contact.setVerifiedDefault();
|
||||
}
|
||||
|
||||
const untrusted = await contact.isUntrusted();
|
||||
if (untrusted) {
|
||||
await contact.setApproved();
|
||||
}
|
||||
|
||||
message.resend(contact.id);
|
||||
},
|
||||
});
|
||||
|
||||
this.$el.prepend(dialog.el);
|
||||
dialog.focusCancel();
|
||||
},
|
||||
|
||||
showSafetyNumber(providedModel) {
|
||||
let model = providedModel;
|
||||
|
||||
if (!model && this.model.isPrivate()) {
|
||||
|
@ -945,26 +952,78 @@
|
|||
}
|
||||
},
|
||||
|
||||
messageDetail(e, data) {
|
||||
const view = new Whisper.MessageDetailView({
|
||||
model: data.message,
|
||||
conversation: this.model,
|
||||
// we pass these in to allow nested panels
|
||||
listenBack: this.listenBack.bind(this),
|
||||
resetPanel: this.resetPanel.bind(this),
|
||||
downloadAttachment({ attachment, message }) {
|
||||
const { getAbsoluteAttachmentPath } = Migrations;
|
||||
|
||||
Signal.Types.Attachment.save({
|
||||
attachment,
|
||||
document,
|
||||
getAbsolutePath: getAbsoluteAttachmentPath,
|
||||
timestamp: message.get('sent_at'),
|
||||
});
|
||||
this.listenBack(view);
|
||||
view.render();
|
||||
},
|
||||
|
||||
// not currently in use
|
||||
newGroupUpdate() {
|
||||
const view = new Whisper.NewGroupUpdateView({
|
||||
model: this.model,
|
||||
window: this.window,
|
||||
deleteMessage(message) {
|
||||
const dialog = new Whisper.ConfirmationDialogView({
|
||||
message: i18n('deleteWarning'),
|
||||
okText: i18n('delete'),
|
||||
resolve: () => {
|
||||
message.destroy();
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
});
|
||||
view.render();
|
||||
|
||||
this.$el.prepend(dialog.el);
|
||||
dialog.focusCancel();
|
||||
},
|
||||
|
||||
showLightbox({ attachment, message }) {
|
||||
const { getAbsoluteAttachmentPath } = Migrations;
|
||||
const { contentType, path } = attachment;
|
||||
|
||||
if (
|
||||
!Signal.Util.GoogleChrome.isImageTypeSupported(contentType) &&
|
||||
!Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
|
||||
) {
|
||||
this.downloadAttachment({ attachment, message });
|
||||
return;
|
||||
}
|
||||
|
||||
const props = {
|
||||
objectURL: getAbsoluteAttachmentPath(path),
|
||||
contentType,
|
||||
onSave: () => this.downloadAttachment({ attachment, message }),
|
||||
};
|
||||
this.lightboxView = new Whisper.ReactWrapperView({
|
||||
className: 'lightbox-wrapper',
|
||||
Component: Signal.Components.Lightbox,
|
||||
props,
|
||||
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
|
||||
});
|
||||
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
|
||||
},
|
||||
|
||||
showMessageDetail(message) {
|
||||
const props = message.getPropsForMessageDetail();
|
||||
const view = new Whisper.ReactWrapperView({
|
||||
className: 'message-detail-wrapper',
|
||||
Component: Signal.Components.MessageDetail,
|
||||
props,
|
||||
onClose: () => {
|
||||
this.stopListening(message, 'change', update);
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
});
|
||||
|
||||
const update = () => view.update(message.getPropsForMessageDetail());
|
||||
this.listenTo(message, 'change', update);
|
||||
// We could listen to all involved contacts, but we'll call that overkill
|
||||
|
||||
this.listenBack(view);
|
||||
this.updateHeader();
|
||||
view.render();
|
||||
},
|
||||
|
||||
showContactDetail({ contact, hasSignalAccount }) {
|
||||
|
@ -989,7 +1048,10 @@
|
|||
}
|
||||
},
|
||||
},
|
||||
onClose: () => this.resetPanel(),
|
||||
onClose: () => {
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
});
|
||||
|
||||
this.listenBack(view);
|
||||
|
@ -1009,57 +1071,45 @@
|
|||
this.panels[0].$el.hide();
|
||||
}
|
||||
this.panels.unshift(view);
|
||||
|
||||
if (this.panels.length === 1) {
|
||||
this.$('.main.panel, .header-buttons.right').hide();
|
||||
this.$('.back').show();
|
||||
}
|
||||
|
||||
view.$el.insertBefore(this.$('.panel').first());
|
||||
},
|
||||
resetPanel() {
|
||||
if (!this.panels || !this.panels.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.panels.shift();
|
||||
|
||||
if (this.panels.length > 0) {
|
||||
this.panels[0].$el.show();
|
||||
}
|
||||
view.remove();
|
||||
|
||||
if (this.panels.length === 0) {
|
||||
this.$('.main.panel, .header-buttons.right').show();
|
||||
this.$('.back').hide();
|
||||
this.$el.trigger('force-resize');
|
||||
}
|
||||
},
|
||||
|
||||
closeMenu(e) {
|
||||
if (e && !$(e.target).hasClass('hamburger')) {
|
||||
this.$('.conversation-menu .menu-list').hide();
|
||||
}
|
||||
if (e && !$(e.target).hasClass('clock')) {
|
||||
this.$('.timer-menu .menu-list').hide();
|
||||
}
|
||||
},
|
||||
|
||||
endSession() {
|
||||
this.model.endSession();
|
||||
this.$('.menu-list').hide();
|
||||
},
|
||||
|
||||
leaveGroup() {
|
||||
this.model.leaveGroup();
|
||||
this.$('.menu-list').hide();
|
||||
},
|
||||
|
||||
toggleMenu() {
|
||||
this.$('.conversation-menu .menu-list').toggle();
|
||||
setDisappearingMessages(seconds) {
|
||||
if (seconds > 0) {
|
||||
this.model.updateExpirationTimer(seconds);
|
||||
} else {
|
||||
this.model.updateExpirationTimer(null);
|
||||
}
|
||||
},
|
||||
|
||||
async destroyMessages() {
|
||||
this.$('.menu-list').hide();
|
||||
|
||||
await this.confirm(i18n('deleteConversationConfirmation'));
|
||||
this.model.destroyMessages();
|
||||
this.remove();
|
||||
try {
|
||||
await this.confirm(i18n('deleteConversationConfirmation'));
|
||||
await this.model.destroyMessages();
|
||||
this.remove();
|
||||
} catch (error) {
|
||||
// nothing to see here
|
||||
}
|
||||
},
|
||||
|
||||
showSendConfirmationDialog(e, contacts) {
|
||||
|
@ -1247,24 +1297,21 @@
|
|||
|
||||
const contact = this.quotedMessage.getContact();
|
||||
if (contact) {
|
||||
this.listenTo(contact, 'change:color', this.renderQuotedMesage);
|
||||
this.listenTo(contact, 'change', this.renderQuotedMesage);
|
||||
}
|
||||
|
||||
this.quoteView = new Whisper.ReactWrapperView({
|
||||
className: 'quote-wrapper',
|
||||
Component: window.Signal.Components.Quote,
|
||||
props: Object.assign({}, props, {
|
||||
text: props.text,
|
||||
withContentAbove: true,
|
||||
onClose: () => {
|
||||
this.setQuoteMessage(null);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const selector =
|
||||
storage.get('theme-setting') === 'ios' ? '.bottom-bar' : '.send';
|
||||
|
||||
this.$(selector).prepend(this.quoteView.el);
|
||||
this.$('.send').prepend(this.quoteView.el);
|
||||
this.updateMessageFieldSize({});
|
||||
},
|
||||
|
||||
|
@ -1319,28 +1366,6 @@
|
|||
}
|
||||
},
|
||||
|
||||
updateColor(model, color) {
|
||||
const header = this.$('.conversation-header');
|
||||
header.removeClass(Whisper.Conversation.COLORS);
|
||||
if (color) {
|
||||
header.addClass(color);
|
||||
}
|
||||
const avatarView = new (Whisper.View.extend({
|
||||
templateName: 'avatar',
|
||||
render_attributes: { avatar: this.model.getAvatar() },
|
||||
}))();
|
||||
header.find('.avatar').replaceWith(avatarView.render().$('.avatar'));
|
||||
},
|
||||
|
||||
updateAvatar() {
|
||||
const header = this.$('.conversation-header');
|
||||
const avatarView = new (Whisper.View.extend({
|
||||
templateName: 'avatar',
|
||||
render_attributes: { avatar: this.model.getAvatar() },
|
||||
}))();
|
||||
header.find('.avatar').replaceWith(avatarView.render().$('.avatar'));
|
||||
},
|
||||
|
||||
updateMessageFieldSize(event) {
|
||||
const keyCode = event.which || event.keyCode;
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { MIME } = window.Signal.Types;
|
||||
const { MIME, VisualAttachment } = window.Signal.Types;
|
||||
|
||||
Whisper.FileSizeToast = Whisper.ToastView.extend({
|
||||
templateName: 'file-size-modal',
|
||||
|
@ -28,98 +28,6 @@
|
|||
template: i18n('unsupportedFileType'),
|
||||
});
|
||||
|
||||
function makeImageThumbnail(size, objectUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement('img');
|
||||
img.onerror = reject;
|
||||
img.onload = () => {
|
||||
// using components/blueimp-load-image
|
||||
|
||||
// first, make the correct size
|
||||
let canvas = loadImage.scale(img, {
|
||||
canvas: true,
|
||||
cover: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
// then crop
|
||||
canvas = loadImage.scale(canvas, {
|
||||
canvas: true,
|
||||
crop: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
const blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
|
||||
|
||||
resolve(blob);
|
||||
};
|
||||
img.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function makeVideoScreenshot(objectUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
|
||||
function capture() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas
|
||||
.getContext('2d')
|
||||
.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const image = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
|
||||
|
||||
video.removeEventListener('canplay', capture);
|
||||
|
||||
resolve(image);
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', capture);
|
||||
video.addEventListener('error', error => {
|
||||
console.log(
|
||||
'makeVideoThumbnail error',
|
||||
Signal.Types.Errors.toLogFormat(error)
|
||||
);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
video.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function blobToArrayBuffer(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = e => resolve(e.target.result);
|
||||
fileReader.onerror = reject;
|
||||
fileReader.onabort = reject;
|
||||
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function makeVideoThumbnail(size, videoObjectUrl) {
|
||||
const blob = await makeVideoScreenshot(videoObjectUrl);
|
||||
const data = await blobToArrayBuffer(blob);
|
||||
const screenshotObjectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
data,
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
const thumbnail = await makeImageThumbnail(size, screenshotObjectUrl);
|
||||
URL.revokeObjectURL(screenshotObjectUrl);
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
Whisper.FileInputView = Backbone.View.extend({
|
||||
tagName: 'span',
|
||||
className: 'file-input',
|
||||
|
@ -252,10 +160,14 @@
|
|||
const renderVideoPreview = async () => {
|
||||
// we use the variable on this here to ensure cleanup if we're interrupted
|
||||
this.previewObjectUrl = URL.createObjectURL(file);
|
||||
const thumbnail = await makeVideoScreenshot(this.previewObjectUrl);
|
||||
const type = 'image/png';
|
||||
const thumbnail = await VisualAttachment.makeVideoScreenshot(
|
||||
this.previewObjectUrl,
|
||||
type
|
||||
);
|
||||
URL.revokeObjectURL(this.previewObjectUrl);
|
||||
|
||||
const data = await blobToArrayBuffer(thumbnail);
|
||||
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
|
||||
this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
data,
|
||||
type: 'image/png',
|
||||
|
@ -385,7 +297,10 @@
|
|||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
const arrayBuffer = await makeImageThumbnail(size, objectUrl);
|
||||
const arrayBuffer = await VisualAttachment.makeImageThumbnail(
|
||||
size,
|
||||
objectUrl
|
||||
);
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
return this.readFile(arrayBuffer);
|
||||
|
@ -482,8 +397,4 @@
|
|||
}
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.FileInputView.makeImageThumbnail = makeImageThumbnail;
|
||||
Whisper.FileInputView.makeVideoThumbnail = makeVideoThumbnail;
|
||||
Whisper.FileInputView.makeVideoScreenshot = makeVideoScreenshot;
|
||||
})();
|
||||
|
|
|
@ -1,55 +0,0 @@
|
|||
/* global Whisper, i18n */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.IdentityKeySendErrorPanelView = Whisper.View.extend({
|
||||
className: 'identity-key-send-error panel',
|
||||
templateName: 'identity-key-send-error',
|
||||
initialize(options) {
|
||||
this.listenBack = options.listenBack;
|
||||
this.resetPanel = options.resetPanel;
|
||||
|
||||
this.wasUnverified = this.model.isUnverified();
|
||||
this.listenTo(this.model, 'change', this.render);
|
||||
},
|
||||
events: {
|
||||
'click .show-safety-number': 'showSafetyNumber',
|
||||
'click .send-anyway': 'sendAnyway',
|
||||
'click .cancel': 'cancel',
|
||||
},
|
||||
showSafetyNumber() {
|
||||
const view = new Whisper.KeyVerificationPanelView({
|
||||
model: this.model,
|
||||
});
|
||||
this.listenBack(view);
|
||||
},
|
||||
sendAnyway() {
|
||||
this.resetPanel();
|
||||
this.trigger('send-anyway');
|
||||
},
|
||||
cancel() {
|
||||
this.resetPanel();
|
||||
},
|
||||
render_attributes() {
|
||||
let send = i18n('sendAnyway');
|
||||
if (this.wasUnverified && !this.model.isUnverified()) {
|
||||
send = i18n('resend');
|
||||
}
|
||||
|
||||
const errorExplanation = i18n('identityKeyErrorOnSend', [
|
||||
this.model.getTitle(),
|
||||
this.model.getTitle(),
|
||||
]);
|
||||
return {
|
||||
errorExplanation,
|
||||
showSafetyNumber: i18n('showSafetyNumber'),
|
||||
sendAnyway: send,
|
||||
cancel: i18n('cancel'),
|
||||
};
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -108,7 +108,9 @@
|
|||
const inboxCollection = getInboxCollection();
|
||||
|
||||
inboxCollection.on('messageError', () => {
|
||||
this.networkStatusView.render();
|
||||
if (this.networkStatusView) {
|
||||
this.networkStatusView.render();
|
||||
}
|
||||
});
|
||||
|
||||
this.inboxListView = new Whisper.ConversationListView({
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.LastSeenIndicatorView = Whisper.View.extend({
|
||||
className: 'last-seen-indicator-view',
|
||||
className: 'module-last-seen-indicator',
|
||||
templateName: 'last-seen-indicator-view',
|
||||
initialize(options = {}) {
|
||||
this.count = options.count || 0;
|
||||
|
|
|
@ -1,182 +0,0 @@
|
|||
/* global Whisper, i18n, _, ConversationController, Mustache, moment */
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const ContactView = Whisper.View.extend({
|
||||
className: 'contact-detail',
|
||||
templateName: 'contact-detail',
|
||||
initialize(options) {
|
||||
this.listenBack = options.listenBack;
|
||||
this.resetPanel = options.resetPanel;
|
||||
this.message = options.message;
|
||||
|
||||
const newIdentity = i18n('newIdentity');
|
||||
this.errors = _.map(options.errors, error => {
|
||||
if (error.name === 'OutgoingIdentityKeyError') {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.message = newIdentity;
|
||||
}
|
||||
return error;
|
||||
});
|
||||
this.outgoingKeyError = _.find(
|
||||
this.errors,
|
||||
error => error.name === 'OutgoingIdentityKeyError'
|
||||
);
|
||||
},
|
||||
events: {
|
||||
click: 'onClick',
|
||||
},
|
||||
onClick() {
|
||||
if (this.outgoingKeyError) {
|
||||
const view = new Whisper.IdentityKeySendErrorPanelView({
|
||||
model: this.model,
|
||||
listenBack: this.listenBack,
|
||||
resetPanel: this.resetPanel,
|
||||
});
|
||||
|
||||
this.listenTo(view, 'send-anyway', this.onSendAnyway);
|
||||
|
||||
view.render();
|
||||
|
||||
this.listenBack(view);
|
||||
view.$('.cancel').focus();
|
||||
}
|
||||
},
|
||||
forceSend() {
|
||||
this.model
|
||||
.updateVerified()
|
||||
.then(() => {
|
||||
if (this.model.isUnverified()) {
|
||||
return this.model.setVerifiedDefault();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.then(() => this.model.isUntrusted())
|
||||
.then(untrusted => {
|
||||
if (untrusted) {
|
||||
return this.model.setApproved();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.then(() => {
|
||||
this.message.resend(this.outgoingKeyError.number);
|
||||
});
|
||||
},
|
||||
onSendAnyway() {
|
||||
if (this.outgoingKeyError) {
|
||||
this.forceSend();
|
||||
}
|
||||
},
|
||||
render_attributes() {
|
||||
const showButton = Boolean(this.outgoingKeyError);
|
||||
|
||||
return {
|
||||
status: this.message.getStatus(this.model.id),
|
||||
name: this.model.getTitle(),
|
||||
avatar: this.model.getAvatar(),
|
||||
errors: this.errors,
|
||||
showErrorButton: showButton,
|
||||
errorButtonLabel: i18n('view'),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.MessageDetailView = Whisper.View.extend({
|
||||
className: 'message-detail panel',
|
||||
templateName: 'message-detail',
|
||||
initialize(options) {
|
||||
this.listenBack = options.listenBack;
|
||||
this.resetPanel = options.resetPanel;
|
||||
|
||||
this.view = new Whisper.MessageView({ model: this.model });
|
||||
this.view.render();
|
||||
this.conversation = options.conversation;
|
||||
|
||||
this.listenTo(this.model, 'change', this.render);
|
||||
},
|
||||
events: {
|
||||
'click button.delete': 'onDelete',
|
||||
},
|
||||
onDelete() {
|
||||
const dialog = new Whisper.ConfirmationDialogView({
|
||||
message: i18n('deleteWarning'),
|
||||
okText: i18n('delete'),
|
||||
resolve: () => {
|
||||
this.model.destroy();
|
||||
this.resetPanel();
|
||||
},
|
||||
});
|
||||
|
||||
this.$el.prepend(dialog.el);
|
||||
dialog.focusCancel();
|
||||
},
|
||||
getContacts() {
|
||||
// Return the set of models to be rendered in this view
|
||||
let ids;
|
||||
if (this.model.isIncoming()) {
|
||||
ids = [this.model.get('source')];
|
||||
} else if (this.model.isOutgoing()) {
|
||||
ids = this.model.get('recipients');
|
||||
if (!ids) {
|
||||
// older messages have no recipients field
|
||||
// use the current set of recipients
|
||||
ids = this.conversation.getRecipients();
|
||||
}
|
||||
}
|
||||
return Promise.all(
|
||||
ids.map(number =>
|
||||
ConversationController.getOrCreateAndWait(number, 'private')
|
||||
)
|
||||
);
|
||||
},
|
||||
renderContact(contact) {
|
||||
const view = new ContactView({
|
||||
model: contact,
|
||||
errors: this.grouped[contact.id],
|
||||
listenBack: this.listenBack,
|
||||
resetPanel: this.resetPanel,
|
||||
message: this.model,
|
||||
}).render();
|
||||
this.$('.contacts').append(view.el);
|
||||
},
|
||||
render() {
|
||||
const errorsWithoutNumber = _.reject(this.model.get('errors'), error =>
|
||||
Boolean(error.number)
|
||||
);
|
||||
|
||||
this.$el.html(
|
||||
Mustache.render(_.result(this, 'template', ''), {
|
||||
sent_at: moment(this.model.get('sent_at')).format('LLLL'),
|
||||
received_at: this.model.isIncoming()
|
||||
? moment(this.model.get('received_at')).format('LLLL')
|
||||
: null,
|
||||
tofrom: this.model.isIncoming() ? i18n('from') : i18n('to'),
|
||||
errors: errorsWithoutNumber,
|
||||
title: i18n('messageDetail'),
|
||||
sent: i18n('sent'),
|
||||
received: i18n('received'),
|
||||
errorLabel: i18n('error'),
|
||||
deleteLabel: i18n('deleteMessage'),
|
||||
})
|
||||
);
|
||||
this.view.$el.prependTo(this.$('.message-container'));
|
||||
|
||||
this.grouped = _.groupBy(this.model.get('errors'), 'number');
|
||||
|
||||
this.getContacts().then(contacts => {
|
||||
_.sortBy(contacts, c => {
|
||||
const prefix = this.grouped[c.id] ? '0' : '1';
|
||||
// this prefix ensures that contacts with errors are listed first;
|
||||
// otherwise it's alphabetical
|
||||
return prefix + c.getTitle();
|
||||
}).forEach(this.renderContact.bind(this));
|
||||
});
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -64,19 +64,10 @@
|
|||
this.measureScrollPosition();
|
||||
},
|
||||
addOne(model) {
|
||||
let view;
|
||||
if (model.isExpirationTimerUpdate()) {
|
||||
view = new Whisper.ExpirationTimerUpdateView({ model }).render();
|
||||
} else if (model.get('type') === 'keychange') {
|
||||
view = new Whisper.KeyChangeView({ model }).render();
|
||||
} else if (model.get('type') === 'verified-change') {
|
||||
view = new Whisper.VerifiedChangeView({ model }).render();
|
||||
} else {
|
||||
// eslint-disable-next-line new-cap
|
||||
view = new this.itemView({ model }).render();
|
||||
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
|
||||
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
|
||||
}
|
||||
// eslint-disable-next-line new-cap
|
||||
const view = new this.itemView({ model }).render();
|
||||
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
|
||||
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
|
||||
|
||||
const index = this.collection.indexOf(model);
|
||||
this.measureScrollPosition();
|
||||
|
|
|
@ -1,743 +1,115 @@
|
|||
/* global Whisper: false */
|
||||
/* global i18n: false */
|
||||
/* global textsecure: false */
|
||||
/* global _: false */
|
||||
/* global Mustache: false */
|
||||
/* global $: false */
|
||||
/* global storage: false */
|
||||
/* global Signal: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
loadAttachmentData,
|
||||
getAbsoluteAttachmentPath,
|
||||
} = window.Signal.Migrations;
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const ErrorIconView = Whisper.View.extend({
|
||||
templateName: 'error-icon',
|
||||
className: 'error-icon-container',
|
||||
initialize() {
|
||||
if (this.model.name === 'UnregisteredUserError') {
|
||||
this.$el.addClass('unregistered-user-error');
|
||||
}
|
||||
},
|
||||
});
|
||||
const NetworkErrorView = Whisper.View.extend({
|
||||
tagName: 'span',
|
||||
className: 'hasRetry',
|
||||
templateName: 'hasRetry',
|
||||
render_attributes() {
|
||||
let messageNotSent;
|
||||
|
||||
if (!this.model.someRecipientsFailed()) {
|
||||
messageNotSent = i18n('messageNotSent');
|
||||
}
|
||||
|
||||
return {
|
||||
messageNotSent,
|
||||
resend: i18n('resend'),
|
||||
};
|
||||
},
|
||||
});
|
||||
const SomeFailedView = Whisper.View.extend({
|
||||
tagName: 'span',
|
||||
className: 'some-failed',
|
||||
templateName: 'some-failed',
|
||||
render_attributes() {
|
||||
return {
|
||||
someFailed: i18n('someRecipientsFailed'),
|
||||
};
|
||||
},
|
||||
});
|
||||
const TimerView = Whisper.View.extend({
|
||||
templateName: 'hourglass',
|
||||
initialize() {
|
||||
this.listenTo(this.model, 'unload', this.remove);
|
||||
},
|
||||
update() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
if (this.model.isExpired()) {
|
||||
return this;
|
||||
}
|
||||
if (this.model.isExpiring()) {
|
||||
this.render();
|
||||
const totalTime = this.model.get('expireTimer') * 1000;
|
||||
const remainingTime = this.model.msTilExpire();
|
||||
const elapsed = (totalTime - remainingTime) / totalTime;
|
||||
this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`);
|
||||
this.$el.css('display', 'inline-block');
|
||||
this.timeout = setTimeout(
|
||||
this.update.bind(this),
|
||||
Math.max(totalTime / 100, 500)
|
||||
);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.ExpirationTimerUpdateView = Whisper.View.extend({
|
||||
Whisper.MessageView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
className: 'expirationTimerUpdate advisory',
|
||||
templateName: 'expirationTimerUpdate',
|
||||
id() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize() {
|
||||
this.conversation = this.model.getExpirationTimerUpdateSource();
|
||||
this.listenTo(this.conversation, 'change', this.render);
|
||||
this.listenTo(this.model, 'unload', this.remove);
|
||||
this.listenTo(this.model, 'change', this.onChange);
|
||||
},
|
||||
render_attributes() {
|
||||
const seconds = this.model.get('expirationTimerUpdate').expireTimer;
|
||||
let timerMessage;
|
||||
|
||||
const timerUpdate = this.model.get('expirationTimerUpdate');
|
||||
const prettySeconds = Whisper.ExpirationTimerOptions.getName(
|
||||
seconds || 0
|
||||
);
|
||||
|
||||
if (
|
||||
timerUpdate &&
|
||||
(timerUpdate.fromSync || timerUpdate.fromGroupUpdate)
|
||||
) {
|
||||
timerMessage = i18n('timerSetOnSync', prettySeconds);
|
||||
} else if (this.conversation.id === textsecure.storage.user.getNumber()) {
|
||||
timerMessage = i18n('youChangedTheTimer', prettySeconds);
|
||||
} else {
|
||||
timerMessage = i18n('theyChangedTheTimer', [
|
||||
this.conversation.getTitle(),
|
||||
prettySeconds,
|
||||
]);
|
||||
}
|
||||
return { content: timerMessage };
|
||||
this.listenTo(this.model, 'destroy', this.onDestroy);
|
||||
this.listenTo(this.model, 'unload', this.onUnload);
|
||||
},
|
||||
onChange() {
|
||||
this.addId();
|
||||
},
|
||||
addId() {
|
||||
// This is important to enable the lastSeenIndicator when it's just been added.
|
||||
this.$el.attr('id', this.id());
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.KeyChangeView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
className: 'keychange advisory',
|
||||
templateName: 'keychange',
|
||||
id() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize() {
|
||||
this.conversation = this.model.getModelForKeyChange();
|
||||
this.listenTo(this.conversation, 'change', this.render);
|
||||
this.listenTo(this.model, 'unload', this.remove);
|
||||
},
|
||||
events: {
|
||||
'click .content': 'showIdentity',
|
||||
},
|
||||
render_attributes() {
|
||||
return {
|
||||
content: this.model.getNotificationText(),
|
||||
};
|
||||
},
|
||||
showIdentity() {
|
||||
this.$el.trigger('show-identity', this.conversation);
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.VerifiedChangeView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
className: 'verified-change advisory',
|
||||
templateName: 'verified-change',
|
||||
id() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize() {
|
||||
this.conversation = this.model.getModelForVerifiedChange();
|
||||
this.listenTo(this.conversation, 'change', this.render);
|
||||
this.listenTo(this.model, 'unload', this.remove);
|
||||
},
|
||||
events: {
|
||||
'click .content': 'showIdentity',
|
||||
},
|
||||
render_attributes() {
|
||||
let key;
|
||||
|
||||
if (this.model.get('verified')) {
|
||||
if (this.model.get('local')) {
|
||||
key = 'youMarkedAsVerified';
|
||||
} else {
|
||||
key = 'youMarkedAsVerifiedOtherDevice';
|
||||
}
|
||||
return {
|
||||
icon: 'verified',
|
||||
content: i18n(key, this.conversation.getTitle()),
|
||||
};
|
||||
}
|
||||
|
||||
if (this.model.get('local')) {
|
||||
key = 'youMarkedAsNotVerified';
|
||||
} else {
|
||||
key = 'youMarkedAsNotVerifiedOtherDevice';
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'shield',
|
||||
content: i18n(key, this.conversation.getTitle()),
|
||||
};
|
||||
},
|
||||
showIdentity() {
|
||||
this.$el.trigger('show-identity', this.conversation);
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.MessageView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
templateName: 'message',
|
||||
id() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize() {
|
||||
// loadedAttachmentViews :: Promise (Array AttachmentView) | null
|
||||
this.loadedAttachmentViews = null;
|
||||
|
||||
this.listenTo(this.model, 'change:errors', this.onErrorsChanged);
|
||||
this.listenTo(this.model, 'change:body', this.render);
|
||||
this.listenTo(this.model, 'change:delivered', this.renderDelivered);
|
||||
this.listenTo(this.model, 'change:read_by', this.renderRead);
|
||||
this.listenTo(
|
||||
this.model,
|
||||
'change:expirationStartTimestamp',
|
||||
this.renderExpiring
|
||||
);
|
||||
this.listenTo(this.model, 'change', this.onChange);
|
||||
this.listenTo(
|
||||
this.model,
|
||||
'change:flags change:group_update',
|
||||
this.renderControl
|
||||
);
|
||||
this.listenTo(this.model, 'destroy', this.onDestroy);
|
||||
this.listenTo(this.model, 'unload', this.onUnload);
|
||||
this.listenTo(this.model, 'expired', this.onExpired);
|
||||
this.listenTo(this.model, 'pending', this.renderPending);
|
||||
this.listenTo(this.model, 'done', this.renderDone);
|
||||
this.timeStampView = new Whisper.ExtendedTimestampView();
|
||||
|
||||
this.contact = this.model.isIncoming() ? this.model.getContact() : null;
|
||||
if (this.contact) {
|
||||
this.listenTo(this.contact, 'change:color', this.updateColor);
|
||||
}
|
||||
},
|
||||
events: {
|
||||
'click .retry': 'retryMessage',
|
||||
'click .error-icon': 'select',
|
||||
'click .timestamp': 'select',
|
||||
'click .status': 'select',
|
||||
'click .some-failed': 'select',
|
||||
'click .error-message': 'select',
|
||||
'click .menu-container': 'showMenu',
|
||||
'click .menu-list .reply': 'onReply',
|
||||
},
|
||||
retryMessage() {
|
||||
const retrys = _.filter(
|
||||
this.model.get('errors'),
|
||||
this.model.isReplayableError.bind(this.model)
|
||||
);
|
||||
_.map(retrys, 'number').forEach(number => {
|
||||
this.model.resend(number);
|
||||
});
|
||||
},
|
||||
showMenu(e) {
|
||||
if (this.menuVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.menuVisible = true;
|
||||
e.stopPropagation();
|
||||
|
||||
this.$('.menu-list').show();
|
||||
$(document).one('click', () => {
|
||||
this.hideMenu();
|
||||
});
|
||||
},
|
||||
hideMenu() {
|
||||
this.menuVisible = false;
|
||||
this.$('.menu-list').hide();
|
||||
},
|
||||
onReply() {
|
||||
this.model.trigger('reply', this.model);
|
||||
},
|
||||
onExpired() {
|
||||
this.$el.addClass('expired');
|
||||
this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => {
|
||||
if (e.target === this.$('.bubble')[0]) {
|
||||
this.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Failsafe: if in the background, animation events don't fire
|
||||
setTimeout(this.remove.bind(this), 1000);
|
||||
// The ID is important for other items inserting themselves into the DOM. Because
|
||||
// of ReactWrapperView and this view, there are two layers of DOM elements
|
||||
// between the parent and the elements returned by the React component, so this is
|
||||
// necessary.
|
||||
const { id } = this.model;
|
||||
this.$el.attr('id', id);
|
||||
},
|
||||
onUnload() {
|
||||
if (this.avatarView) {
|
||||
this.avatarView.remove();
|
||||
if (this.childView) {
|
||||
this.childView.remove();
|
||||
}
|
||||
if (this.bodyView) {
|
||||
this.bodyView.remove();
|
||||
}
|
||||
if (this.contactView) {
|
||||
this.contactView.remove();
|
||||
}
|
||||
if (this.controlView) {
|
||||
this.controlView.remove();
|
||||
}
|
||||
if (this.errorIconView) {
|
||||
this.errorIconView.remove();
|
||||
}
|
||||
if (this.networkErrorView) {
|
||||
this.networkErrorView.remove();
|
||||
}
|
||||
if (this.quoteView) {
|
||||
this.quoteView.remove();
|
||||
}
|
||||
if (this.someFailedView) {
|
||||
this.someFailedView.remove();
|
||||
}
|
||||
if (this.timeStampView) {
|
||||
this.timeStampView.remove();
|
||||
}
|
||||
|
||||
// NOTE: We have to do this in the background (`then` instead of `await`)
|
||||
// as our tests rely on `onUnload` synchronously removing the view from
|
||||
// the DOM.
|
||||
// eslint-disable-next-line more/no-then
|
||||
this.loadAttachmentViews().then(views =>
|
||||
views.forEach(view => view.unload())
|
||||
);
|
||||
|
||||
// No need to handle this one, since it listens to 'unload' itself:
|
||||
// this.timerView
|
||||
|
||||
this.remove();
|
||||
},
|
||||
onDestroy() {
|
||||
if (this.$el.hasClass('expired')) {
|
||||
return;
|
||||
}
|
||||
this.onUnload();
|
||||
},
|
||||
onChange() {
|
||||
this.renderSent();
|
||||
this.renderQuote();
|
||||
this.addId();
|
||||
},
|
||||
select(e) {
|
||||
this.$el.trigger('select', { message: this.model });
|
||||
e.stopPropagation();
|
||||
},
|
||||
className() {
|
||||
return ['entry', this.model.get('type')].join(' ');
|
||||
},
|
||||
renderPending() {
|
||||
this.$el.addClass('pending');
|
||||
},
|
||||
renderDone() {
|
||||
this.$el.removeClass('pending');
|
||||
},
|
||||
renderSent() {
|
||||
if (this.model.isOutgoing()) {
|
||||
this.$el.toggleClass('sent', !!this.model.get('sent'));
|
||||
}
|
||||
},
|
||||
renderDelivered() {
|
||||
if (this.model.get('delivered')) {
|
||||
this.$el.addClass('delivered');
|
||||
}
|
||||
},
|
||||
renderRead() {
|
||||
if (!_.isEmpty(this.model.get('read_by'))) {
|
||||
this.$el.addClass('read');
|
||||
}
|
||||
},
|
||||
onErrorsChanged() {
|
||||
if (this.model.isIncoming()) {
|
||||
this.render();
|
||||
} else {
|
||||
this.renderErrors();
|
||||
}
|
||||
},
|
||||
renderErrors() {
|
||||
const errors = this.model.get('errors');
|
||||
getRenderInfo() {
|
||||
const { Components } = window.Signal;
|
||||
|
||||
this.$('.error-icon-container').remove();
|
||||
if (this.errorIconView) {
|
||||
this.errorIconView.remove();
|
||||
this.errorIconView = null;
|
||||
}
|
||||
if (_.size(errors) > 0) {
|
||||
if (this.model.isIncoming()) {
|
||||
this.$('.content')
|
||||
.text(this.model.getDescription())
|
||||
.addClass('error-message');
|
||||
}
|
||||
this.errorIconView = new ErrorIconView({ model: errors[0] });
|
||||
this.errorIconView.render().$el.appendTo(this.$('.bubble'));
|
||||
} else if (!this.hasContents()) {
|
||||
const el = this.$('.content');
|
||||
if (!el || el.length === 0) {
|
||||
this.$('.inner-bubble').append("<div class='content'></div>");
|
||||
}
|
||||
this.$('.content')
|
||||
.text(i18n('noContents'))
|
||||
.addClass('error-message');
|
||||
if (this.model.isExpirationTimerUpdate()) {
|
||||
return {
|
||||
Component: Components.TimerNotification,
|
||||
props: this.model.getPropsForTimerNotification(),
|
||||
};
|
||||
} else if (this.model.isKeyChange()) {
|
||||
return {
|
||||
Component: Components.SafetyNumberNotification,
|
||||
props: this.model.getPropsForSafetyNumberNotification(),
|
||||
};
|
||||
} else if (this.model.isVerifiedChange()) {
|
||||
return {
|
||||
Component: Components.VerificationNotification,
|
||||
props: this.model.getPropsForVerificationNotification(),
|
||||
};
|
||||
} else if (this.model.isEndSession()) {
|
||||
return {
|
||||
Component: Components.ResetSessionNotification,
|
||||
props: this.model.getPropsForResetSessionNotification(),
|
||||
};
|
||||
} else if (this.model.isGroupUpdate()) {
|
||||
return {
|
||||
Component: Components.GroupNotification,
|
||||
props: this.model.getPropsForGroupNotification(),
|
||||
};
|
||||
}
|
||||
|
||||
this.$('.meta .hasRetry').remove();
|
||||
if (this.networkErrorView) {
|
||||
this.networkErrorView.remove();
|
||||
this.networkErrorView = null;
|
||||
}
|
||||
if (this.model.hasNetworkError()) {
|
||||
this.networkErrorView = new NetworkErrorView({ model: this.model });
|
||||
this.$('.meta').prepend(this.networkErrorView.render().el);
|
||||
}
|
||||
|
||||
this.$('.meta .some-failed').remove();
|
||||
if (this.someFailedView) {
|
||||
this.someFailedView.remove();
|
||||
this.someFailedView = null;
|
||||
}
|
||||
if (this.model.someRecipientsFailed()) {
|
||||
this.someFailedView = new SomeFailedView();
|
||||
this.$('.meta').prepend(this.someFailedView.render().el);
|
||||
}
|
||||
},
|
||||
renderControl() {
|
||||
if (this.model.isEndSession() || this.model.isGroupUpdate()) {
|
||||
this.$el.addClass('control');
|
||||
|
||||
if (this.controlView) {
|
||||
this.controlView.remove();
|
||||
this.controlView = null;
|
||||
}
|
||||
|
||||
this.controlView = new Whisper.ReactWrapperView({
|
||||
className: 'content-wrapper',
|
||||
Component: window.Signal.Components.Emojify,
|
||||
props: {
|
||||
text: this.model.getDescription(),
|
||||
},
|
||||
});
|
||||
this.$('.content').prepend(this.controlView.el);
|
||||
} else {
|
||||
this.$el.removeClass('control');
|
||||
}
|
||||
},
|
||||
renderExpiring() {
|
||||
if (!this.timerView) {
|
||||
this.timerView = new TimerView({ model: this.model });
|
||||
}
|
||||
this.timerView.setElement(this.$('.timer'));
|
||||
this.timerView.update();
|
||||
},
|
||||
renderQuote() {
|
||||
const props = this.model.getPropsForQuote();
|
||||
if (!props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contact = this.model.getQuoteContact();
|
||||
if (this.quoteView) {
|
||||
this.quoteView.remove();
|
||||
this.quoteView = null;
|
||||
} else if (contact) {
|
||||
this.listenTo(contact, 'change:color', this.renderQuote);
|
||||
}
|
||||
|
||||
this.quoteView = new Whisper.ReactWrapperView({
|
||||
className: 'quote-wrapper',
|
||||
Component: window.Signal.Components.Quote,
|
||||
props: Object.assign({}, props, {
|
||||
text: props.text,
|
||||
}),
|
||||
});
|
||||
this.$('.inner-bubble').prepend(this.quoteView.el);
|
||||
},
|
||||
renderContact() {
|
||||
const contacts = this.model.get('contact');
|
||||
if (!contacts || !contacts.length) {
|
||||
return;
|
||||
}
|
||||
const contact = contacts[0];
|
||||
|
||||
const regionCode = storage.get('regionCode');
|
||||
const { contactSelector } = Signal.Types.Contact;
|
||||
|
||||
const number =
|
||||
contact.number && contact.number[0] && contact.number[0].value;
|
||||
const haveConversation =
|
||||
number && Boolean(window.ConversationController.get(number));
|
||||
const hasLocalSignalAccount =
|
||||
this.contactHasSignalAccount || (number && haveConversation);
|
||||
|
||||
// We store this value on this. because a re-render shouldn't kick off another
|
||||
// profile check, going to the web.
|
||||
this.contactHasSignalAccount = hasLocalSignalAccount;
|
||||
|
||||
const onSendMessage = number
|
||||
? () => {
|
||||
this.model.trigger('open-conversation', number);
|
||||
}
|
||||
: null;
|
||||
const onOpenContact = async () => {
|
||||
// First let's finish our check with the central server to see if this user has
|
||||
// a signal account. Then we won't have to do it a second time for the detail
|
||||
// screen.
|
||||
await this.checkingProfile;
|
||||
this.model.trigger('show-contact-detail', {
|
||||
contact,
|
||||
hasSignalAccount: this.contactHasSignalAccount,
|
||||
});
|
||||
return {
|
||||
Component: Components.Message,
|
||||
props: this.model.getPropsForMessage(),
|
||||
};
|
||||
|
||||
const getProps = ({ hasSignalAccount }) => ({
|
||||
contact: contactSelector(contact, {
|
||||
regionCode,
|
||||
getAbsoluteAttachmentPath,
|
||||
}),
|
||||
hasSignalAccount,
|
||||
onSendMessage,
|
||||
onOpenContact,
|
||||
});
|
||||
|
||||
if (this.contactView) {
|
||||
this.contactView.remove();
|
||||
this.contactView = null;
|
||||
}
|
||||
|
||||
this.contactView = new Whisper.ReactWrapperView({
|
||||
className: 'contact-wrapper',
|
||||
Component: window.Signal.Components.EmbeddedContact,
|
||||
props: getProps({
|
||||
hasSignalAccount: hasLocalSignalAccount,
|
||||
}),
|
||||
});
|
||||
|
||||
this.$('.inner-bubble').prepend(this.contactView.el);
|
||||
|
||||
// If we can't verify a signal account locally, we'll go to the Signal Server.
|
||||
if (number && !hasLocalSignalAccount) {
|
||||
// eslint-disable-next-line more/no-then
|
||||
this.checkingProfile = window.textsecure.messaging
|
||||
.getProfile(number)
|
||||
.then(() => {
|
||||
this.contactHasSignalAccount = true;
|
||||
|
||||
if (!this.contactView) {
|
||||
return;
|
||||
}
|
||||
this.contactView.update(getProps({ hasSignalAccount: true }));
|
||||
})
|
||||
.catch(() => {
|
||||
// No account available, or network connectivity problem
|
||||
});
|
||||
} else {
|
||||
this.checkingProfile = Promise.resolve();
|
||||
}
|
||||
},
|
||||
isImageWithoutCaption() {
|
||||
const attachments = this.model.get('attachments');
|
||||
const body = this.model.get('body');
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (body && body.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
if (Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
hasContents() {
|
||||
const attachments = this.model.get('attachments');
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
|
||||
const contacts = this.model.get('contact');
|
||||
const hasContact = contacts && contacts.length > 0;
|
||||
|
||||
return this.hasTextContents() || hasAttachments || hasContact;
|
||||
},
|
||||
hasTextContents() {
|
||||
const body = this.model.get('body');
|
||||
const isGroupUpdate = this.model.isGroupUpdate();
|
||||
const isEndSession = this.model.isEndSession();
|
||||
|
||||
const errors = this.model.get('errors');
|
||||
const hasErrors = errors && errors.length > 0;
|
||||
const errorsCanBeContents = this.model.isIncoming() && hasErrors;
|
||||
|
||||
return body || isGroupUpdate || isEndSession || errorsCanBeContents;
|
||||
},
|
||||
addId() {
|
||||
// Because we initially render a sent Message before we've roundtripped with the
|
||||
// database, we don't have its id for that first render. We do get a change event,
|
||||
// however, and can add the id manually.
|
||||
const { id } = this.model;
|
||||
this.$el.attr('id', id);
|
||||
},
|
||||
render() {
|
||||
const contact = this.model.isIncoming() ? this.model.getContact() : null;
|
||||
const attachments = this.model.get('attachments');
|
||||
this.addId();
|
||||
|
||||
const errors = this.model.get('errors');
|
||||
const hasErrors = errors && errors.length > 0;
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
const hasBody = this.hasTextContents();
|
||||
|
||||
const messageBody = this.model.get('body');
|
||||
|
||||
this.$el.html(
|
||||
Mustache.render(
|
||||
_.result(this, 'template', ''),
|
||||
{
|
||||
message: Boolean(messageBody),
|
||||
hasBody,
|
||||
timestamp: this.model.get('sent_at'),
|
||||
sender: (contact && contact.getTitle()) || '',
|
||||
avatar: contact && contact.getAvatar(),
|
||||
profileName: contact && contact.getProfileName(),
|
||||
innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail',
|
||||
hoverIcon: !hasErrors,
|
||||
hasAttachments,
|
||||
reply: i18n('replyToMessage'),
|
||||
},
|
||||
this.render_partials()
|
||||
)
|
||||
);
|
||||
this.timeStampView.setElement(this.$('.timestamp'));
|
||||
this.timeStampView.update();
|
||||
|
||||
this.renderControl();
|
||||
|
||||
if (messageBody) {
|
||||
if (this.bodyView) {
|
||||
this.bodyView.remove();
|
||||
this.bodyView = null;
|
||||
}
|
||||
this.bodyView = new Whisper.ReactWrapperView({
|
||||
className: 'body-wrapper',
|
||||
Component: window.Signal.Components.MessageBody,
|
||||
props: {
|
||||
text: messageBody,
|
||||
},
|
||||
});
|
||||
this.$('.body').append(this.bodyView.el);
|
||||
if (this.childView) {
|
||||
this.childView.remove();
|
||||
this.childView = null;
|
||||
}
|
||||
|
||||
this.renderSent();
|
||||
this.renderDelivered();
|
||||
this.renderRead();
|
||||
this.renderErrors();
|
||||
this.renderExpiring();
|
||||
this.renderQuote();
|
||||
this.renderContact();
|
||||
const { Component, props } = this.getRenderInfo();
|
||||
this.childView = new Whisper.ReactWrapperView({
|
||||
className: 'message-wrapper',
|
||||
Component,
|
||||
props,
|
||||
});
|
||||
|
||||
// NOTE: We have to do this in the background (`then` instead of `await`)
|
||||
// as our code / Backbone seems to rely on `render` synchronously returning
|
||||
// `this` instead of `Promise MessageView` (this):
|
||||
// eslint-disable-next-line more/no-then
|
||||
this.loadAttachmentViews().then(views =>
|
||||
this.renderAttachmentViews(views)
|
||||
);
|
||||
const update = () => {
|
||||
const info = this.getRenderInfo();
|
||||
this.childView.update(info.props);
|
||||
};
|
||||
|
||||
this.listenTo(this.model, 'change', update);
|
||||
|
||||
this.conversation = this.model.getConversation();
|
||||
this.listenTo(this.conversation, 'change', update);
|
||||
|
||||
this.fromContact = this.model.getIncomingContact();
|
||||
if (this.fromContact) {
|
||||
this.listenTo(this.fromContact, 'change', update);
|
||||
}
|
||||
|
||||
this.quotedContact = this.model.getQuoteContact();
|
||||
if (this.quotedContact) {
|
||||
this.listenTo(this.quotedContact, 'change', update);
|
||||
}
|
||||
|
||||
this.$el.append(this.childView.el);
|
||||
|
||||
return this;
|
||||
},
|
||||
updateColor() {
|
||||
const bubble = this.$('.bubble');
|
||||
|
||||
// this.contact is known to be non-null if we're registered for color changes
|
||||
const color = this.contact.getColor();
|
||||
if (color) {
|
||||
bubble.removeClass(Whisper.Conversation.COLORS);
|
||||
bubble.addClass(color);
|
||||
}
|
||||
this.avatarView = new (Whisper.View.extend({
|
||||
templateName: 'avatar',
|
||||
render_attributes: { avatar: this.contact.getAvatar() },
|
||||
}))();
|
||||
this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar'));
|
||||
},
|
||||
loadAttachmentViews() {
|
||||
if (this.loadedAttachmentViews !== null) {
|
||||
return this.loadedAttachmentViews;
|
||||
}
|
||||
|
||||
const attachments = this.model.get('attachments') || [];
|
||||
const loadedAttachmentViews = Promise.all(
|
||||
attachments.map(
|
||||
attachment =>
|
||||
new Promise(async resolve => {
|
||||
const attachmentWithData = await loadAttachmentData(attachment);
|
||||
const view = new Whisper.AttachmentView({
|
||||
model: attachmentWithData,
|
||||
timestamp: this.model.get('sent_at'),
|
||||
});
|
||||
|
||||
this.listenTo(view, 'update', () => {
|
||||
// NOTE: Can we do without `updated` flag now that we use promises?
|
||||
view.updated = true;
|
||||
resolve(view);
|
||||
});
|
||||
|
||||
view.render();
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Memoize attachment views to avoid double loading:
|
||||
this.loadedAttachmentViews = loadedAttachmentViews;
|
||||
|
||||
return loadedAttachmentViews;
|
||||
},
|
||||
renderAttachmentViews(views) {
|
||||
views.forEach(view => this.renderAttachmentView(view));
|
||||
},
|
||||
renderAttachmentView(view) {
|
||||
if (!view.updated) {
|
||||
throw new Error(
|
||||
'Invariant violation:' +
|
||||
' Cannot render an attachment view that isn’t ready'
|
||||
);
|
||||
}
|
||||
|
||||
const parent = this.$('.attachments')[0];
|
||||
const isViewAlreadyChild = parent === view.el.parentNode;
|
||||
if (isViewAlreadyChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (view.el.parentNode) {
|
||||
view.el.parentNode.removeChild(view.el);
|
||||
}
|
||||
|
||||
this.trigger('beforeChangeHeight');
|
||||
this.$('.attachments').append(view.el);
|
||||
view.setElement(view.el);
|
||||
this.trigger('afterChangeHeight');
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.ScrollDownButtonView = Whisper.View.extend({
|
||||
className: 'scroll-down-button-view',
|
||||
className: 'module-scroll-down',
|
||||
templateName: 'scroll-down-button-view',
|
||||
|
||||
initialize(options = {}) {
|
||||
|
@ -20,7 +20,8 @@
|
|||
},
|
||||
|
||||
render_attributes() {
|
||||
const cssClass = this.count > 0 ? 'new-messages' : '';
|
||||
const buttonClass =
|
||||
this.count > 0 ? 'module-scroll-down__button--new-messages' : '';
|
||||
|
||||
let moreBelow = i18n('scrollDown');
|
||||
if (this.count > 1) {
|
||||
|
@ -30,7 +31,7 @@
|
|||
}
|
||||
|
||||
return {
|
||||
cssClass,
|
||||
buttonClass,
|
||||
moreBelow,
|
||||
};
|
||||
},
|
||||
|
|
|
@ -77,6 +77,7 @@
|
|||
"protobufjs": "^6.8.6",
|
||||
"proxy-agent": "^2.1.0",
|
||||
"react": "^16.2.0",
|
||||
"react-contextmenu": "^2.9.2",
|
||||
"react-dom": "^16.2.0",
|
||||
"read-last-lines": "^1.3.0",
|
||||
"rimraf": "^2.6.2",
|
||||
|
|
|
@ -202,13 +202,6 @@ window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInst
|
|||
window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat;
|
||||
window.loadImage = require('blueimp-load-image');
|
||||
|
||||
// Note: when modifying this file, consider whether our React Components or Backbone Views
|
||||
// will need these things to render in the Style Guide. If so, go update one of these
|
||||
// two locations:
|
||||
//
|
||||
// 1) test/styleguide/legacy_bridge.js
|
||||
// 2) ts/styleguide/StyleGuideUtil.js
|
||||
|
||||
window.React = require('react');
|
||||
window.ReactDOM = require('react-dom');
|
||||
window.moment = require('moment');
|
||||
|
|
|
@ -54,98 +54,6 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
},
|
||||
body: {
|
||||
// Brings in all the necessary components to boostrap Backbone views
|
||||
// Mirrors the order used in background.js.
|
||||
scripts: [
|
||||
{
|
||||
src: 'test/styleguide/legacy_bridge.js',
|
||||
},
|
||||
{
|
||||
src: 'node_modules/moment/min/moment-with-locales.min.js',
|
||||
},
|
||||
{
|
||||
src: 'js/components.js',
|
||||
},
|
||||
{
|
||||
src: 'js/reliable_trigger.js',
|
||||
},
|
||||
{
|
||||
src: 'js/database.js',
|
||||
},
|
||||
{
|
||||
src: 'js/storage.js',
|
||||
},
|
||||
{
|
||||
src: 'js/signal_protocol_store.js',
|
||||
},
|
||||
{
|
||||
src: 'js/libtextsecure.js',
|
||||
},
|
||||
{
|
||||
src: 'js/focus_listener.js',
|
||||
},
|
||||
{
|
||||
src: 'js/notifications.js',
|
||||
},
|
||||
{
|
||||
src: 'js/delivery_receipts.js',
|
||||
},
|
||||
{
|
||||
src: 'js/read_receipts.js',
|
||||
},
|
||||
{
|
||||
src: 'js/read_syncs.js',
|
||||
},
|
||||
{
|
||||
src: 'js/libphonenumber-util.js',
|
||||
},
|
||||
{
|
||||
src: 'js/models/messages.js',
|
||||
},
|
||||
{
|
||||
src: 'js/models/conversations.js',
|
||||
},
|
||||
{
|
||||
src: 'js/models/blockedNumbers.js',
|
||||
},
|
||||
{
|
||||
src: 'js/expiring_messages.js',
|
||||
},
|
||||
{
|
||||
src: 'js/chromium.js',
|
||||
},
|
||||
{
|
||||
src: 'js/registration.js',
|
||||
},
|
||||
{
|
||||
src: 'js/expire.js',
|
||||
},
|
||||
{
|
||||
src: 'js/conversation_controller.js',
|
||||
},
|
||||
// Select Backbone views
|
||||
{
|
||||
src: 'js/views/react_wrapper_view.js',
|
||||
},
|
||||
{
|
||||
src: 'js/views/whisper_view.js',
|
||||
},
|
||||
{
|
||||
src: 'js/views/timestamp_view.js',
|
||||
},
|
||||
{
|
||||
src: 'js/views/attachment_view.js',
|
||||
},
|
||||
{
|
||||
src: 'js/views/message_view.js',
|
||||
},
|
||||
// Hacky way of including templates for Backbone components
|
||||
{
|
||||
src: 'test/styleguide/legacy_templates.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
propsParser,
|
||||
webpackConfig: {
|
||||
|
|
|
@ -1,85 +1,10 @@
|
|||
.conversation-title {
|
||||
display: block;
|
||||
line-height: 36px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 46px;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
.conversation-name + .conversation-number {
|
||||
&:before {
|
||||
content: '\00b7'; // ·
|
||||
font-weight: bold;
|
||||
padding: 0 5px 0 4px;
|
||||
}
|
||||
}
|
||||
.conversation-title .verified {
|
||||
&:before {
|
||||
content: '\00b7'; // ·
|
||||
font-weight: bold;
|
||||
padding: 0 5px 0 4px;
|
||||
}
|
||||
}
|
||||
.conversation-title .verified-icon {
|
||||
@include color-svg('../images/verified-check.svg', white);
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
background-color: white;
|
||||
background-color: $color-white;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.conversation-loading-screen {
|
||||
z-index: 99;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 78px;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 3px solid $blue;
|
||||
border-radius: 50%;
|
||||
float: left;
|
||||
margin: 0 6px;
|
||||
transform: scale(0);
|
||||
|
||||
animation: loading 1500ms ease infinite 0ms;
|
||||
&:nth-child(2) {
|
||||
animation: loading 1500ms ease infinite 333ms;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation: loading 1500ms ease infinite 666ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel,
|
||||
.react-wrapper {
|
||||
.panel-wrapper {
|
||||
height: calc(100% - #{$header-height});
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
@ -94,7 +19,7 @@
|
|||
}
|
||||
|
||||
.main.panel,
|
||||
.react-wrapper {
|
||||
.panel-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: initial;
|
||||
|
@ -124,8 +49,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.message-detail-wrapper {
|
||||
height: calc(100% - 48px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.discussion-container {
|
||||
background-color: 'white';
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
.key-verification {
|
||||
|
@ -142,10 +72,10 @@
|
|||
display: inline-block;
|
||||
|
||||
&.verified {
|
||||
@include color-svg('../images/verified-check.svg', $grey_d);
|
||||
@include color-svg('../images/verified-check.svg', $color-light-90);
|
||||
}
|
||||
&.shield {
|
||||
@include color-svg('../images/shield.svg', $grey_d);
|
||||
@include color-svg('../images/shield.svg', $color-light-90);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -199,291 +129,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.identity-key-send-error {
|
||||
button {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
.explanation {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.safety-number {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.message-detail {
|
||||
background-color: #eee;
|
||||
|
||||
.message-container {
|
||||
padding: 20px 0;
|
||||
|
||||
.sender {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 1em;
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
padding-right: 1em;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: $border-radius;
|
||||
color: white;
|
||||
padding: 0.5em;
|
||||
font-weight: bold;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.retries {
|
||||
padding: 1em;
|
||||
}
|
||||
button.retry {
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.contacts .contact-detail {
|
||||
padding: 0 36px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.status-icon-container,
|
||||
.error-icon-container {
|
||||
float: right;
|
||||
}
|
||||
|
||||
button.error {
|
||||
background-color: red;
|
||||
color: white;
|
||||
|
||||
span.icon.error {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
@include color-svg('../images/warning.svg', white);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 6px 0 0;
|
||||
font-size: $font-size-small;
|
||||
font-weight: bold;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1em;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
button.cancel {
|
||||
float: right;
|
||||
color: $grey_d;
|
||||
border: solid 1px #ccc;
|
||||
}
|
||||
|
||||
.delete-container {
|
||||
text-align: center;
|
||||
|
||||
button.delete {
|
||||
background-color: red;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
.message-list {
|
||||
.error-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.advisory {
|
||||
text-align: center;
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
background: #fff5c4;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
li.entry .error-icon-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(100% + 5px);
|
||||
height: 100%;
|
||||
|
||||
.error-icon {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: black;
|
||||
color: white;
|
||||
border-radius: $border-radius;
|
||||
padding: 0.5em;
|
||||
font-weight: normal;
|
||||
bottom: calc(50% + 18px);
|
||||
left: -84px;
|
||||
width: 180px;
|
||||
z-index: 10;
|
||||
|
||||
&:before {
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -16px;
|
||||
left: 50%;
|
||||
border: 6px solid transparent;
|
||||
border-top: 10px solid #000000;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .error-message {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
li.entry .menu-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(100% + 5px);
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.menu-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dots-horizontal-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
li.entry:hover .dots-horizontal-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
li.entry.outgoing .menu-container {
|
||||
left: auto;
|
||||
right: calc(100% + 5px);
|
||||
}
|
||||
|
||||
.incoming .menu-list {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
display: inline-block;
|
||||
width: $error-icon-size;
|
||||
height: $error-icon-size;
|
||||
position: relative;
|
||||
@include color-svg('../images/warning.svg', red);
|
||||
}
|
||||
|
||||
.dots-horizontal-icon {
|
||||
display: inline-block;
|
||||
width: $error-icon-size;
|
||||
height: $error-icon-size;
|
||||
position: relative;
|
||||
@include color-svg('../images/dots-horizontal.svg', gray);
|
||||
|
||||
&:hover {
|
||||
@include color-svg('../images/dots-horizontal.svg', black);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
li.entry .unregistered-user-error {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.group-update {
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.private .entry .avatar,
|
||||
.private .sender,
|
||||
.outgoing .sender {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sender {
|
||||
font-size: smaller;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
margin-right: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// There's a p.status used in the onboarding screen, so this needs to be more specific
|
||||
span.status {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.sent span.status {
|
||||
display: inline-block;
|
||||
@include color-svg('../images/check.svg', black);
|
||||
}
|
||||
.delivered span.status {
|
||||
display: inline-block;
|
||||
@include color-svg('../images/double-check.svg', black);
|
||||
}
|
||||
.read span.status {
|
||||
display: inline-block;
|
||||
@include color-svg('../images/double-check.svg', $blue);
|
||||
}
|
||||
.pending span.status {
|
||||
display: inline-block;
|
||||
background: none;
|
||||
&:before {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
|
||||
.message-container,
|
||||
.message-list {
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
max-width: 800px;
|
||||
margin: 0 auto 10px;
|
||||
padding-left: 1em;
|
||||
// we need more padding on right side because scroll bar overlaps
|
||||
padding-right: 1.5em;
|
||||
max-width: 736px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.message-wrapper {
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
visibility: hidden;
|
||||
|
@ -494,252 +153,14 @@ span.status {
|
|||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
left: -2px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
word-wrap: break-word;
|
||||
margin-left: 8px;
|
||||
max-width: 30em;
|
||||
text-align: -webkit-auto;
|
||||
-webkit-user-select: text;
|
||||
|
||||
@media (max-width: 825px) {
|
||||
max-width: calc(
|
||||
100% - 45px - #{$error-icon-size}
|
||||
); // avatar size + padding + error-icon size
|
||||
.group {
|
||||
.message-container,
|
||||
.message-list {
|
||||
li .message-wrapper {
|
||||
margin-left: 44px;
|
||||
}
|
||||
|
||||
.body {
|
||||
white-space: pre-wrap;
|
||||
|
||||
a {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments + .content {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.quote-wrapper + .content {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.contact-wrapper + .content {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: smaller;
|
||||
margin-top: 3px;
|
||||
text-align: right;
|
||||
line-height: 18px;
|
||||
|
||||
.hasRetry + .timestamp {
|
||||
&:before {
|
||||
content: '\00b7'; // ·
|
||||
font-weight: bold;
|
||||
padding: 0 5px 0 4px;
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.retry {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.some-failed {
|
||||
float: left;
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hasRetry,
|
||||
.timestamp,
|
||||
.status,
|
||||
.timer {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.timestamp,
|
||||
.status {
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.incoming {
|
||||
.avatar,
|
||||
.bubble {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing {
|
||||
.meta {
|
||||
float: right;
|
||||
}
|
||||
.error-icon-container {
|
||||
left: auto;
|
||||
right: calc(100% + 5px);
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.bubble {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
clear: left;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.expired .bubble {
|
||||
animation: shake 0.2s linear 3;
|
||||
}
|
||||
|
||||
.timer {
|
||||
display: none;
|
||||
.hourglass {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.control {
|
||||
.bubble {
|
||||
.content {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
a {
|
||||
font-style: italic;
|
||||
display: block;
|
||||
padding: 1em;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
img,
|
||||
audio,
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
video {
|
||||
background: black;
|
||||
min-height: 300px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileView {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
position: relative;
|
||||
padding: 5px;
|
||||
padding-right: 10px;
|
||||
padding-bottom: 0px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.fileName {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon,
|
||||
.text {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.icon,
|
||||
.text {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: -0.5em;
|
||||
margin-right: 0.5em;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: $button-height * 2;
|
||||
height: $button-height * 2;
|
||||
@include color-svg('../images/file.svg', $grey_d);
|
||||
|
||||
&.audio {
|
||||
@include color-svg('../images/audio.svg', $grey_d);
|
||||
}
|
||||
&.video {
|
||||
@include color-svg('../images/video.svg', $grey_d);
|
||||
}
|
||||
&.voice {
|
||||
@include color-svg('../images/voice.svg', $grey_d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing .avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bubble .content.error-message {
|
||||
cursor: pointer;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -762,45 +183,23 @@ span.status {
|
|||
}
|
||||
|
||||
.send .quote-wrapper {
|
||||
margin-left: 46px;
|
||||
margin-top: 5px;
|
||||
margin-right: 75px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.incoming .quoted-message {
|
||||
background-color: rgba(white, 0.6);
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
border-left-color: white;
|
||||
}
|
||||
|
||||
.message-list,
|
||||
.message-container {
|
||||
.avatar {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
line-height: 36px;
|
||||
}
|
||||
margin-left: 37px;
|
||||
margin-right: 73px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
box-sizing: content-box;
|
||||
$button-width: 36px;
|
||||
padding: 5px 0px 5px 0;
|
||||
background: $grey_l;
|
||||
|
||||
.compose {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
form.active {
|
||||
outline: solid 1px $blue;
|
||||
textarea {
|
||||
border: solid 1px $blue;
|
||||
}
|
||||
}
|
||||
|
||||
form.send {
|
||||
background: #ffffff;
|
||||
background: $color-white;
|
||||
|
||||
&.video-attachment {
|
||||
.image-container {
|
||||
|
@ -829,13 +228,9 @@ span.status {
|
|||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
color: $grey_d;
|
||||
}
|
||||
|
||||
.attachment-previews {
|
||||
padding: 0 36px;
|
||||
margin-bottom: 3px;
|
||||
|
||||
.attachment-preview {
|
||||
padding: 13px 10px 0;
|
||||
|
@ -875,8 +270,11 @@ span.status {
|
|||
display: block;
|
||||
max-height: 100px;
|
||||
padding: 10px;
|
||||
margin: 0 5px;
|
||||
border: 0;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 20px;
|
||||
background-color: $color-light-02;
|
||||
color: $color-light-90;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
outline: 0;
|
||||
z-index: 5;
|
||||
resize: none;
|
||||
|
@ -903,7 +301,7 @@ span.status {
|
|||
margin: 0 2em 3em;
|
||||
padding: 0.5em 1.5em;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: white;
|
||||
color: $color-white;
|
||||
box-shadow: 0 0 5px 0 black;
|
||||
border-radius: $border-radius;
|
||||
font-size: $font-size-small;
|
||||
|
@ -957,109 +355,105 @@ span.status {
|
|||
}
|
||||
}
|
||||
|
||||
.advisory .icon {
|
||||
height: 1.25em;
|
||||
width: 1.25em;
|
||||
vertical-align: text-bottom;
|
||||
display: inline-block;
|
||||
|
||||
&.verified {
|
||||
@include color-svg('../images/verified-check.svg', $grey_d);
|
||||
}
|
||||
&.shield {
|
||||
@include color-svg('../images/shield.svg', $grey_d);
|
||||
}
|
||||
&.clock {
|
||||
@include color-svg('../images/clock.svg', $grey_d);
|
||||
}
|
||||
}
|
||||
|
||||
.keychange {
|
||||
text-align: center;
|
||||
.content {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
background: #fff5c4;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
|
||||
.verified-change {
|
||||
text-align: center;
|
||||
.conversation-loading-screen {
|
||||
z-index: 99;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: $color-white;
|
||||
|
||||
.content {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
background: #fff5c4;
|
||||
border-radius: $border-radius;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 120px;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 3px solid $blue;
|
||||
border-radius: 50%;
|
||||
float: left;
|
||||
margin: 0 6px;
|
||||
transform: scale(0);
|
||||
|
||||
animation: loading 1500ms ease infinite 0ms;
|
||||
&:nth-child(2) {
|
||||
animation: loading 1500ms ease infinite 333ms;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation: loading 1500ms ease infinite 666ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-list .last-seen-indicator-view {
|
||||
// This padding is large so we clear the avatar circle extending into the conversation
|
||||
// window.scrollIntoView() doesn't honor margins, so we're using padding
|
||||
// padding-top is less to account for the 10px margin at the bottom of messages
|
||||
.module-last-seen-indicator {
|
||||
padding-top: 25px;
|
||||
padding-bottom: 35px;
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
padding: 5px;
|
||||
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.055);
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background-color: white;
|
||||
border-radius: 1.5em;
|
||||
padding: 10px 21px 9px 21px;
|
||||
}
|
||||
margin-left: 28px;
|
||||
margin-right: 28px;
|
||||
}
|
||||
|
||||
.discussion-container .scroll-down-button-view {
|
||||
.module-last-seen-indicator__bar {
|
||||
background-color: $color-light-60;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.module-last-seen-indicator__text {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
|
||||
text-align: center;
|
||||
color: $color-light-90;
|
||||
}
|
||||
|
||||
.module-scroll-down {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
border-radius: 22px;
|
||||
text-align: center;
|
||||
background-color: white;
|
||||
border: none;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
|
||||
outline: none;
|
||||
.module-scroll-down__button {
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
border-radius: 22px;
|
||||
text-align: center;
|
||||
background-color: $color-light-35;
|
||||
border: none;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
|
||||
outline: none;
|
||||
|
||||
.icon {
|
||||
@include color-svg('../images/down.svg', $grey_l3);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.icon:hover {
|
||||
background-color: #616161;
|
||||
}
|
||||
|
||||
&.new-messages {
|
||||
background-color: $blue;
|
||||
.icon {
|
||||
@include color-svg('../images/down.svg', white);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #1472bd;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: $color-light-45;
|
||||
}
|
||||
}
|
||||
|
||||
.module-scroll-down__button--new-messages {
|
||||
background-color: $color-signal-blue;
|
||||
|
||||
&:hover {
|
||||
background-color: #1472bd;
|
||||
}
|
||||
}
|
||||
|
||||
.module-scroll-down__icon {
|
||||
@include color-svg('../images/down.svg', $color-white);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -114,6 +114,17 @@ button.emoji {
|
|||
|
||||
.emoji-panel-container {
|
||||
height: 0px;
|
||||
margin-bottom: 3px;
|
||||
|
||||
.ep-emojies {
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
.ep-categories {
|
||||
background-color: $color-light-10;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ep-e {
|
||||
background-image: url('../node_modules/emoji-datasource-apple/img/apple/sheets/64.png');
|
||||
background-size: 1734px;
|
||||
|
|
|
@ -13,7 +13,7 @@ body {
|
|||
margin: 0;
|
||||
font-family: $roboto;
|
||||
font-size: 14px;
|
||||
color: $grey_d;
|
||||
color: $color-light-90;
|
||||
}
|
||||
|
||||
.dark-overlay {
|
||||
|
@ -22,7 +22,7 @@ body {
|
|||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: black;
|
||||
background-color: $color-black;
|
||||
opacity: 0.25;
|
||||
z-index: 200;
|
||||
}
|
||||
|
@ -40,23 +40,21 @@ body {
|
|||
display: none;
|
||||
}
|
||||
|
||||
#header {
|
||||
h1 {
|
||||
margin: 0;
|
||||
line-height: $header-height;
|
||||
padding-left: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.title-bar {
|
||||
color: $color-light-90;
|
||||
|
||||
height: $header-height;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conversation-header button,
|
||||
.title-bar button {
|
||||
width: $button-height;
|
||||
height: $button-height;
|
||||
line-height: $button-height;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
.logo {
|
||||
margin-left: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 300;
|
||||
color: $color-light-90;
|
||||
}
|
||||
|
||||
button {
|
||||
|
@ -92,103 +90,6 @@ a {
|
|||
color: $blue;
|
||||
}
|
||||
|
||||
button.back {
|
||||
@include header-icon-black('../images/back.svg');
|
||||
}
|
||||
button.clock {
|
||||
@include header-icon-black('../images/clock.svg');
|
||||
}
|
||||
button.hamburger {
|
||||
@include header-icon-black('../images/menu.svg');
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: $border-radius;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
&.left {
|
||||
float: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
&.right {
|
||||
float: right;
|
||||
padding-right: 10px;
|
||||
}
|
||||
height: 0;
|
||||
|
||||
.vertical-align {
|
||||
height: $header-height;
|
||||
vertical-align: middle;
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-header .timer-menu {
|
||||
margin-right: 10px;
|
||||
|
||||
&:before {
|
||||
content: attr(data-time);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
height: 10px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: relative;
|
||||
float: right;
|
||||
|
||||
.hamburger {
|
||||
width: $button-height;
|
||||
height: $button-height;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.menu-list {
|
||||
display: none;
|
||||
position: absolute;
|
||||
color: $grey_d;
|
||||
z-index: 50;
|
||||
text-align: initial;
|
||||
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2);
|
||||
|
||||
li {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
padding: 5px 15px 5px 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey_l;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: relative;
|
||||
.choose-file {
|
||||
|
@ -248,13 +149,13 @@ $avatar-size: 44px;
|
|||
line-height: $avatar-size;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: white;
|
||||
color: $color-white;
|
||||
font-size: 18px;
|
||||
@include avatar-colors;
|
||||
background-color: $grey;
|
||||
}
|
||||
|
||||
.group-info-input {
|
||||
background: white;
|
||||
background: $color-white;
|
||||
|
||||
.group-avatar {
|
||||
display: inline-block;
|
||||
|
@ -303,23 +204,15 @@ $avatar-size: 44px;
|
|||
}
|
||||
}
|
||||
}
|
||||
// the old way
|
||||
.profileName {
|
||||
font-size: smaller;
|
||||
|
||||
&:before {
|
||||
content: '~';
|
||||
}
|
||||
}
|
||||
// the new way
|
||||
.profile-name {
|
||||
font-size: smaller;
|
||||
}
|
||||
$unread-badge-size: 21px;
|
||||
|
||||
.conversation-list-item {
|
||||
cursor: pointer;
|
||||
color: $color-light-90;
|
||||
|
||||
&:hover {
|
||||
background: #f8f8f8;
|
||||
background: $color-black-008;
|
||||
}
|
||||
|
||||
.number {
|
||||
|
@ -351,12 +244,6 @@ $avatar-size: 44px;
|
|||
padding: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
margin: 1px;
|
||||
|
||||
&.selected {
|
||||
background: rgb(236, 243, 252);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
|
@ -511,6 +398,8 @@ $avatar-size: 44px;
|
|||
content: 'Add: ';
|
||||
}
|
||||
|
||||
$loading-height: 16px;
|
||||
|
||||
.loading {
|
||||
position: relative;
|
||||
&::before {
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
@mixin hourglass($color) {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
@include color-svg('../images/hourglass_full.svg', transparent);
|
||||
background-size: 100%;
|
||||
|
||||
&,
|
||||
.sand,
|
||||
&:before,
|
||||
&:after {
|
||||
width: 13px;
|
||||
height: 11px;
|
||||
}
|
||||
.sand,
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.sand {
|
||||
background: $color;
|
||||
}
|
||||
&:after {
|
||||
@include color-svg('../images/hourglass_empty.svg', $color);
|
||||
}
|
||||
}
|
|
@ -3,12 +3,13 @@
|
|||
.inbox,
|
||||
.gutter {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expired {
|
||||
.conversation-stack,
|
||||
.gutter {
|
||||
height: calc(100% - 56px);
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,16 +19,12 @@
|
|||
}
|
||||
|
||||
.gutter {
|
||||
color: $grey_d;
|
||||
background-color: $color-black-008;
|
||||
float: left;
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.content {
|
||||
background-color: $grey_l;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
overflow-y: scroll;
|
||||
max-height: calc(100% - 88px);
|
||||
}
|
||||
}
|
||||
.network-status-container {
|
||||
|
@ -66,8 +63,6 @@
|
|||
}
|
||||
|
||||
.conversation-stack {
|
||||
padding-left: 300px;
|
||||
|
||||
.conversation {
|
||||
display: none;
|
||||
}
|
||||
|
@ -76,62 +71,49 @@
|
|||
}
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
height: $header-height;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #999999;
|
||||
transition: background-color 0.5s;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||
|
||||
.avatar {
|
||||
margin-bottom: -30px;
|
||||
border: solid 2px white;
|
||||
z-index: 10;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
line-height: 44px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
.inactive .conversation-header {
|
||||
background-color: $grey_l !important;
|
||||
color: $grey_d;
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
.verified-icon {
|
||||
@include color-svg('../images/verified-check.svg', $grey_d);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-bar {
|
||||
color: $color-light-90;
|
||||
|
||||
padding: 8px;
|
||||
padding-top: 0px;
|
||||
margin-top: -1px;
|
||||
|
||||
position: relative;
|
||||
.search-icon {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
float: left;
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
height: 33px;
|
||||
-webkit-mask: url('../images/search.svg') no-repeat left center;
|
||||
-webkit-mask-size: 100%;
|
||||
background-color: #ccc;
|
||||
background-color: $color-light-35;
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
$search-x-size: 16px;
|
||||
$search-padding-right: 12px;
|
||||
$search-padding-left: 30px;
|
||||
|
||||
input.search {
|
||||
border: none;
|
||||
border: 1px solid $color-black-02;
|
||||
padding: 0 $search-padding-right 0 $search-padding-left;
|
||||
margin: 0;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
outline: 0;
|
||||
height: $search-height;
|
||||
line-height: $search-height;
|
||||
width: 100%;
|
||||
border: solid 1px $grey_l;
|
||||
height: 32px;
|
||||
width: calc(100% - 16px);
|
||||
outline-offset: -2px;
|
||||
font-size: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
font-weight: normal;
|
||||
color: $color-light-35;
|
||||
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
|
||||
&.active {
|
||||
outline: solid 1px $blue;
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: $z-index-modal;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
|
|
|
@ -28,333 +28,3 @@
|
|||
@include color-svg($svg, black);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin avatar-colors {
|
||||
&.red {
|
||||
background-color: $material_red;
|
||||
}
|
||||
&.pink {
|
||||
background-color: $material_pink;
|
||||
}
|
||||
&.purple {
|
||||
background-color: $material_purple;
|
||||
}
|
||||
&.deep_purple {
|
||||
background-color: $material_deep_purple;
|
||||
}
|
||||
&.indigo {
|
||||
background-color: $material_indigo;
|
||||
}
|
||||
&.blue {
|
||||
background-color: $material_blue;
|
||||
}
|
||||
&.light_blue {
|
||||
background-color: $material_light_blue;
|
||||
}
|
||||
&.cyan {
|
||||
background-color: $material_cyan;
|
||||
}
|
||||
&.teal {
|
||||
background-color: $material_teal;
|
||||
}
|
||||
&.green {
|
||||
background-color: $material_green;
|
||||
}
|
||||
&.light_green {
|
||||
background-color: $material_light_green;
|
||||
}
|
||||
&.orange {
|
||||
background-color: $material_orange;
|
||||
}
|
||||
&.deep_orange {
|
||||
background-color: $material_deep_orange;
|
||||
}
|
||||
&.amber {
|
||||
background-color: $material_amber;
|
||||
}
|
||||
&.blue_grey {
|
||||
background-color: $material_blue_grey;
|
||||
}
|
||||
&.grey {
|
||||
background-color: #999999;
|
||||
}
|
||||
&.default {
|
||||
background-color: $blue;
|
||||
}
|
||||
}
|
||||
@mixin dark-avatar-colors {
|
||||
&.red {
|
||||
background-color: $dark_material_red;
|
||||
}
|
||||
&.pink {
|
||||
background-color: $dark_material_pink;
|
||||
}
|
||||
&.purple {
|
||||
background-color: $dark_material_purple;
|
||||
}
|
||||
&.deep_purple {
|
||||
background-color: $dark_material_deep_purple;
|
||||
}
|
||||
&.indigo {
|
||||
background-color: $dark_material_indigo;
|
||||
}
|
||||
&.blue {
|
||||
background-color: $dark_material_blue;
|
||||
}
|
||||
&.light_blue {
|
||||
background-color: $dark_material_light_blue;
|
||||
}
|
||||
&.cyan {
|
||||
background-color: $dark_material_cyan;
|
||||
}
|
||||
&.teal {
|
||||
background-color: $dark_material_teal;
|
||||
}
|
||||
&.green {
|
||||
background-color: $dark_material_green;
|
||||
}
|
||||
&.light_green {
|
||||
background-color: $dark_material_light_green;
|
||||
}
|
||||
&.orange {
|
||||
background-color: $dark_material_orange;
|
||||
}
|
||||
&.deep_orange {
|
||||
background-color: $dark_material_deep_orange;
|
||||
}
|
||||
&.amber {
|
||||
background-color: $dark_material_amber;
|
||||
}
|
||||
&.blue_grey {
|
||||
background-color: $dark_material_blue_grey;
|
||||
}
|
||||
&.grey {
|
||||
background-color: #666666;
|
||||
}
|
||||
&.default {
|
||||
background-color: $blue;
|
||||
}
|
||||
}
|
||||
@mixin twenty-percent-colors {
|
||||
&.red {
|
||||
background-color: rgba($dark_material_red, 0.2);
|
||||
}
|
||||
&.pink {
|
||||
background-color: rgba($dark_material_pink, 0.2);
|
||||
}
|
||||
&.purple {
|
||||
background-color: rgba($dark_material_purple, 0.2);
|
||||
}
|
||||
&.deep_purple {
|
||||
background-color: rgba($dark_material_deep_purple, 0.2);
|
||||
}
|
||||
&.indigo {
|
||||
background-color: rgba($dark_material_indigo, 0.2);
|
||||
}
|
||||
&.blue {
|
||||
background-color: rgba($dark_material_blue, 0.2);
|
||||
}
|
||||
&.light_blue {
|
||||
background-color: rgba($dark_material_light_blue, 0.2);
|
||||
}
|
||||
&.cyan {
|
||||
background-color: rgba($dark_material_cyan, 0.2);
|
||||
}
|
||||
&.teal {
|
||||
background-color: rgba($dark_material_teal, 0.2);
|
||||
}
|
||||
&.green {
|
||||
background-color: rgba($dark_material_green, 0.2);
|
||||
}
|
||||
&.light_green {
|
||||
background-color: rgba($dark_material_light_green, 0.2);
|
||||
}
|
||||
&.orange {
|
||||
background-color: rgba($dark_material_orange, 0.2);
|
||||
}
|
||||
&.deep_orange {
|
||||
background-color: rgba($dark_material_deep_orange, 0.2);
|
||||
}
|
||||
&.amber {
|
||||
background-color: rgba($dark_material_amber, 0.2);
|
||||
}
|
||||
&.blue_grey {
|
||||
background-color: rgba($dark_material_blue_grey, 0.2);
|
||||
}
|
||||
&.grey {
|
||||
background-color: rgba(#666666, 0.2);
|
||||
}
|
||||
&.default {
|
||||
background-color: rgba($blue, 0.2);
|
||||
}
|
||||
}
|
||||
@mixin text-colors {
|
||||
&.red {
|
||||
color: $material_red;
|
||||
}
|
||||
&.pink {
|
||||
color: $material_pink;
|
||||
}
|
||||
&.purple {
|
||||
color: $material_purple;
|
||||
}
|
||||
&.deep_purple {
|
||||
color: $material_deep_purple;
|
||||
}
|
||||
&.indigo {
|
||||
color: $material_indigo;
|
||||
}
|
||||
&.blue {
|
||||
color: $material_blue;
|
||||
}
|
||||
&.light_blue {
|
||||
color: $material_light_blue;
|
||||
}
|
||||
&.cyan {
|
||||
color: $material_cyan;
|
||||
}
|
||||
&.teal {
|
||||
color: $material_teal;
|
||||
}
|
||||
&.green {
|
||||
color: $material_green;
|
||||
}
|
||||
&.light_green {
|
||||
color: $material_light_green;
|
||||
}
|
||||
&.orange {
|
||||
color: $material_orange;
|
||||
}
|
||||
&.deep_orange {
|
||||
color: $material_deep_orange;
|
||||
}
|
||||
&.amber {
|
||||
color: $material_amber;
|
||||
}
|
||||
&.blue_grey {
|
||||
color: $material_blue_grey;
|
||||
}
|
||||
&.grey {
|
||||
color: #999999;
|
||||
}
|
||||
&.default {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Deduplicate these! Can SASS functions generate property names?
|
||||
@mixin message-replies-colors {
|
||||
&.red {
|
||||
border-left-color: $material_red;
|
||||
}
|
||||
&.pink {
|
||||
border-left-color: $material_pink;
|
||||
}
|
||||
&.purple {
|
||||
border-left-color: $material_purple;
|
||||
}
|
||||
&.deep_purple {
|
||||
border-left-color: $material_deep_purple;
|
||||
}
|
||||
&.indigo {
|
||||
border-left-color: $material_indigo;
|
||||
}
|
||||
&.blue {
|
||||
border-left-color: $material_blue;
|
||||
}
|
||||
&.light_blue {
|
||||
border-left-color: $material_light_blue;
|
||||
}
|
||||
&.cyan {
|
||||
border-left-color: $material_cyan;
|
||||
}
|
||||
&.teal {
|
||||
border-left-color: $material_teal;
|
||||
}
|
||||
&.green {
|
||||
border-left-color: $material_green;
|
||||
}
|
||||
&.light_green {
|
||||
border-left-color: $material_light_green;
|
||||
}
|
||||
&.orange {
|
||||
border-left-color: $material_orange;
|
||||
}
|
||||
&.deep_orange {
|
||||
border-left-color: $material_deep_orange;
|
||||
}
|
||||
&.amber {
|
||||
border-left-color: $material_amber;
|
||||
}
|
||||
&.blue_grey {
|
||||
border-left-color: $material_blue_grey;
|
||||
}
|
||||
&.grey {
|
||||
border-left-color: #999999;
|
||||
}
|
||||
&.default {
|
||||
border-left-color: $blue;
|
||||
}
|
||||
}
|
||||
@mixin dark-message-replies-colors {
|
||||
&.red {
|
||||
border-left-color: $dark_material_red;
|
||||
}
|
||||
&.pink {
|
||||
border-left-color: $dark_material_pink;
|
||||
}
|
||||
&.purple {
|
||||
border-left-color: $dark_material_purple;
|
||||
}
|
||||
&.deep_purple {
|
||||
border-left-color: $dark_material_deep_purple;
|
||||
}
|
||||
&.indigo {
|
||||
border-left-color: $dark_material_indigo;
|
||||
}
|
||||
&.blue {
|
||||
border-left-color: $dark_material_blue;
|
||||
}
|
||||
&.light_blue {
|
||||
border-left-color: $dark_material_light_blue;
|
||||
}
|
||||
&.cyan {
|
||||
border-left-color: $dark_material_cyan;
|
||||
}
|
||||
&.teal {
|
||||
border-left-color: $dark_material_teal;
|
||||
}
|
||||
&.green {
|
||||
border-left-color: $dark_material_green;
|
||||
}
|
||||
&.light_green {
|
||||
border-left-color: $dark_material_light_green;
|
||||
}
|
||||
&.orange {
|
||||
border-left-color: $dark_material_orange;
|
||||
}
|
||||
&.deep_orange {
|
||||
border-left-color: $dark_material_deep_orange;
|
||||
}
|
||||
&.amber {
|
||||
border-left-color: $dark_material_amber;
|
||||
}
|
||||
&.blue_grey {
|
||||
border-left-color: $dark_material_blue_grey;
|
||||
}
|
||||
&.grey {
|
||||
border-left-color: #666666;
|
||||
}
|
||||
&.default {
|
||||
border-left-color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin invert-text-color {
|
||||
color: white;
|
||||
|
||||
&::selection {
|
||||
background: white;
|
||||
color: $grey_d;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
max-width: 350px;
|
||||
margin: 100px auto;
|
||||
padding: 1em;
|
||||
background: white;
|
||||
background-color: $color-white;
|
||||
border-radius: $border-radius;
|
||||
overflow: auto;
|
||||
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
}
|
||||
}
|
||||
.recorder {
|
||||
background: $grey_l;
|
||||
background: $color-white;
|
||||
|
||||
button {
|
||||
float: right;
|
||||
|
@ -50,20 +50,20 @@
|
|||
}
|
||||
|
||||
.finish {
|
||||
background: lighten($green, 20%);
|
||||
border: 1px solid $green;
|
||||
background: lighten($color-core-green, 20%);
|
||||
border: 1px solid $color-core-green;
|
||||
|
||||
.icon {
|
||||
@include color-svg('../images/check.svg', $green);
|
||||
@include color-svg('../images/check.svg', $color-core-green);
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
background: lighten($red, 20%);
|
||||
border: 1px solid $red;
|
||||
background: lighten($color-core-red, 20%);
|
||||
border: 1px solid $color-core-red;
|
||||
|
||||
.icon {
|
||||
@include color-svg('../images/x.svg', $red);
|
||||
@include color-svg('../images/x.svg', $color-core-red);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
.light-theme {
|
||||
#header {
|
||||
background-color: $blue;
|
||||
color: white;
|
||||
transition: background-color 0.5s;
|
||||
|
||||
&.inactive {
|
||||
background-color: $grey_l;
|
||||
color: $grey_d;
|
||||
}
|
||||
}
|
||||
.contact-details .name {
|
||||
font-weight: 400;
|
||||
}
|
||||
.conversation.placeholder .conversation-header {
|
||||
display: none;
|
||||
}
|
||||
.conversation-header,
|
||||
.bubble {
|
||||
@include avatar-colors;
|
||||
}
|
||||
.bottom-bar {
|
||||
min-height: 10px;
|
||||
}
|
||||
.bubble {
|
||||
padding: 9px 12px;
|
||||
border-radius: $border-radius;
|
||||
box-shadow: 0 3px 3px -4px black;
|
||||
}
|
||||
|
||||
.outgoing .bubble {
|
||||
background-color: white;
|
||||
}
|
||||
.outgoing .hourglass {
|
||||
@include hourglass(#999);
|
||||
}
|
||||
.incoming .hourglass {
|
||||
@include hourglass(#fff);
|
||||
}
|
||||
|
||||
.incoming .bubble {
|
||||
.sender,
|
||||
.content,
|
||||
.body,
|
||||
.meta,
|
||||
a,
|
||||
.fileView {
|
||||
@include invert-text-color;
|
||||
}
|
||||
.attachments,
|
||||
.content {
|
||||
a {
|
||||
color: $grey_l;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.incoming .bubble .fileView .icon {
|
||||
@include color-svg('../images/file.svg', white);
|
||||
&.audio {
|
||||
@include color-svg('../images/audio.svg', white);
|
||||
}
|
||||
&.video {
|
||||
@include color-svg('../images/video.svg', white);
|
||||
}
|
||||
&.voice {
|
||||
@include color-svg('../images/voice.svg', white);
|
||||
}
|
||||
}
|
||||
|
||||
button.clock {
|
||||
@include header-icon-white('../images/clock.svg');
|
||||
}
|
||||
.inactive button.clock {
|
||||
@include header-icon-black('../images/clock.svg');
|
||||
}
|
||||
button.hamburger {
|
||||
@include header-icon-white('../images/menu.svg');
|
||||
}
|
||||
.inactive button.hamburger {
|
||||
@include header-icon-black('../images/menu.svg');
|
||||
}
|
||||
button.back {
|
||||
@include header-icon-white('../images/back.svg');
|
||||
}
|
||||
.inactive button.back {
|
||||
@include header-icon-black('../images/back.svg');
|
||||
}
|
||||
}
|
|
@ -1,19 +1,3 @@
|
|||
// colors
|
||||
$blue_l: #a2d2f4;
|
||||
$blue: #2090ea;
|
||||
$grey_l: #f3f3f3;
|
||||
$grey_l1: #bdbdbd;
|
||||
$grey_l1_5: #e6e6e6;
|
||||
$grey_l2: #d9d9d9; // ~ Equivalent to darken($grey_l, 10%), unreliably compiles
|
||||
$grey_l3: darken($grey_l, 20%);
|
||||
$grey_l4: darken($grey_l, 40%);
|
||||
$grey: #616161;
|
||||
$grey_d: #454545;
|
||||
$green: #47d647;
|
||||
$red: #ef8989;
|
||||
|
||||
$z-index-modal: 100;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto-Light';
|
||||
src: url('../fonts/Roboto-Light.ttf') format('truetype');
|
||||
|
@ -37,62 +21,69 @@ $z-index-modal: 100;
|
|||
src: url('../fonts/Roboto-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
$roboto: Roboto, 'Helvetica Neue', Arial, Helvetica, sans-serif;
|
||||
$roboto-light: Roboto-Light, 'Helvetica Neue', Arial, Helvetica, sans-serif;
|
||||
|
||||
$header-height: 64px;
|
||||
// New colors
|
||||
|
||||
$color-signal-blue: #2090ea;
|
||||
$color-core-green: #4caf50;
|
||||
$color-core-red: #f44336;
|
||||
|
||||
$color-white: #ffffff;
|
||||
$color-white-02: rgba($color-white, 0.2);
|
||||
$color-white-07: rgba($color-white, 0.7);
|
||||
$color-white-075: rgba($color-white, 0.75);
|
||||
$color-light-02: #f9fafa;
|
||||
$color-light-10: #eeefef;
|
||||
$color-light-35: #a4a6a9;
|
||||
$color-light-45: #8b8e91;
|
||||
$color-light-60: #62656a;
|
||||
$color-light-90: #070c14;
|
||||
|
||||
$color-dark-05: #efefef;
|
||||
$color-dark-30: #a8a9aa;
|
||||
$color-dark-55: #88898c;
|
||||
$color-dark-60: #797a7c;
|
||||
$color-dark-70: #414347;
|
||||
$color-dark-85: #1a1c20;
|
||||
$color-black: #000000;
|
||||
$color-black-008: rgba($color-black, 0.08);
|
||||
$color-black-012: rgba($color-black, 0.12);
|
||||
$color-black-02: rgba($color-black, 0.2);
|
||||
$color-black-04: rgba($color-black, 0.4);
|
||||
|
||||
$color-conversation-grey: #757575;
|
||||
$color-conversation-blue: #1976d2;
|
||||
$color-conversation-cyan: #00838f;
|
||||
$color-conversation-deep_orange: #bf360c;
|
||||
$color-conversation-green: #2e7d32;
|
||||
$color-conversation-indigo: #3949ab;
|
||||
$color-conversation-pink: #d81b60;
|
||||
$color-conversation-purple: #8e24aa;
|
||||
$color-conversation-red: #d32f2f;
|
||||
$color-conversation-teal: #00796b;
|
||||
|
||||
// Old colors
|
||||
|
||||
$blue_l: #a2d2f4;
|
||||
$blue: #2090ea;
|
||||
$grey_l: #f3f3f3;
|
||||
$grey_l1: #bdbdbd;
|
||||
$grey_l1_5: #e6e6e6;
|
||||
$grey_l2: #d9d9d9; // ~ Equivalent to darken($grey_l, 10%), unreliably compiles
|
||||
$grey_l3: darken($grey_l, 20%);
|
||||
$grey_l4: darken($grey_l, 40%);
|
||||
$grey: #616161;
|
||||
$grey_d: #454545;
|
||||
|
||||
// A few layout variables used cross-file
|
||||
|
||||
$header-height: 48px;
|
||||
$button-height: 24px;
|
||||
$header-color: $blue;
|
||||
|
||||
$search-height: 36px;
|
||||
$search-padding-right: 10px;
|
||||
$search-padding-left: 65px;
|
||||
$search-padding-left-ios: 30px;
|
||||
$search-x-size: 16px;
|
||||
|
||||
$unread-badge-size: 21px;
|
||||
$loading-height: 16px;
|
||||
|
||||
$border-radius: 5px;
|
||||
|
||||
$error-icon-size: 24px;
|
||||
|
||||
$font-size: 14px;
|
||||
$font-size-small: (13/14) + em;
|
||||
|
||||
$material_red: #ef5350;
|
||||
$material_pink: #ec407a;
|
||||
$material_purple: #ab47bc;
|
||||
$material_deep_purple: #7e57c2;
|
||||
$material_indigo: #5c6bc0;
|
||||
$material_blue: #2196f3;
|
||||
$material_light_blue: #03a9f4;
|
||||
$material_cyan: #00bcd4;
|
||||
$material_teal: #009688;
|
||||
$material_green: #4caf50;
|
||||
$material_light_green: #7cb342;
|
||||
$material_orange: #ff9800;
|
||||
$material_deep_orange: #ff5722;
|
||||
$material_amber: #ffb300;
|
||||
$material_blue_grey: #607d8b;
|
||||
|
||||
$dark_material_red: #d32f2f;
|
||||
$dark_material_pink: #c2185b;
|
||||
$dark_material_purple: #7b1fa2;
|
||||
$dark_material_deep_purple: #512da8;
|
||||
$dark_material_indigo: #303f9f;
|
||||
$dark_material_blue: #1976d2;
|
||||
$dark_material_light_blue: #0288d1;
|
||||
$dark_material_cyan: #0097a7;
|
||||
$dark_material_teal: #00796b;
|
||||
$dark_material_green: #388e3c;
|
||||
$dark_material_light_green: #689f38;
|
||||
$dark_material_orange: #f57c00;
|
||||
$dark_material_deep_orange: #e64a19;
|
||||
$dark_material_amber: #ffa000;
|
||||
$dark_material_blue_grey: #455a64;
|
||||
|
||||
// Android
|
||||
$android-bubble-padding-horizontal: 12px;
|
||||
$android-bubble-padding-vertical: 9px;
|
||||
$android-bubble-quote-padding: 4px;
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
// Components
|
||||
@import 'progress';
|
||||
@import 'hourglass';
|
||||
@import 'modal';
|
||||
@import 'debugLog';
|
||||
@import 'lightbox';
|
||||
|
@ -16,7 +15,6 @@
|
|||
// Build the main view
|
||||
@import 'index';
|
||||
@import 'conversation';
|
||||
@import 'theme_light';
|
||||
@import 'theme_dark';
|
||||
|
||||
// New CSS
|
||||
|
|
|
@ -12,7 +12,10 @@ describe('i18n', function() {
|
|||
});
|
||||
it('returns message with multiple substitutions', function() {
|
||||
const actual = i18n('theyChangedTheTimer', ['Someone', '5 minutes']);
|
||||
assert.equal(actual, 'Someone set the timer to 5 minutes.');
|
||||
assert.equal(
|
||||
actual,
|
||||
'Someone set the disappearing message timer to 5 minutes'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
151
test/index.html
|
@ -96,37 +96,7 @@
|
|||
<p> {{ content }}</p>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='conversation'>
|
||||
<div class='conversation-header {{ avatar.color }}'>
|
||||
<div class='header-buttons left'>
|
||||
<div class='vertical-align'>
|
||||
<button class='back hide'></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class='header-buttons right'>
|
||||
<div class='vertical-align'>
|
||||
<div class='conversation-menu menu'>
|
||||
<button class='hamburger' alt='conversation menu'></button>
|
||||
<ul class='menu-list'>
|
||||
<li class='disappearing-messages'>{{ disappearing-messages }}</li>
|
||||
{{#group}}
|
||||
<li class='show-members'>{{ show-members }}</li>
|
||||
<!-- <li class='update-group'>Update group</li> -->
|
||||
<!-- <li class='leave-group'>Leave group</li> -->
|
||||
{{/group}}
|
||||
{{^group}}
|
||||
{{ ^isMe }}
|
||||
<li class='show-identity'>{{ show-identity }}</li>
|
||||
{{ /isMe }}
|
||||
<li class='end-session'>{{ end-session }}</li>
|
||||
{{/group}}
|
||||
<li class='destroy'>{{ destroy }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class='conversation-title'></span>
|
||||
{{> avatar }}
|
||||
</div>
|
||||
<div class='conversation-header'></div>
|
||||
<div class='main panel'>
|
||||
<div class='discussion-container'>
|
||||
<div class='bar-container hide'>
|
||||
|
@ -172,62 +142,6 @@
|
|||
<img src='{{ source }}' class='preview' />
|
||||
<a class='x close' alt='remove attachment' href='#'></a>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='hasRetry'>
|
||||
{{ messageNotSent }}
|
||||
<span href='#' class='retry'>{{ resend }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='keychange'>
|
||||
<span class='content' dir='auto'><span class='shield icon'></span> {{ content }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='verified-change'>
|
||||
<span class='content' dir='auto'><span class='{{ icon }} icon'></span> {{ content }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='message'>
|
||||
{{> avatar }}
|
||||
<div class='bubble {{ avatar.color }}'>
|
||||
<div class='sender' dir='auto'>
|
||||
{{ sender }}
|
||||
{{ #profileName }}
|
||||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
{{ #hasAttachments }}
|
||||
<div class='attachments'></div>
|
||||
{{ /hasAttachments }}
|
||||
{{ #hasBody }}
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}
|
||||
<div class='body'></div>
|
||||
{{ /message }}
|
||||
</div>
|
||||
{{ /hasBody }}
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
{{ #hoverIcon }}
|
||||
<div class='menu-container menu'>
|
||||
<div class='menu-anchor'>
|
||||
<span class='dots-horizontal-icon'></span>
|
||||
<ul class='menu-list'>
|
||||
<li class='reply'>{{ reply }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ /hoverIcon }}
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='hourglass'>
|
||||
<span class='hourglass'><span class='sand'></span></span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='expirationTimerUpdate'>
|
||||
<span class='content'><span class='icon clock'></span> {{ content }}</span>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='new-group-update'>
|
||||
<div class='conversation-header'>
|
||||
<button class='back'></button>
|
||||
|
@ -304,48 +218,6 @@
|
|||
<script type='text/x-tmpl-mustache' id='attachment-type-modal'>
|
||||
Sorry, your attachment has a type, {{type}}, that is not currently supported.
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='message-detail'>
|
||||
<div class='container'>
|
||||
<div class='message-container'></div>
|
||||
<div class='info'>
|
||||
<table>
|
||||
{{ #errors }}
|
||||
<tr>
|
||||
<td class='label'>{{ errorLabel }}</td>
|
||||
<td> <span class='error-message'>{{message}}</span> </td>
|
||||
</tr>
|
||||
{{ /errors }}
|
||||
<tr>
|
||||
<td class='label'>{{ sent }}</td>
|
||||
<td> {{ sent_at }}</td>
|
||||
</tr>
|
||||
{{ #received_at }}
|
||||
<tr>
|
||||
<td class='label'>{{ received }}</td>
|
||||
<td> {{ received_at }}</td>
|
||||
</tr>
|
||||
{{ /received_at }}
|
||||
<tr> <td class='tofrom label'>{{tofrom}}</td> </tr>
|
||||
</table>
|
||||
<div class='contacts'>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='identity-key-send-error'>
|
||||
<div class='container'>
|
||||
<div class='explanation'>
|
||||
{{ errorExplanation }}
|
||||
</div>
|
||||
<div class='safety-number'>
|
||||
<button class='show-safety-number grey'>{{ showSafetyNumber }}</button>
|
||||
</div>
|
||||
<div class='actions'>
|
||||
<button class='send-anyway grey'>{{ sendAnyway }}</button>
|
||||
<button class='cancel grey'>{{ cancel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='group-member-list'>
|
||||
<div class='container'>
|
||||
{{ #summary }} <div class='summary'>{{ summary }}</div>{{ /summary }}
|
||||
|
@ -425,24 +297,6 @@
|
|||
<span class='error-message'>{{message}}</span>
|
||||
{{ /message }}
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='contact-detail'>
|
||||
<div class='clearfix'>
|
||||
{{> avatar }}
|
||||
<div class='contact-details'>
|
||||
{{ #errors }}
|
||||
<div class='error-icon-container'>
|
||||
<span class='error-icon'></span>
|
||||
</div>
|
||||
{{ /errors }}
|
||||
<span class='name' dir='auto'>{{ name }}</span>
|
||||
{{ #errors }}
|
||||
{{ #message }}
|
||||
<p class='error-message'>{{message}}</p>
|
||||
{{ /message }}
|
||||
{{ /errors }}
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='link_to_support'>
|
||||
<a href='http://support.signal.org/hc/articles/213134107' target='_blank'>
|
||||
{{ learnMore }}
|
||||
|
@ -514,7 +368,6 @@
|
|||
<script type='text/javascript' src='../js/views/timestamp_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/message_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/key_verification_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/message_detail_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/message_list_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/group_member_list_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/recorder_view.js' data-cover></script>
|
||||
|
@ -528,12 +381,10 @@
|
|||
<script type='text/javascript' src='../js/views/last_seen_indicator_view.js' data-cover></script>
|
||||
<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/identity_key_send_error_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/clear_data_view.js'></script>
|
||||
|
||||
<script type="text/javascript" src="views/whisper_view_test.js"></script>
|
||||
<script type="text/javascript" src="views/group_update_view_test.js"></script>
|
||||
<script type="text/javascript" src="views/message_view_test.js"></script>
|
||||
<script type="text/javascript" src="views/attachment_view_test.js"></script>
|
||||
<script type="text/javascript" src="views/timestamp_view_test.js"></script>
|
||||
<script type="text/javascript" src="views/list_view_test.js"></script>
|
||||
|
|
|
@ -164,21 +164,21 @@
|
|||
message = messages.add({ group_update: { left: 'Alice' } });
|
||||
assert.equal(
|
||||
message.getDescription(),
|
||||
'Alice left the group.',
|
||||
'Alice left the group',
|
||||
'Notes one person leaving the group.'
|
||||
);
|
||||
|
||||
message = messages.add({ group_update: { name: 'blerg' } });
|
||||
assert.equal(
|
||||
message.getDescription(),
|
||||
"Updated the group. Title is now 'blerg'.",
|
||||
"Title is now 'blerg'",
|
||||
'Returns a single notice if only group_updates.name changes.'
|
||||
);
|
||||
|
||||
message = messages.add({ group_update: { joined: ['Bob'] } });
|
||||
assert.equal(
|
||||
message.getDescription(),
|
||||
'Updated the group. Bob joined the group.',
|
||||
'Bob joined the group',
|
||||
'Returns a single notice if only group_updates.joined changes.'
|
||||
);
|
||||
|
||||
|
@ -187,7 +187,7 @@
|
|||
});
|
||||
assert.equal(
|
||||
message.getDescription(),
|
||||
'Updated the group. Bob, Alice, Eve joined the group.',
|
||||
'Bob, Alice, Eve joined the group',
|
||||
'Notes when >1 person joins the group.'
|
||||
);
|
||||
|
||||
|
@ -196,7 +196,7 @@
|
|||
});
|
||||
assert.equal(
|
||||
message.getDescription(),
|
||||
"Updated the group. Title is now 'blerg'. Bob joined the group.",
|
||||
"Title is now 'blerg', Bob joined the group",
|
||||
'Notes when there are multiple changes to group_updates properties.'
|
||||
);
|
||||
|
||||
|
|
|
@ -278,6 +278,12 @@ describe('Message', () => {
|
|||
return 'abc/abcdefg';
|
||||
},
|
||||
getRegionCode: () => 'US',
|
||||
getAbsoluteAttachmentPath: () => 'some/path/on/disk',
|
||||
makeObjectUrl: () => 'blob://FAKE',
|
||||
revokeObjectUrl: () => null,
|
||||
getImageDimensions: () => ({ height: 10, width: 15 }),
|
||||
makeImageThumbnail: () => new Blob(),
|
||||
makeVideoScreenshot: () => new Blob(),
|
||||
};
|
||||
const actual = await Message.upgradeSchema(input, context);
|
||||
assert.deepEqual(actual, expected);
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
/* global window: false */
|
||||
|
||||
// Because we aren't hosting the Style Guide in Electron, we can't rely on preload.js
|
||||
// to set things up for us. This gives us the minimum bar shims for everything it
|
||||
// provdes.
|
||||
//
|
||||
// Remember, the idea here is just to enable visual testing, no full functionality. Most
|
||||
// of thise can be very simple.
|
||||
|
||||
window.PROTO_ROOT = '/protos';
|
||||
window.nodeSetImmediate = () => {};
|
||||
|
||||
window.libphonenumber = {
|
||||
parse: number => ({
|
||||
e164: number,
|
||||
isValidNumber: true,
|
||||
getCountryCode: () => '1',
|
||||
getNationalNumber: () => number,
|
||||
}),
|
||||
isValidNumber: () => true,
|
||||
getRegionCodeForNumber: () => '1',
|
||||
format: number => number.e164,
|
||||
PhoneNumberFormat: {},
|
||||
};
|
||||
|
||||
window.Signal = {};
|
||||
window.Signal.Backup = {};
|
||||
window.Signal.Crypto = {};
|
||||
window.Signal.Logs = {};
|
||||
window.Signal.Migrations = {
|
||||
getPlaceholderMigrations: () => [
|
||||
{
|
||||
migrate: (transaction, next) => {
|
||||
console.log('migration version 1');
|
||||
transaction.db.createObjectStore('conversations');
|
||||
next();
|
||||
},
|
||||
version: 1,
|
||||
},
|
||||
{
|
||||
migrate: (transaction, next) => {
|
||||
console.log('migration version 2');
|
||||
const messages = transaction.db.createObjectStore('messages');
|
||||
messages.createIndex('expires_at', 'expireTimer', { unique: false });
|
||||
next();
|
||||
},
|
||||
version: 2,
|
||||
},
|
||||
{
|
||||
migrate: (transaction, next) => {
|
||||
console.log('migration version 3');
|
||||
transaction.db.createObjectStore('items');
|
||||
next();
|
||||
},
|
||||
version: 3,
|
||||
},
|
||||
],
|
||||
loadAttachmentData: attachment => Promise.resolve(attachment),
|
||||
getAbsoluteAttachmentPath: path => path,
|
||||
};
|
||||
|
||||
window.Signal.Components = {};
|
||||
|
||||
window.i18n = () => '';
|
||||
|
||||
// Ideally we don't need to add things here. We want to add them in StyleGuideUtil, which
|
||||
// means that references to these things can't be early-bound, not capturing the direct
|
||||
// reference to the function on file load.
|
||||
window.Signal.Migrations.V17 = {};
|
||||
window.Signal.OS = {};
|
||||
window.Signal.Types = {};
|
||||
window.Signal.Types.Attachment = {};
|
||||
window.Signal.Types.Conversation = {};
|
||||
window.Signal.Types.Errors = {};
|
||||
window.Signal.Types.Message = {
|
||||
initializeSchemaVersion: attributes => attributes,
|
||||
};
|
||||
window.Signal.Types.MIME = {};
|
||||
window.Signal.Types.Settings = {};
|
||||
window.Signal.Views = {};
|
||||
window.Signal.Views.Initialization = {};
|
||||
window.Signal.Workflow = {};
|
|
@ -1,90 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
/* global window: false */
|
||||
|
||||
// Taken from background.html.
|
||||
// Templates are here solely to support the Backbone views rendered in the Style Guide.
|
||||
|
||||
// Note: Any change here must be reflected in background.html to be reflected in the app
|
||||
// and test/index.html to be reflected in the unit tests.
|
||||
|
||||
window.Whisper.View.Templates = {
|
||||
hasRetry: `
|
||||
{{ messageNotSent }} <span href='#' class='retry'>{{ resend }}</span>
|
||||
`,
|
||||
'some-failed': `
|
||||
{{ someFailed }}
|
||||
`,
|
||||
keychange: `
|
||||
<span class='content' dir='auto'>
|
||||
<span class='shield icon'></span> {{ content }}
|
||||
</span>
|
||||
`,
|
||||
'verified-change': `
|
||||
<span class='content' dir='auto'>
|
||||
<span class='{{ icon }} icon'></span> {{ content }}
|
||||
</span>
|
||||
`,
|
||||
message: `
|
||||
{{> avatar }}
|
||||
<div class='bubble {{ avatar.color }}'>
|
||||
<div class='sender' dir='auto'>
|
||||
{{ sender }}
|
||||
{{ #profileName }}
|
||||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
{{ #hasAttachments }}
|
||||
<div class='attachments'></div>
|
||||
{{ /hasAttachments }}
|
||||
{{ #hasBody }}
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}
|
||||
<div class='body'></div>
|
||||
{{ /message }}
|
||||
</div>
|
||||
{{ /hasBody }}
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
{{ #hoverIcon }}
|
||||
<div class='menu-container menu'>
|
||||
<div class='menu-anchor'>
|
||||
<span class='dots-horizontal-icon'></span>
|
||||
<ul class='menu-list'>
|
||||
<li class='reply'>{{ reply }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ /hoverIcon }}
|
||||
</div>
|
||||
`,
|
||||
hourglass: `
|
||||
<span class='hourglass'><span class='sand'></span></span>
|
||||
`,
|
||||
expirationTimerUpdate: `
|
||||
<span class='content'><span class='icon clock'></span> {{ content }}</span>
|
||||
`,
|
||||
'file-view': `
|
||||
<div class='icon {{ mediaType }}'></div>
|
||||
<div class='text'>
|
||||
<div class='fileName' title='{{ altText }}'>
|
||||
{{ fileName }}
|
||||
</div>
|
||||
<div class='fileSize'>{{ fileSize }}</div>
|
||||
</div>
|
||||
`,
|
||||
'error-icon': `
|
||||
<span class='error-icon'>
|
||||
</span>
|
||||
{{ #message }}
|
||||
<span class='error-message'>{{message}}</span>
|
||||
{{ /message }}
|
||||
`,
|
||||
};
|
|
@ -5,6 +5,22 @@
|
|||
'use strict';
|
||||
|
||||
describe('AttachmentView', () => {
|
||||
var convo, message;
|
||||
|
||||
before(async () => {
|
||||
await clearDatabase();
|
||||
convo = new Whisper.Conversation({ id: 'foo' });
|
||||
message = convo.messageCollection.add({
|
||||
conversationId: convo.id,
|
||||
body: 'hello world',
|
||||
type: 'outgoing',
|
||||
source: '+14158675309',
|
||||
received_at: Date.now(),
|
||||
});
|
||||
|
||||
await storage.put('number_id', '+18088888888.1');
|
||||
});
|
||||
|
||||
describe('with arbitrary files', () => {
|
||||
it('should render a file view', () => {
|
||||
const attachment = {
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
describe('MessageView', function() {
|
||||
var convo, message;
|
||||
|
||||
before(async () => {
|
||||
await clearDatabase();
|
||||
convo = new Whisper.Conversation({ id: 'foo' });
|
||||
message = convo.messageCollection.add({
|
||||
conversationId: convo.id,
|
||||
body: 'hello world',
|
||||
type: 'outgoing',
|
||||
source: '+14158675309',
|
||||
received_at: Date.now(),
|
||||
});
|
||||
|
||||
await storage.put('number_id', '+18088888888.1');
|
||||
});
|
||||
|
||||
it('should display the message text', function() {
|
||||
var view = new Whisper.MessageView({ model: message }).render();
|
||||
assert.match(view.$el.text(), /hello world/);
|
||||
});
|
||||
|
||||
it('should auto-update the message text', function() {
|
||||
var view = new Whisper.MessageView({ model: message }).render();
|
||||
message.set('body', 'goodbye world');
|
||||
assert.match(view.$el.html(), /goodbye world/);
|
||||
});
|
||||
|
||||
it('should have a nice timestamp', function() {
|
||||
var view = new Whisper.MessageView({ model: message });
|
||||
message.set({ sent_at: Date.now() - 5000 });
|
||||
view.render();
|
||||
assert.match(view.$el.html(), /now/);
|
||||
|
||||
message.set({ sent_at: Date.now() - 60000 });
|
||||
view.render();
|
||||
assert.match(view.$el.html(), /min/);
|
||||
|
||||
message.set({ sent_at: Date.now() - 3600000 });
|
||||
view.render();
|
||||
assert.match(view.$el.html(), /hour/);
|
||||
});
|
||||
it('should not imply messages are from the future', function() {
|
||||
var view = new Whisper.MessageView({ model: message });
|
||||
message.set({ sent_at: Date.now() + 60000 });
|
||||
view.render();
|
||||
assert.match(view.$el.html(), /now/);
|
||||
});
|
||||
|
||||
it('should go away when the model is destroyed', function() {
|
||||
var view = new Whisper.MessageView({ model: message });
|
||||
var div = $('<div>').append(view.$el);
|
||||
message.destroy();
|
||||
assert.strictEqual(div.find(view.$el).length, 0);
|
||||
});
|
||||
|
||||
it('allows links', function() {
|
||||
var url = 'http://example.com';
|
||||
message.set('body', url);
|
||||
var view = new Whisper.MessageView({ model: message });
|
||||
view.render();
|
||||
var link = view.$el.find('.body a');
|
||||
assert.strictEqual(link.length, 1);
|
||||
assert.strictEqual(link.text(), url);
|
||||
assert.strictEqual(link.attr('href'), url);
|
||||
});
|
||||
|
||||
it('disallows xss', function() {
|
||||
var xss = '<script>alert("pwnd")</script>';
|
||||
message.set('body', xss);
|
||||
var view = new Whisper.MessageView({ model: message });
|
||||
view.render();
|
||||
assert.include(view.$el.text(), xss); // should appear as escaped text
|
||||
assert.strictEqual(view.$el.find('script').length, 0); // should not appear as html
|
||||
});
|
||||
|
||||
it('supports emoji', function() {
|
||||
message.set('body', 'I \u2764\uFE0F emoji!');
|
||||
var view = new Whisper.MessageView({ model: message });
|
||||
view.render();
|
||||
var img = view.$el.find('.content img');
|
||||
assert.strictEqual(img.length, 1);
|
||||
assert.strictEqual(
|
||||
img.attr('src'),
|
||||
'node_modules/emoji-datasource-apple/img/apple/64/2764-fe0f.png'
|
||||
);
|
||||
assert.strictEqual(img.attr('title'), ':heart:');
|
||||
assert.strictEqual(img.attr('class'), 'emoji');
|
||||
});
|
||||
});
|
|
@ -10,7 +10,6 @@ describe('ScrollDownButtonView', function() {
|
|||
var view = new Whisper.ScrollDownButtonView({ count: 1 });
|
||||
view.render();
|
||||
assert.equal(view.count, 1);
|
||||
assert.match(view.$el.html(), /new-messages/);
|
||||
assert.match(view.$el.html(), /New message below/);
|
||||
});
|
||||
|
||||
|
@ -19,7 +18,6 @@ describe('ScrollDownButtonView', function() {
|
|||
view.render();
|
||||
assert.equal(view.count, 2);
|
||||
|
||||
assert.match(view.$el.html(), /new-messages/);
|
||||
assert.match(view.$el.html(), /New messages below/);
|
||||
});
|
||||
|
||||
|
@ -27,9 +25,9 @@ describe('ScrollDownButtonView', function() {
|
|||
var view = new Whisper.ScrollDownButtonView();
|
||||
view.render();
|
||||
assert.equal(view.count, 0);
|
||||
assert.notMatch(view.$el.html(), /new-messages/);
|
||||
assert.notMatch(view.$el.html(), /New message below/);
|
||||
view.increment(1);
|
||||
assert.equal(view.count, 1);
|
||||
assert.match(view.$el.html(), /new-messages/);
|
||||
assert.match(view.$el.html(), /New message below/);
|
||||
});
|
||||
});
|
||||
|
|
61
ts/components/Intl.md
Normal file
|
@ -0,0 +1,61 @@
|
|||
#### No replacements
|
||||
|
||||
```jsx
|
||||
<Intl id="leftTheGroup" i18n={util.i18n} />
|
||||
```
|
||||
|
||||
#### Single string replacement
|
||||
|
||||
```jsx
|
||||
<Intl id="leftTheGroup" i18n={util.i18n} components={['Alice']} />
|
||||
```
|
||||
|
||||
#### Single tag replacement
|
||||
|
||||
```jsx
|
||||
<Intl
|
||||
id="leftTheGroup"
|
||||
i18n={util.i18n}
|
||||
components={[
|
||||
<button
|
||||
key="external-2"
|
||||
style={{ backgroundColor: 'blue', color: 'white' }}
|
||||
>
|
||||
Alice
|
||||
</button>,
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Multiple string replacement
|
||||
|
||||
```jsx
|
||||
<Intl
|
||||
id="changedSinceVerified"
|
||||
i18n={util.i18n}
|
||||
components={['Alice', 'Bob']}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Multiple tag replacement
|
||||
|
||||
```jsx
|
||||
<Intl
|
||||
id="changedSinceVerified"
|
||||
i18n={util.i18n}
|
||||
components={[
|
||||
<button
|
||||
key="external-1"
|
||||
style={{ backgroundColor: 'blue', color: 'white' }}
|
||||
>
|
||||
Alice
|
||||
</button>,
|
||||
<button
|
||||
key="external-2"
|
||||
style={{ backgroundColor: 'black', color: 'white' }}
|
||||
>
|
||||
Bob
|
||||
</button>,
|
||||
]}
|
||||
/>
|
||||
```
|
79
ts/components/Intl.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Localizer, RenderTextCallback } from '../types/Util';
|
||||
|
||||
type FullJSX = Array<JSX.Element | string> | JSX.Element | string;
|
||||
|
||||
interface Props {
|
||||
/** The translation string id */
|
||||
id: string;
|
||||
i18n: Localizer;
|
||||
components?: Array<FullJSX>;
|
||||
renderText?: RenderTextCallback;
|
||||
}
|
||||
|
||||
export class Intl extends React.Component<Props> {
|
||||
public static defaultProps: Partial<Props> = {
|
||||
renderText: ({ text }) => text,
|
||||
};
|
||||
|
||||
public getComponent(index: number): FullJSX | null {
|
||||
const { id, components } = this.props;
|
||||
|
||||
if (!components || !components.length || components.length <= index) {
|
||||
// tslint:disable-next-line no-console
|
||||
console.log(
|
||||
`Error: Intl missing provided components for id ${id}, index ${index}`
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return components[index];
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { id, i18n, renderText } = this.props;
|
||||
|
||||
const text = i18n(id);
|
||||
const results: Array<any> = [];
|
||||
const FIND_REPLACEMENTS = /\$[^$]+\$/g;
|
||||
|
||||
// We have to do this, because renderText is not required in our Props object,
|
||||
// but it is always provided via defaultProps.
|
||||
if (!renderText) {
|
||||
return;
|
||||
}
|
||||
|
||||
let componentIndex = 0;
|
||||
let key = 0;
|
||||
let lastTextIndex = 0;
|
||||
let match = FIND_REPLACEMENTS.exec(text);
|
||||
|
||||
if (!match) {
|
||||
return renderText({ text, key: 0 });
|
||||
}
|
||||
|
||||
while (match) {
|
||||
if (lastTextIndex < match.index) {
|
||||
const textWithNoReplacements = text.slice(lastTextIndex, match.index);
|
||||
results.push(renderText({ text: textWithNoReplacements, key: key }));
|
||||
key += 1;
|
||||
}
|
||||
|
||||
results.push(this.getComponent(componentIndex));
|
||||
componentIndex += 1;
|
||||
|
||||
// @ts-ignore
|
||||
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
|
||||
match = FIND_REPLACEMENTS.exec(text);
|
||||
}
|
||||
|
||||
if (lastTextIndex < text.length) {
|
||||
results.push(renderText({ text: text.slice(lastTextIndex), key: key }));
|
||||
key += 1;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
|
@ -3,17 +3,23 @@ const noop = () => {};
|
|||
|
||||
const messages = [
|
||||
{
|
||||
objectURL: 'https://placekitten.com/800/600',
|
||||
objectURL: 'https://placekitten.com/799/600',
|
||||
attachments: [{ contentType: 'image/jpeg' }],
|
||||
},
|
||||
{
|
||||
objectURL: 'https://placekitten.com/900/600',
|
||||
attachments: [{ contentType: 'image/jpeg' }],
|
||||
},
|
||||
// Unsupported image type
|
||||
{
|
||||
objectURL: 'foo.tif',
|
||||
attachments: [{ contentType: 'image/tiff' }],
|
||||
},
|
||||
// Video
|
||||
{
|
||||
objectURL: util.mp4ObjectUrl,
|
||||
attachments: [{ contentType: 'video/mp4' }],
|
||||
},
|
||||
{
|
||||
objectURL: 'https://placekitten.com/980/800',
|
||||
attachments: [{ contentType: 'image/jpeg' }],
|
||||
|
|
|
@ -1,32 +1,26 @@
|
|||
#### With name and profile
|
||||
#### Number, name and profile
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="+12025550011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
```
|
||||
|
||||
#### Profile, no name
|
||||
#### Number and profile, no name
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
phoneNumber="+12025550011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
```
|
||||
|
||||
#### No name, no profile
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ContactName i18n={util.i18n} phoneNumber="+12025550011" />
|
||||
</div>
|
||||
<ContactName i18n={util.i18n} phoneNumber="(202) 555-0011" />
|
||||
```
|
||||
|
|
|
@ -9,23 +9,27 @@ interface Props {
|
|||
name?: string;
|
||||
profileName?: string;
|
||||
i18n: Localizer;
|
||||
module?: string;
|
||||
}
|
||||
|
||||
export class ContactName extends React.Component<Props> {
|
||||
public render() {
|
||||
const { phoneNumber, name, profileName, i18n } = this.props;
|
||||
const { phoneNumber, name, profileName, i18n, module } = this.props;
|
||||
const prefix = module ? module : 'module-contact-name';
|
||||
|
||||
const title = name ? name : phoneNumber;
|
||||
const profileElement =
|
||||
profileName && !name ? (
|
||||
<span className="profile-name">
|
||||
~<Emojify text={profileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
const shouldShowProfile = Boolean(profileName && !name);
|
||||
const profileElement = shouldShowProfile ? (
|
||||
<span className={`${prefix}__profile-name`}>
|
||||
~<Emojify text={profileName || ''} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Emojify text={title} i18n={i18n} /> {profileElement}
|
||||
<span className={prefix}>
|
||||
<Emojify text={title} i18n={i18n} />
|
||||
{shouldShowProfile ? ' ' : null}
|
||||
{profileElement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
139
ts/components/conversation/ConversationHeader.md
Normal file
|
@ -0,0 +1,139 @@
|
|||
### Name variations, 1:1 conversation
|
||||
|
||||
Note the five items in gear menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'.
|
||||
|
||||
#### With name and profile, verified
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="red"
|
||||
isVerified={true}
|
||||
avatarPath={util.gifObjectUrl}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0001"
|
||||
id="1"
|
||||
profileName="🔥Flames🔥"
|
||||
onSetDisappearingMessages={seconds =>
|
||||
console.log('onSetDisappearingMessages', seconds)
|
||||
}
|
||||
onDeleteMessages={() => console.log('onDeleteMessages')}
|
||||
onResetSession={() => console.log('onResetSession')}
|
||||
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
|
||||
onShowAllMedia={() => console.log('onShowAllMedia')}
|
||||
onShowGroupMembers={() => console.log('onShowGroupMembers')}
|
||||
onGoBack={() => console.log('onGoBack')}
|
||||
/>
|
||||
```
|
||||
|
||||
#### With name, not verified, no avatar
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="blue"
|
||||
isVerified={false}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0002"
|
||||
id="2"
|
||||
/>
|
||||
```
|
||||
|
||||
#### Profile, no name
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="teal"
|
||||
isVerified={false}
|
||||
phoneNumber="(202) 555-0003"
|
||||
id="3"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
```
|
||||
|
||||
#### No name, no profile, no color
|
||||
|
||||
```jsx
|
||||
<ConversationHeader i18n={util.i18n} phoneNumber="(202) 555-0011" id="11" />
|
||||
```
|
||||
|
||||
### With back button
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
showBackButton={true}
|
||||
color="deep_orange"
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0004"
|
||||
id="4"
|
||||
/>
|
||||
```
|
||||
|
||||
### Disappearing messages set
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
color="indigo"
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0005"
|
||||
id="5"
|
||||
expirationSettingName="10 seconds"
|
||||
timerOptions={[
|
||||
{
|
||||
name: 'off',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: '10 seconds',
|
||||
value: 10,
|
||||
},
|
||||
]}
|
||||
onSetDisappearingMessages={seconds =>
|
||||
console.log('onSetDisappearingMessages', seconds)
|
||||
}
|
||||
onDeleteMessages={() => console.log('onDeleteMessages')}
|
||||
onResetSession={() => console.log('onResetSession')}
|
||||
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
|
||||
onShowAllMedia={() => console.log('onShowAllMedia')}
|
||||
onShowGroupMembers={() => console.log('onShowGroupMembers')}
|
||||
onGoBack={() => console.log('onGoBack')}
|
||||
/>
|
||||
```
|
||||
|
||||
### In a group
|
||||
|
||||
Note that the menu should includes 'Show Members' instead of 'Show Safety Number'
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="green"
|
||||
phoneNumber="(202) 555-0006"
|
||||
id="6"
|
||||
isGroup={true}
|
||||
onSetDisappearingMessages={seconds =>
|
||||
console.log('onSetDisappearingMessages', seconds)
|
||||
}
|
||||
onDeleteMessages={() => console.log('onDeleteMessages')}
|
||||
onResetSession={() => console.log('onResetSession')}
|
||||
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
|
||||
onShowAllMedia={() => console.log('onShowAllMedia')}
|
||||
onShowGroupMembers={() => console.log('onShowGroupMembers')}
|
||||
onGoBack={() => console.log('onGoBack')}
|
||||
/>
|
||||
```
|
||||
|
||||
### In chat with yourself
|
||||
|
||||
Note that the menu should not have a 'Show Safety Number' entry.
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
color="cyan"
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0007"
|
||||
id="7"
|
||||
isMe={true}
|
||||
/>
|
||||
```
|
253
ts/components/conversation/ConversationHeader.tsx
Normal file
|
@ -0,0 +1,253 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Emojify } from './Emojify';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
MenuItem,
|
||||
SubMenu,
|
||||
} from 'react-contextmenu';
|
||||
|
||||
interface TimerOption {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
isVerified: boolean;
|
||||
name?: string;
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
color: string;
|
||||
|
||||
avatarPath?: string;
|
||||
isMe: boolean;
|
||||
isGroup: boolean;
|
||||
expirationSettingName?: string;
|
||||
showBackButton: boolean;
|
||||
timerOptions: Array<TimerOption>;
|
||||
|
||||
onSetDisappearingMessages: (seconds: number) => void;
|
||||
onDeleteMessages: () => void;
|
||||
onResetSession: () => void;
|
||||
|
||||
onShowSafetyNumber: () => void;
|
||||
onShowAllMedia: () => void;
|
||||
onShowGroupMembers: () => void;
|
||||
onGoBack: () => void;
|
||||
}
|
||||
|
||||
function getInitial(name: string): string {
|
||||
return name.trim()[0] || '#';
|
||||
}
|
||||
|
||||
export class ConversationHeader extends React.Component<Props> {
|
||||
public captureMenuTriggerBound: (trigger: any) => void;
|
||||
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
public menuTriggerRef: Trigger | null;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
|
||||
this.showMenuBound = this.showMenu.bind(this);
|
||||
this.menuTriggerRef = null;
|
||||
}
|
||||
|
||||
public captureMenuTrigger(triggerRef: Trigger) {
|
||||
this.menuTriggerRef = triggerRef;
|
||||
}
|
||||
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (this.menuTriggerRef) {
|
||||
this.menuTriggerRef.handleContextClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
public renderBackButton() {
|
||||
const { onGoBack, showBackButton } = this.props;
|
||||
|
||||
if (!showBackButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onGoBack}
|
||||
role="button"
|
||||
className="module-conversation-header__back-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderTitle() {
|
||||
const { name, phoneNumber, i18n, profileName, isVerified } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-conversation-header__title">
|
||||
{name ? <Emojify text={name} i18n={i18n} /> : null}
|
||||
{name && phoneNumber ? ' · ' : null}
|
||||
{phoneNumber ? phoneNumber : null}{' '}
|
||||
{profileName && !name ? (
|
||||
<span className="module-conversation-header__title__profile-name">
|
||||
<Emojify text={profileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null}
|
||||
{isVerified ? ' · ' : null}
|
||||
{isVerified ? (
|
||||
<span>
|
||||
<span className="module-conversation-header__title__verified-icon" />
|
||||
{i18n('verified')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderAvatar() {
|
||||
const {
|
||||
avatarPath,
|
||||
color,
|
||||
i18n,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
} = this.props;
|
||||
|
||||
if (!avatarPath) {
|
||||
const initial = getInitial(name || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-conversation-header___avatar',
|
||||
'module-conversation-header___default-avatar',
|
||||
`module-conversation-header___default-avatar--${color}`
|
||||
)}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = `${name || phoneNumber}${
|
||||
!name && profileName ? ` ~${profileName}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
className="module-conversation-header___avatar"
|
||||
alt={i18n('contactAvatarAlt', [title])}
|
||||
src={avatarPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderExpirationLength() {
|
||||
const { expirationSettingName } = this.props;
|
||||
|
||||
if (!expirationSettingName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-conversation-header__expiration">
|
||||
<div className="module-conversation-header__expiration__clock-icon" />
|
||||
<div className="module-conversation-header__expiration__setting">
|
||||
{expirationSettingName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderGear(triggerId: string) {
|
||||
const { showBackButton } = this.props;
|
||||
|
||||
if (showBackButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
|
||||
<div
|
||||
role="button"
|
||||
onClick={this.showMenuBound}
|
||||
className="module-conversation-header__gear-icon"
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
/* tslint:disable:jsx-no-lambda react-this-binding-issue */
|
||||
public renderMenu(triggerId: string) {
|
||||
const {
|
||||
i18n,
|
||||
isMe,
|
||||
isGroup,
|
||||
onDeleteMessages,
|
||||
onResetSession,
|
||||
onSetDisappearingMessages,
|
||||
onShowAllMedia,
|
||||
onShowGroupMembers,
|
||||
onShowSafetyNumber,
|
||||
timerOptions,
|
||||
} = this.props;
|
||||
|
||||
const title = i18n('disappearingMessages') as any;
|
||||
|
||||
return (
|
||||
<ContextMenu id={triggerId}>
|
||||
<SubMenu title={title}>
|
||||
{(timerOptions || []).map(item => (
|
||||
<MenuItem
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
onSetDisappearingMessages(item.value);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SubMenu>
|
||||
<MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem>
|
||||
{isGroup ? (
|
||||
<MenuItem onClick={onShowGroupMembers}>
|
||||
{i18n('showMembers')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{!isGroup && !isMe ? (
|
||||
<MenuItem onClick={onShowSafetyNumber}>
|
||||
{i18n('showSafetyNumber')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{!isGroup ? (
|
||||
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
/* tslint:enable */
|
||||
|
||||
public render() {
|
||||
const { id } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-conversation-header">
|
||||
{this.renderBackButton()}
|
||||
{this.renderAvatar()}
|
||||
{this.renderTitle()}
|
||||
{this.renderExpirationLength()}
|
||||
{this.renderGear(id)}
|
||||
{this.renderMenu(id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
#### With name and profile, verified
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle
|
||||
i18n={util.i18n}
|
||||
isVerified
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### With name, not verified
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle
|
||||
i18n={util.i18n}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0011"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Profile, no name
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### No name, no profile
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle i18n={util.i18n} phoneNumber="(202) 555-0011" />
|
||||
</div>
|
||||
```
|
|
@ -1,42 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Emojify } from './Emojify';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
isVerified: boolean;
|
||||
name?: string;
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
}
|
||||
|
||||
export class ConversationTitle extends React.Component<Props> {
|
||||
public render() {
|
||||
const { name, phoneNumber, i18n, profileName, isVerified } = this.props;
|
||||
|
||||
return (
|
||||
<span className="conversation-title">
|
||||
{name ? (
|
||||
<span className="conversation-name" dir="auto">
|
||||
<Emojify text={name} i18n={i18n} />
|
||||
</span>
|
||||
) : null}
|
||||
{phoneNumber ? (
|
||||
<span className="conversation-number">{phoneNumber}</span>
|
||||
) : null}{' '}
|
||||
{profileName && !name ? (
|
||||
<span className="profileName">
|
||||
<Emojify text={profileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null}
|
||||
{isVerified ? (
|
||||
<span className="verified">
|
||||
<span className="verified-icon" />
|
||||
{i18n('verified')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,516 +3,533 @@
|
|||
#### Including all data types
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
onClick: () => console.log('onClick'),
|
||||
onSendMessage: () => console.log('onSendMessage'),
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Really long long data
|
||||
#### Really long data
|
||||
|
||||
```
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
<li><Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
contact={contact}/></li>
|
||||
<li><Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
contact={contact}/></li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### In group conversation
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme} type="group">
|
||||
<Message
|
||||
color="green"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
authorName="Mr. Fire"
|
||||
conversationType="group"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
authorName="Mr. Fire"
|
||||
conversationType="group"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### If contact has no signal account
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### With organization name instead of name
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
organization: 'United Somewheres, Inc.',
|
||||
email: [
|
||||
{
|
||||
value: 'someone@somewheres.com',
|
||||
type: 2,
|
||||
},
|
||||
],
|
||||
const contact = {
|
||||
organization: 'United Somewheres, Inc.',
|
||||
email: [
|
||||
{
|
||||
value: 'someone@somewheres.com',
|
||||
type: 2,
|
||||
},
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### No displayName or organization
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
givenName: 'Someone',
|
||||
const contact = {
|
||||
name: {
|
||||
givenName: 'Someone',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-1000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '+12025551000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Default avatar
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: util.CONTACTS[0].id,
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
];
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-1001',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Empty contact
|
||||
|
||||
```jsx
|
||||
const contacts = [{}];
|
||||
const contact = {};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Contact with caption (cannot currently be sent)
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contactWithAccount = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
const contactWithoutAccount = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
|
|
@ -12,8 +12,7 @@ interface Props {
|
|||
isIncoming: boolean;
|
||||
withContentAbove: boolean;
|
||||
withContentBelow: boolean;
|
||||
onSendMessage?: () => void;
|
||||
onClickContact?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export class EmbeddedContact extends React.Component<Props> {
|
||||
|
@ -22,7 +21,7 @@ export class EmbeddedContact extends React.Component<Props> {
|
|||
contact,
|
||||
i18n,
|
||||
isIncoming,
|
||||
onClickContact,
|
||||
onClick,
|
||||
withContentAbove,
|
||||
withContentBelow,
|
||||
} = this.props;
|
||||
|
@ -40,7 +39,7 @@ export class EmbeddedContact extends React.Component<Props> {
|
|||
: null
|
||||
)}
|
||||
role="button"
|
||||
onClick={onClickContact}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderAvatar({ contact, i18n, module })}
|
||||
<div className="module-embedded-contact__text-container">
|
||||
|
|
|
@ -34,10 +34,13 @@ function getImageTag({
|
|||
const title = getTitle(result.value);
|
||||
|
||||
return (
|
||||
// tslint:disable-next-line react-a11y-img-has-alt
|
||||
<img
|
||||
key={key}
|
||||
src={img.path}
|
||||
alt={i18n('emojiAlt', [title || ''])}
|
||||
// We can't use alt or it will be what is captured when a user copies message
|
||||
// contents ("Emoji of ':1'"). Instead, we want the title to be copied (':+1:').
|
||||
aria-label={i18n('emojiAlt', [title || ''])}
|
||||
className={classNames('emoji', sizeClass)}
|
||||
data-codepoints={img.full_idx}
|
||||
title={`:${title}:`}
|
||||
|
|
193
ts/components/conversation/ExpireTimer.md
Normal file
|
@ -0,0 +1,193 @@
|
|||
### Countdown at different rates
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="10 second timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={10 * 1000}
|
||||
expirationTimestamp={Date.now() + 10 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="cyan"
|
||||
text="30 second timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={30 * 1000}
|
||||
expirationTimestamp={Date.now() + 30 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="1 minute timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 55 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="5 minute timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={5 * 60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 60 * 1000}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Timer calculations
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Full timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 60 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Full timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 60 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="55 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 55 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="55 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 55 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="30 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 30 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="30 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 30 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="5 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="5 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Expired timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now()}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Expired timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now()}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Expiration is too far away"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 120 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Expiration is too far away"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 120 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Already expired"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() - 20 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Already expired"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() - 20 * 1000}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>
|
||||
```
|
86
ts/components/conversation/ExpireTimer.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { padStart } from 'lodash';
|
||||
|
||||
interface Props {
|
||||
withImageNoCaption: boolean;
|
||||
expirationLength: number;
|
||||
expirationTimestamp: number;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
export class ExpireTimer extends React.Component<Props> {
|
||||
private interval: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const { expirationLength } = this.props;
|
||||
const increment = getIncrement(expirationLength);
|
||||
const updateFrequency = Math.max(increment, 500);
|
||||
|
||||
const update = () => {
|
||||
this.setState({
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
};
|
||||
this.interval = setInterval(update, updateFrequency);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
withImageNoCaption,
|
||||
} = this.props;
|
||||
|
||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-expire-timer',
|
||||
`module-expire-timer--${bucket}`,
|
||||
`module-expire-timer--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-expire-timer--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIncrement(length: number): number {
|
||||
if (length < 0) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
return Math.ceil(length / 12);
|
||||
}
|
||||
|
||||
function getTimerBucket(expiration: number, length: number): string {
|
||||
const delta = expiration - Date.now();
|
||||
if (delta < 0) {
|
||||
return '00';
|
||||
}
|
||||
if (delta > length) {
|
||||
return '60';
|
||||
}
|
||||
|
||||
const bucket = Math.round(delta / length * 12);
|
||||
|
||||
return padStart(String(bucket * 5), 2, '0');
|
||||
}
|
171
ts/components/conversation/GroupNotification.md
Normal file
|
@ -0,0 +1,171 @@
|
|||
### Three changes, all types
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'add',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mrs. Ice',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
name: 'Ms. Earth',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'name',
|
||||
newName: 'New Group Name',
|
||||
},
|
||||
{
|
||||
type: 'remove',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Joined group
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'add',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mrs. Ice',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
name: 'Ms. Earth',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'add',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Left group
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'remove',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mrs. Ice',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
name: 'Ms. Earth',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'remove',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'remove',
|
||||
isMe: true,
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Title changed
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'name',
|
||||
newName: 'New Group Name',
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Generic group update
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'general',
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
109
ts/components/conversation/GroupNotification.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
import { compact, flatten } from 'lodash';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
interface Contact {
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Change {
|
||||
type: 'add' | 'remove' | 'name' | 'general';
|
||||
isMe: boolean;
|
||||
newName?: string;
|
||||
contacts?: Array<Contact>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
changes: Array<Change>;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class GroupNotification extends React.Component<Props> {
|
||||
public renderChange(change: Change) {
|
||||
const { isMe, contacts, type, newName } = change;
|
||||
const { i18n } = this.props;
|
||||
|
||||
const people = compact(
|
||||
flatten(
|
||||
(contacts || []).map((contact, index) => {
|
||||
const element = (
|
||||
<span
|
||||
key={`external-${contact.phoneNumber}`}
|
||||
className="module-group-notification__contact"
|
||||
>
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
profileName={contact.profileName}
|
||||
name={contact.name}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
return [index > 0 ? ', ' : null, element];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'name':
|
||||
return i18n('titleIsNow', [newName || '']);
|
||||
case 'add':
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={
|
||||
contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'
|
||||
}
|
||||
components={[people]}
|
||||
/>
|
||||
);
|
||||
case 'remove':
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
if (isMe) {
|
||||
return i18n('youLeftTheGroup');
|
||||
}
|
||||
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'}
|
||||
components={[people]}
|
||||
/>
|
||||
);
|
||||
case 'general':
|
||||
return i18n('updatedTheGroup');
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { changes } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-group-notification">
|
||||
{(changes || []).map(change => (
|
||||
<div className="module-group-notification__change">
|
||||
{this.renderChange(change)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,23 +1,28 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import { padStart } from 'lodash';
|
||||
|
||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
||||
import {
|
||||
isImageTypeSupported,
|
||||
isVideoTypeSupported,
|
||||
} from '../../util/GoogleChrome';
|
||||
|
||||
import { MessageBody } from './MessageBody';
|
||||
import { Emojify } from './Emojify';
|
||||
import { ExpireTimer, getIncrement } from './ExpireTimer';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { ContactName } from './ContactName';
|
||||
import { Quote, QuotedAttachment } from './Quote';
|
||||
import { EmbeddedContact } from './EmbeddedContact';
|
||||
|
||||
import { Contact } from '../../types/Contact';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { Color, Localizer } from '../../types/Util';
|
||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
||||
|
||||
import * as MIME from '../../../ts/types/MIME';
|
||||
|
||||
interface Trigger {
|
||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
contentType: MIME.MIMEType;
|
||||
fileName: string;
|
||||
|
@ -26,50 +31,69 @@ interface Attachment {
|
|||
/** For messages not already on disk, this will be a data url */
|
||||
url: string;
|
||||
fileSize?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
screenshot?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIME.MIMEType;
|
||||
};
|
||||
thumbnail?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIME.MIMEType;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
disableMenu?: boolean;
|
||||
text?: string;
|
||||
id?: string;
|
||||
collapseMetadata?: boolean;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
timestamp: number;
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read';
|
||||
contacts?: Array<Contact>;
|
||||
color:
|
||||
| 'gray'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'deep-orange'
|
||||
| 'green'
|
||||
| 'indigo'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'teal';
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
|
||||
// What if changed this over to a single contact like quote, and put the events on it?
|
||||
contact?: Contact & {
|
||||
hasSignalAccount: boolean;
|
||||
onSendMessage?: () => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
i18n: Localizer;
|
||||
authorName?: string;
|
||||
authorProfileName?: string;
|
||||
/** Note: this should be formatted for display */
|
||||
authorPhoneNumber?: string;
|
||||
authorPhoneNumber: string;
|
||||
authorColor: Color;
|
||||
conversationType: 'group' | 'direct';
|
||||
attachment?: Attachment;
|
||||
quote?: {
|
||||
text: string;
|
||||
attachments: Array<QuotedAttachment>;
|
||||
attachment?: QuotedAttachment;
|
||||
isFromMe: boolean;
|
||||
authorName?: string;
|
||||
authorPhoneNumber?: string;
|
||||
authorPhoneNumber: string;
|
||||
authorProfileName?: string;
|
||||
authorName?: string;
|
||||
authorColor: Color;
|
||||
onClick?: () => void;
|
||||
};
|
||||
authorAvatarPath?: string;
|
||||
contactHasSignalAccount: boolean;
|
||||
expirationLength?: number;
|
||||
expirationTimestamp?: number;
|
||||
onClickQuote?: () => void;
|
||||
onSendMessageToContact?: () => void;
|
||||
onClickContact?: () => void;
|
||||
onClickAttachment?: () => void;
|
||||
onReply?: () => void;
|
||||
onRetrySend?: () => void;
|
||||
onDownload?: () => void;
|
||||
onDelete?: () => void;
|
||||
onShowDetail: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
expiring: boolean;
|
||||
expired: boolean;
|
||||
imageBroken: boolean;
|
||||
}
|
||||
|
||||
function isImage(attachment?: Attachment) {
|
||||
|
@ -80,6 +104,10 @@ function isImage(attachment?: Attachment) {
|
|||
);
|
||||
}
|
||||
|
||||
function hasImage(attachment?: Attachment) {
|
||||
return attachment && attachment.url;
|
||||
}
|
||||
|
||||
function isVideo(attachment?: Attachment) {
|
||||
return (
|
||||
attachment &&
|
||||
|
@ -88,24 +116,18 @@ function isVideo(attachment?: Attachment) {
|
|||
);
|
||||
}
|
||||
|
||||
function hasVideoScreenshot(attachment?: Attachment) {
|
||||
return attachment && attachment.screenshot && attachment.screenshot.url;
|
||||
}
|
||||
|
||||
function isAudio(attachment?: Attachment) {
|
||||
return (
|
||||
attachment && attachment.contentType && MIME.isAudio(attachment.contentType)
|
||||
);
|
||||
}
|
||||
|
||||
function getTimerBucket(expiration: number, length: number): string {
|
||||
const delta = expiration - Date.now();
|
||||
if (delta < 0) {
|
||||
return '00';
|
||||
}
|
||||
if (delta > length) {
|
||||
return '60';
|
||||
}
|
||||
|
||||
const increment = Math.round(delta / length * 12);
|
||||
|
||||
return padStart(String(increment * 5), 2, '0');
|
||||
function getInitial(name: string): string {
|
||||
return name.trim()[0] || '#';
|
||||
}
|
||||
|
||||
function getExtension({
|
||||
|
@ -131,58 +153,118 @@ function getExtension({
|
|||
return null;
|
||||
}
|
||||
|
||||
export class Message extends React.Component<Props> {
|
||||
public renderTimer() {
|
||||
const {
|
||||
attachment,
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
text,
|
||||
} = this.props;
|
||||
const MINIMUM_IMG_HEIGHT = 150;
|
||||
const MAXIMUM_IMG_HEIGHT = 300;
|
||||
const EXPIRATION_CHECK_MINIMUM = 2000;
|
||||
const EXPIRED_DELAY = 600;
|
||||
|
||||
if (!expirationLength || !expirationTimestamp) {
|
||||
return null;
|
||||
export class Message extends React.Component<Props, State> {
|
||||
public captureMenuTriggerBound: (trigger: any) => void;
|
||||
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
public handleImageErrorBound: () => void;
|
||||
|
||||
public menuTriggerRef: Trigger | null;
|
||||
public expirationCheckInterval: any;
|
||||
public expiredTimeout: any;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
|
||||
this.showMenuBound = this.showMenu.bind(this);
|
||||
this.handleImageErrorBound = this.handleImageError.bind(this);
|
||||
|
||||
this.menuTriggerRef = null;
|
||||
this.expirationCheckInterval = null;
|
||||
this.expiredTimeout = null;
|
||||
|
||||
this.state = {
|
||||
expiring: false,
|
||||
expired: false,
|
||||
imageBroken: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const { expirationLength } = this.props;
|
||||
if (!expirationLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
const withImageNoCaption = !text && isImage(attachment);
|
||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||
const increment = getIncrement(expirationLength);
|
||||
const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__metadata__timer',
|
||||
`module-message__metadata__timer--${bucket}`,
|
||||
`module-message__metadata__timer--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__timer--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
);
|
||||
this.checkExpired();
|
||||
|
||||
this.expirationCheckInterval = setInterval(() => {
|
||||
this.checkExpired();
|
||||
}, checkFrequency);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.expirationCheckInterval) {
|
||||
clearInterval(this.expirationCheckInterval);
|
||||
}
|
||||
if (this.expiredTimeout) {
|
||||
clearTimeout(this.expiredTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
public checkExpired() {
|
||||
const now = Date.now();
|
||||
const { expirationTimestamp, expirationLength } = this.props;
|
||||
|
||||
if (!expirationTimestamp || !expirationLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (now >= expirationTimestamp) {
|
||||
this.setState({
|
||||
expiring: true,
|
||||
});
|
||||
|
||||
const setExpired = () => {
|
||||
this.setState({
|
||||
expired: true,
|
||||
});
|
||||
};
|
||||
this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
public handleImageError() {
|
||||
// tslint:disable-next-line no-console
|
||||
console.log('Message: Image failed to load; failing over to placeholder');
|
||||
this.setState({
|
||||
imageBroken: true,
|
||||
});
|
||||
}
|
||||
|
||||
public renderMetadata() {
|
||||
const {
|
||||
attachment,
|
||||
collapseMetadata,
|
||||
color,
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
i18n,
|
||||
status,
|
||||
timestamp,
|
||||
text,
|
||||
attachment,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
|
||||
if (collapseMetadata) {
|
||||
return null;
|
||||
}
|
||||
// We're not showing metadata on top of videos since they still have native controls
|
||||
if (!text && isVideo(attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withImageNoCaption = !text && isImage(attachment);
|
||||
const withImageNoCaption = Boolean(
|
||||
!text &&
|
||||
!imageBroken &&
|
||||
((isImage(attachment) && hasImage(attachment)) ||
|
||||
(isVideo(attachment) && hasVideoScreenshot(attachment)))
|
||||
);
|
||||
const showError = status === 'error' && direction === 'outgoing';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -193,33 +275,43 @@ export class Message extends React.Component<Props> {
|
|||
: null
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-message__metadata__date',
|
||||
`module-message__metadata__date--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__date--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
title={moment(timestamp).format('llll')}
|
||||
>
|
||||
{formatRelativeTime(timestamp, { i18n, extended: true })}
|
||||
</span>
|
||||
{this.renderTimer()}
|
||||
{showError ? (
|
||||
<span
|
||||
className={classNames(
|
||||
'module-message__metadata__date',
|
||||
`module-message__metadata__date--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__date--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
{i18n('sendFailed')}
|
||||
</span>
|
||||
) : (
|
||||
<Timestamp
|
||||
i18n={i18n}
|
||||
timestamp={timestamp}
|
||||
direction={direction}
|
||||
withImageNoCaption={withImageNoCaption}
|
||||
module="module-message__metadata__date"
|
||||
/>
|
||||
)}
|
||||
{expirationLength && expirationTimestamp ? (
|
||||
<ExpireTimer
|
||||
direction={direction}
|
||||
expirationLength={expirationLength}
|
||||
expirationTimestamp={expirationTimestamp}
|
||||
withImageNoCaption={withImageNoCaption}
|
||||
/>
|
||||
) : null}
|
||||
<span className="module-message__metadata__spacer" />
|
||||
{direction === 'outgoing' ? (
|
||||
{direction === 'outgoing' && status !== 'error' ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__metadata__status-icon',
|
||||
`module-message__metadata__status-icon-${status}`,
|
||||
status === 'read'
|
||||
? `module-message__metadata__status-icon-${color}`
|
||||
: null,
|
||||
`module-message__metadata__status-icon--${status}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__status-icon--with-image-no-caption'
|
||||
: null,
|
||||
withImageNoCaption && status === 'read'
|
||||
? 'module-message__metadata__status-icon--read-with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
|
@ -231,11 +323,11 @@ export class Message extends React.Component<Props> {
|
|||
public renderAuthor() {
|
||||
const {
|
||||
authorName,
|
||||
authorPhoneNumber,
|
||||
authorProfileName,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
authorPhoneNumber,
|
||||
authorProfileName,
|
||||
} = this.props;
|
||||
|
||||
const title = authorName ? authorName : authorPhoneNumber;
|
||||
|
@ -244,21 +336,20 @@ export class Message extends React.Component<Props> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const profileElement =
|
||||
authorProfileName && !authorName ? (
|
||||
<span className="module-message__author__profile-name">
|
||||
~<Emojify text={authorProfileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="module-message__author">
|
||||
<Emojify text={title} i18n={i18n} /> {profileElement}
|
||||
<ContactName
|
||||
phoneNumber={authorPhoneNumber}
|
||||
name={authorName}
|
||||
profileName={authorProfileName}
|
||||
module="module-message__author"
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
|
||||
public renderAttachment() {
|
||||
const {
|
||||
i18n,
|
||||
|
@ -270,6 +361,7 @@ export class Message extends React.Component<Props> {
|
|||
quote,
|
||||
onClickAttachment,
|
||||
} = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
|
@ -282,9 +374,30 @@ export class Message extends React.Component<Props> {
|
|||
quote || (conversationType === 'group' && direction === 'incoming');
|
||||
|
||||
if (isImage(attachment)) {
|
||||
if (imageBroken || !attachment.url) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__broken-image',
|
||||
`module-message__broken-image--${direction}`
|
||||
)}
|
||||
>
|
||||
{i18n('imageFailedToLoad')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculating height to prevent reflow when image loads
|
||||
const height = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0);
|
||||
|
||||
return (
|
||||
<div className="module-message__attachment-container">
|
||||
<div
|
||||
onClick={onClickAttachment}
|
||||
role="button"
|
||||
className="module-message__attachment-container"
|
||||
>
|
||||
<img
|
||||
onError={this.handleImageErrorBound}
|
||||
className={classNames(
|
||||
'module-message__img-attachment',
|
||||
withCaption
|
||||
|
@ -294,9 +407,9 @@ export class Message extends React.Component<Props> {
|
|||
? 'module-message__img-attachment--with-content-above'
|
||||
: null
|
||||
)}
|
||||
height={Math.min(MAXIMUM_IMG_HEIGHT, height)}
|
||||
src={attachment.url}
|
||||
alt={i18n('imageAttachmentAlt')}
|
||||
onClick={onClickAttachment}
|
||||
/>
|
||||
{!withCaption && !collapseMetadata ? (
|
||||
<div className="module-message__img-overlay" />
|
||||
|
@ -304,21 +417,53 @@ export class Message extends React.Component<Props> {
|
|||
</div>
|
||||
);
|
||||
} else if (isVideo(attachment)) {
|
||||
const { screenshot } = attachment;
|
||||
if (imageBroken || !screenshot || !screenshot.url) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={onClickAttachment}
|
||||
className={classNames(
|
||||
'module-message__broken-video-screenshot',
|
||||
`module-message__broken-video-screenshot--${direction}`
|
||||
)}
|
||||
>
|
||||
{i18n('videoScreenshotFailedToLoad')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculating height to prevent reflow when image loads
|
||||
const height = Math.max(MINIMUM_IMG_HEIGHT, screenshot.height || 0);
|
||||
|
||||
return (
|
||||
<video
|
||||
controls={true}
|
||||
className={classNames(
|
||||
'module-message__img-attachment',
|
||||
withCaption
|
||||
? 'module-message__img-attachment--with-content-below'
|
||||
: null,
|
||||
withContentAbove
|
||||
? 'module-message__img-attachment--with-content-above'
|
||||
: null
|
||||
)}
|
||||
<div
|
||||
onClick={onClickAttachment}
|
||||
role="button"
|
||||
className="module-message__attachment-container"
|
||||
>
|
||||
<source src={attachment.url} />
|
||||
</video>
|
||||
<img
|
||||
onError={this.handleImageErrorBound}
|
||||
className={classNames(
|
||||
'module-message__img-attachment',
|
||||
withCaption
|
||||
? 'module-message__img-attachment--with-content-below'
|
||||
: null,
|
||||
withContentAbove
|
||||
? 'module-message__img-attachment--with-content-above'
|
||||
: null
|
||||
)}
|
||||
alt={i18n('videoAttachmentAlt')}
|
||||
height={Math.min(MAXIMUM_IMG_HEIGHT, height)}
|
||||
src={screenshot.url}
|
||||
/>
|
||||
{!withCaption && !collapseMetadata ? (
|
||||
<div className="module-message__img-overlay" />
|
||||
) : null}
|
||||
<div className="module-message__video-overlay__circle">
|
||||
<div className="module-message__video-overlay__play-icon" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (isAudio(attachment)) {
|
||||
return (
|
||||
|
@ -384,38 +529,26 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderQuote() {
|
||||
const {
|
||||
color,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
onClickQuote,
|
||||
quote,
|
||||
} = this.props;
|
||||
const { conversationType, direction, i18n, quote } = this.props;
|
||||
|
||||
if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorTitle = quote.authorName
|
||||
? quote.authorName
|
||||
: quote.authorPhoneNumber;
|
||||
const authorProfileName = !quote.authorName
|
||||
? quote.authorProfileName
|
||||
: undefined;
|
||||
const withContentAbove =
|
||||
conversationType === 'group' && direction === 'incoming';
|
||||
|
||||
return (
|
||||
<Quote
|
||||
i18n={i18n}
|
||||
onClick={onClickQuote}
|
||||
color={color}
|
||||
onClick={quote.onClick}
|
||||
text={quote.text}
|
||||
attachments={quote.attachments}
|
||||
attachment={quote.attachment}
|
||||
isIncoming={direction === 'incoming'}
|
||||
authorTitle={authorTitle || ''}
|
||||
authorProfileName={authorProfileName}
|
||||
authorPhoneNumber={quote.authorPhoneNumber}
|
||||
authorProfileName={quote.authorProfileName}
|
||||
authorName={quote.authorName}
|
||||
authorColor={quote.authorColor}
|
||||
isFromMe={quote.isFromMe}
|
||||
withContentAbove={withContentAbove}
|
||||
/>
|
||||
|
@ -425,18 +558,13 @@ export class Message extends React.Component<Props> {
|
|||
public renderEmbeddedContact() {
|
||||
const {
|
||||
collapseMetadata,
|
||||
contactHasSignalAccount,
|
||||
contacts,
|
||||
contact,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
onClickContact,
|
||||
onSendMessageToContact,
|
||||
text,
|
||||
} = this.props;
|
||||
const first = contacts && contacts[0];
|
||||
|
||||
if (!first) {
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -447,12 +575,11 @@ export class Message extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<EmbeddedContact
|
||||
contact={first}
|
||||
hasSignalAccount={contactHasSignalAccount}
|
||||
contact={contact}
|
||||
hasSignalAccount={contact.hasSignalAccount}
|
||||
isIncoming={direction === 'incoming'}
|
||||
i18n={i18n}
|
||||
onSendMessage={onSendMessageToContact}
|
||||
onClickContact={onClickContact}
|
||||
onClick={contact.onClick}
|
||||
withContentAbove={withContentAbove}
|
||||
withContentBelow={withContentBelow}
|
||||
/>
|
||||
|
@ -460,22 +587,15 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderSendMessageButton() {
|
||||
const {
|
||||
contactHasSignalAccount,
|
||||
contacts,
|
||||
i18n,
|
||||
onSendMessageToContact,
|
||||
} = this.props;
|
||||
const first = contacts && contacts[0];
|
||||
|
||||
if (!first || !contactHasSignalAccount) {
|
||||
const { contact, i18n } = this.props;
|
||||
if (!contact || !contact.hasSignalAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={onSendMessageToContact}
|
||||
onClick={contact.onSendMessage}
|
||||
className="module-message__send-message-button"
|
||||
>
|
||||
{i18n('sendMessageToContact')}
|
||||
|
@ -489,8 +609,8 @@ export class Message extends React.Component<Props> {
|
|||
authorPhoneNumber,
|
||||
authorProfileName,
|
||||
authorAvatarPath,
|
||||
authorColor,
|
||||
collapseMetadata,
|
||||
color,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
|
@ -509,14 +629,18 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
if (!authorAvatarPath) {
|
||||
const label = authorName ? getInitial(authorName) : '#';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__author-default-avatar',
|
||||
`module-message__author-default-avatar--${color}`
|
||||
`module-message__author-default-avatar--${authorColor}`
|
||||
)}
|
||||
>
|
||||
<div className="module-message__author-default-avatar__label">#</div>
|
||||
<div className="module-message__author-default-avatar__label">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -529,9 +653,14 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderText() {
|
||||
const { text, i18n, direction } = this.props;
|
||||
const { text, i18n, direction, status } = this.props;
|
||||
|
||||
if (!text) {
|
||||
const contents =
|
||||
direction === 'incoming' && status === 'error'
|
||||
? i18n('incomingError')
|
||||
: text;
|
||||
|
||||
if (!contents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -539,38 +668,162 @@ export class Message extends React.Component<Props> {
|
|||
<div
|
||||
className={classNames(
|
||||
'module-message__text',
|
||||
`module-message__text--${direction}`
|
||||
`module-message__text--${direction}`,
|
||||
status === 'error' && direction === 'incoming'
|
||||
? 'module-message__text--error'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<MessageBody text={text || ''} i18n={i18n} />
|
||||
<MessageBody text={contents || ''} i18n={i18n} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderError(isCorrectSide: boolean) {
|
||||
const { status, direction } = this.props;
|
||||
|
||||
if (!isCorrectSide || status !== 'error') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-message__error-container">
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__error',
|
||||
`module-message__error--${direction}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public captureMenuTrigger(triggerRef: Trigger) {
|
||||
this.menuTriggerRef = triggerRef;
|
||||
}
|
||||
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (this.menuTriggerRef) {
|
||||
this.menuTriggerRef.handleContextClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
public renderMenu(isCorrectSide: boolean, triggerId: string) {
|
||||
const {
|
||||
attachment,
|
||||
direction,
|
||||
disableMenu,
|
||||
onDownload,
|
||||
onReply,
|
||||
} = this.props;
|
||||
|
||||
if (!isCorrectSide || disableMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadButton = attachment ? (
|
||||
<div
|
||||
onClick={onDownload}
|
||||
role="button"
|
||||
className={classNames(
|
||||
'module-message__buttons__download',
|
||||
`module-message__buttons__download--${direction}`
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const replyButton = (
|
||||
<div
|
||||
onClick={onReply}
|
||||
role="button"
|
||||
className={classNames(
|
||||
'module-message__buttons__reply',
|
||||
`module-message__buttons__download--${direction}`
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = (
|
||||
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
|
||||
<div
|
||||
role="button"
|
||||
onClick={this.showMenuBound}
|
||||
className={classNames(
|
||||
'module-message__buttons__menu',
|
||||
`module-message__buttons__download--${direction}`
|
||||
)}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
|
||||
const first = direction === 'incoming' ? downloadButton : menuButton;
|
||||
const last = direction === 'incoming' ? menuButton : downloadButton;
|
||||
|
||||
return (
|
||||
<div className="module-message__buttons">
|
||||
{first}
|
||||
{replyButton}
|
||||
{last}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContextMenu(triggerId: string) {
|
||||
const {
|
||||
direction,
|
||||
status,
|
||||
onDelete,
|
||||
onRetrySend,
|
||||
onShowDetail,
|
||||
i18n,
|
||||
} = this.props;
|
||||
|
||||
const showRetry = status === 'error' && direction === 'outgoing';
|
||||
|
||||
return (
|
||||
<ContextMenu id={triggerId}>
|
||||
<MenuItem onClick={onShowDetail}>{i18n('moreInfo')}</MenuItem>
|
||||
{showRetry ? (
|
||||
<MenuItem onClick={onRetrySend}>{i18n('retrySend')}</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={onDelete}>{i18n('deleteMessage')}</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
attachment,
|
||||
color,
|
||||
conversationType,
|
||||
authorPhoneNumber,
|
||||
authorColor,
|
||||
direction,
|
||||
id,
|
||||
quote,
|
||||
text,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
const { expired, expiring } = this.state;
|
||||
|
||||
const imageAndNothingElse =
|
||||
!text && isImage(attachment) && conversationType !== 'group' && !quote;
|
||||
// This id is what connects our triple-dot click with our associated pop-up menu.
|
||||
// It needs to be unique.
|
||||
const triggerId = String(id || `${authorPhoneNumber}-${timestamp}`);
|
||||
|
||||
if (expired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message',
|
||||
`module-message--${direction}`,
|
||||
expiring ? 'module-message--expired' : null
|
||||
)}
|
||||
>
|
||||
{this.renderError(direction === 'incoming')}
|
||||
{this.renderMenu(direction === 'outgoing', triggerId)}
|
||||
<div
|
||||
id={id}
|
||||
className={classNames(
|
||||
'module-message',
|
||||
`module-message--${direction}`,
|
||||
imageAndNothingElse ? 'module-message--with-image-only' : null,
|
||||
'module-message__container',
|
||||
`module-message__container--${direction}`,
|
||||
direction === 'incoming'
|
||||
? `module-message--incoming-${color}`
|
||||
? `module-message__container--incoming-${authorColor}`
|
||||
: null
|
||||
)}
|
||||
>
|
||||
|
@ -583,7 +836,10 @@ export class Message extends React.Component<Props> {
|
|||
{this.renderSendMessageButton()}
|
||||
{this.renderAvatar()}
|
||||
</div>
|
||||
</li>
|
||||
{this.renderError(direction === 'outgoing')}
|
||||
{this.renderMenu(direction === 'incoming', triggerId)}
|
||||
{this.renderContextMenu(triggerId)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
128
ts/components/conversation/MessageDetail.md
Normal file
|
@ -0,0 +1,128 @@
|
|||
### Incoming message
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'incoming',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
sentAt={Date.now() - 2 * 60 * 1000}
|
||||
receivedAt={Date.now() - 10 * 1000}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
### Message to group, multiple contacts
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'outgoing',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
status: 'read',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
sentAt={Date.now()}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mr. Fire',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
status: 'sending',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
avatarPath: util.pngObjectUrl,
|
||||
status: 'delivered',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1003',
|
||||
color: 'teal',
|
||||
status: 'read',
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
### 1:1 conversation, just one recipient
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'outgoing',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
status: 'sending',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
status: 'sending',
|
||||
},
|
||||
]}
|
||||
sentAt={Date.now()}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
### Errors for some users, including on OutgoingKeyError
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'outgoing',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
status: 'error',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
status: 'error',
|
||||
errors: [new Error('Something went wrong'), new Error('Bad things')],
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
avatarPath: util.pngObjectUrl,
|
||||
status: 'error',
|
||||
isOutgoingKeyError: true,
|
||||
errors: [new Error(util.i18n('newIdentity'))],
|
||||
onShowSafetyNumber: () => console.log('onShowSafetyNumber'),
|
||||
onSendAnyway: () => console.log('onSendAnyway'),
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1003',
|
||||
color: 'teal',
|
||||
status: 'read',
|
||||
},
|
||||
]}
|
||||
sentAt={Date.now()}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
209
ts/components/conversation/MessageDetail.tsx
Normal file
|
@ -0,0 +1,209 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Message, Props as MessageProps } from './Message';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Contact {
|
||||
status: string;
|
||||
phoneNumber: string;
|
||||
name?: string;
|
||||
profileName?: string;
|
||||
avatarPath?: string;
|
||||
color: string;
|
||||
isOutgoingKeyError: boolean;
|
||||
|
||||
errors?: Array<Error>;
|
||||
onSendAnyway: () => void;
|
||||
onShowSafetyNumber: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sentAt: number;
|
||||
receivedAt: number;
|
||||
|
||||
message: MessageProps;
|
||||
errors: Array<Error>;
|
||||
contacts: Array<Contact>;
|
||||
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
function getInitial(name: string): string {
|
||||
return name.trim()[0] || '#';
|
||||
}
|
||||
|
||||
export class MessageDetail extends React.Component<Props> {
|
||||
public renderAvatar(contact: Contact) {
|
||||
const { i18n } = this.props;
|
||||
const { avatarPath, color, phoneNumber, name, profileName } = contact;
|
||||
|
||||
if (!avatarPath) {
|
||||
const initial = getInitial(name || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message-detail__contact__avatar',
|
||||
'module-message-detail__contact__default-avatar',
|
||||
`module-message-detail__contact__default-avatar--${color}`
|
||||
)}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = `${name || phoneNumber}${
|
||||
!name && profileName ? ` ~${profileName}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
className="module-message-detail__contact__avatar"
|
||||
alt={i18n('contactAvatarAlt', [title])}
|
||||
src={avatarPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderDeleteButton() {
|
||||
const { i18n, message } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-message-detail__delete-button-container">
|
||||
<button
|
||||
onClick={message.onDelete}
|
||||
className="module-message-detail__delete-button"
|
||||
>
|
||||
{i18n('deleteThisMessage')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContact(contact: Contact) {
|
||||
const { i18n } = this.props;
|
||||
const errors = contact.errors || [];
|
||||
|
||||
const errorComponent = contact.isOutgoingKeyError ? (
|
||||
<div className="module-message-detail__contact__error-buttons">
|
||||
<button
|
||||
className="module-message-detail__contact__show-safety-number"
|
||||
onClick={contact.onShowSafetyNumber}
|
||||
>
|
||||
{i18n('showSafetyNumber')}
|
||||
</button>
|
||||
<button
|
||||
className="module-message-detail__contact__send-anyway"
|
||||
onClick={contact.onSendAnyway}
|
||||
>
|
||||
{i18n('sendAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
const statusComponent = !contact.isOutgoingKeyError ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message-detail__contact__status-icon',
|
||||
`module-message-detail__contact__status-icon--${contact.status}`
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div key={contact.phoneNumber} className="module-message-detail__contact">
|
||||
{this.renderAvatar(contact)}
|
||||
<div className="module-message-detail__contact__text">
|
||||
<div className="module-message-detail__contact__name">
|
||||
<ContactName
|
||||
phoneNumber={contact.phoneNumber}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
{errors.map((error, index) => (
|
||||
<div key={index} className="module-message-detail__contact__error">
|
||||
{error.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{errorComponent}
|
||||
{statusComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContacts() {
|
||||
const { contacts } = this.props;
|
||||
|
||||
if (!contacts || !contacts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-message-detail__contact-container">
|
||||
{contacts.map(contact => this.renderContact(contact))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { errors, message, receivedAt, sentAt, i18n } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-message-detail">
|
||||
<div className="module-message-detail__message-container">
|
||||
<Message i18n={i18n} {...message} />
|
||||
</div>
|
||||
<table className="module-message-detail__info">
|
||||
<tbody>
|
||||
{(errors || []).map(error => (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('error')}
|
||||
</td>
|
||||
<td>
|
||||
{' '}
|
||||
<span className="error-message">{error.message}</span>{' '}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="module-message-detail__label">{i18n('sent')}</td>
|
||||
<td>
|
||||
{moment(sentAt).format('LLLL')}{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({sentAt})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{receivedAt ? (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('received')}
|
||||
</td>
|
||||
<td>
|
||||
{moment(receivedAt).format('LLLL')}{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({receivedAt})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{message.direction === 'incoming' ? i18n('from') : i18n('to')}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{this.renderContacts()}
|
||||
{this.renderDeleteButton()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,151 +0,0 @@
|
|||
### Timer change
|
||||
|
||||
```jsx
|
||||
const fromOther = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: '+12025550003',
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
source: '+12025550003',
|
||||
},
|
||||
});
|
||||
const fromUpdate = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: util.ourNumber,
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
fromSync: true,
|
||||
source: util.ourNumber,
|
||||
},
|
||||
});
|
||||
const fromMe = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: util.ourNumber,
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
source: util.ourNumber,
|
||||
},
|
||||
});
|
||||
const View = Whisper.ExpirationTimerUpdateView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: fromOther }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: fromUpdate }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: fromMe }} />
|
||||
<Notification type="timerUpdate" onClick={() => console.log('onClick')} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Safety number change
|
||||
|
||||
```js
|
||||
const incoming = new Whisper.Message({
|
||||
type: 'keychange',
|
||||
sent_at: Date.now() - 200000,
|
||||
key_changed: '+12025550003',
|
||||
});
|
||||
const View = Whisper.KeyChangeView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: incoming }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Marking as verified
|
||||
|
||||
```js
|
||||
const fromPrimary = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
verified: true,
|
||||
});
|
||||
const local = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
local: true,
|
||||
verified: true,
|
||||
});
|
||||
|
||||
const View = Whisper.VerifiedChangeView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: local }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Marking as not verified
|
||||
|
||||
```js
|
||||
const fromPrimary = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
});
|
||||
const local = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
local: true,
|
||||
});
|
||||
|
||||
const View = Whisper.VerifiedChangeView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: local }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Group update
|
||||
|
||||
```js
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
group_update: {
|
||||
joined: ['+12025550007', '+12025550008', '+12025550009'],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(
|
||||
Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
})
|
||||
);
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: incoming }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### End session
|
||||
|
||||
```js
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
|
||||
});
|
||||
const incoming = new Whisper.Message(
|
||||
Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
})
|
||||
);
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: incoming }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
|
@ -1,32 +0,0 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export class Notification extends React.Component<Props> {
|
||||
public renderContents() {
|
||||
const { type } = this.props;
|
||||
|
||||
return <span>Notification of type {type}</span>;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { onClick } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'module-notification',
|
||||
onClick ? 'module-notification--with-click-handler' : null
|
||||
)}
|
||||
>
|
||||
{this.renderContents()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,15 +6,16 @@ import classNames from 'classnames';
|
|||
import * as MIME from '../../../ts/types/MIME';
|
||||
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
|
||||
|
||||
import { Emojify } from './Emojify';
|
||||
import { MessageBody } from './MessageBody';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { Color, Localizer } from '../../types/Util';
|
||||
import { ContactName } from './ContactName';
|
||||
|
||||
interface Props {
|
||||
attachments: Array<QuotedAttachment>;
|
||||
color: string;
|
||||
attachment?: QuotedAttachment;
|
||||
authorPhoneNumber: string;
|
||||
authorProfileName?: string;
|
||||
authorTitle: string;
|
||||
authorName?: string;
|
||||
authorColor: Color;
|
||||
i18n: Localizer;
|
||||
isFromMe: boolean;
|
||||
isIncoming: boolean;
|
||||
|
@ -43,7 +44,7 @@ function validateQuote(quote: Props): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (quote.attachments && quote.attachments.length > 0) {
|
||||
if (quote.attachment) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -124,14 +125,13 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderGenericFile() {
|
||||
const { attachments } = this.props;
|
||||
const { attachment } = this.props;
|
||||
|
||||
if (!attachments || !attachments.length) {
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { fileName, contentType } = first;
|
||||
const { fileName, contentType } = attachment;
|
||||
const isGenericFile =
|
||||
!GoogleChrome.isVideoTypeSupported(contentType) &&
|
||||
!GoogleChrome.isImageTypeSupported(contentType) &&
|
||||
|
@ -150,13 +150,12 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderIconContainer() {
|
||||
const { attachments, i18n } = this.props;
|
||||
if (!attachments || attachments.length === 0) {
|
||||
const { attachment, i18n } = this.props;
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { contentType, thumbnail } = first;
|
||||
const { contentType, thumbnail } = attachment;
|
||||
const objectUrl = getObjectUrl(thumbnail);
|
||||
|
||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
|
@ -177,7 +176,7 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderText() {
|
||||
const { i18n, text, attachments } = this.props;
|
||||
const { i18n, text, attachment } = this.props;
|
||||
|
||||
if (text) {
|
||||
return (
|
||||
|
@ -187,12 +186,11 @@ export class Quote extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { contentType, isVoiceMessage } = first;
|
||||
const { contentType, isVoiceMessage } = attachment;
|
||||
|
||||
const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage });
|
||||
if (typeLabel) {
|
||||
|
@ -231,29 +229,44 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderAuthor() {
|
||||
const { authorProfileName, authorTitle, i18n, isFromMe } = this.props;
|
||||
|
||||
const authorProfileElement = authorProfileName ? (
|
||||
<span className="module-quote__primary__profile-name">
|
||||
~<Emojify text={authorProfileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
const {
|
||||
authorProfileName,
|
||||
authorPhoneNumber,
|
||||
authorName,
|
||||
authorColor,
|
||||
i18n,
|
||||
isFromMe,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-quote__primary__author">
|
||||
<div
|
||||
className={classNames(
|
||||
'module-quote__primary__author',
|
||||
!isFromMe ? `module-quote__primary__author--${authorColor}` : null
|
||||
)}
|
||||
>
|
||||
{isFromMe ? (
|
||||
i18n('you')
|
||||
) : (
|
||||
<span>
|
||||
<Emojify text={authorTitle} i18n={i18n} /> {authorProfileElement}
|
||||
</span>
|
||||
<ContactName
|
||||
phoneNumber={authorPhoneNumber}
|
||||
name={authorName}
|
||||
profileName={authorProfileName}
|
||||
i18n={i18n}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { color, isIncoming, onClick, withContentAbove } = this.props;
|
||||
const {
|
||||
authorColor,
|
||||
isFromMe,
|
||||
isIncoming,
|
||||
onClick,
|
||||
withContentAbove,
|
||||
} = this.props;
|
||||
|
||||
if (!validateQuote(this.props)) {
|
||||
return null;
|
||||
|
@ -266,7 +279,10 @@ export class Quote extends React.Component<Props> {
|
|||
className={classNames(
|
||||
'module-quote',
|
||||
isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing',
|
||||
!isIncoming ? `module-quote--outgoing-${color}` : null,
|
||||
!isIncoming && !isFromMe
|
||||
? `module-quote--outgoing-${authorColor}`
|
||||
: null,
|
||||
!isIncoming && isFromMe ? 'module-quote--outgoing-you' : null,
|
||||
!onClick ? 'module-quote--no-click' : null,
|
||||
withContentAbove ? 'module-quote--with-content-above' : null
|
||||
)}
|
||||
|
|
7
ts/components/conversation/ResetSessionNotification.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
### End session
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<ResetSessionNotification i18n={util.i18n} />
|
||||
</util.ConversationContext>
|
||||
```
|
19
ts/components/conversation/ResetSessionNotification.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class ResetSessionNotification extends React.Component<Props> {
|
||||
public render() {
|
||||
const { i18n } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-reset-session-notification">
|
||||
{i18n('sessionEnded')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
25
ts/components/conversation/SafetyNumberNotification.md
Normal file
|
@ -0,0 +1,25 @@
|
|||
### In group conversation
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<SafetyNumberNotification
|
||||
i18n={util.i18n}
|
||||
isGroup={true}
|
||||
contact={{ phoneNumber: '(202) 500-1000', profileName: 'Mr. Fire' }}
|
||||
onVerify={() => console.log('onVerify')}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### In one-on-one conversation
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<SafetyNumberNotification
|
||||
i18n={util.i18n}
|
||||
isGroup={false}
|
||||
contact={{ phoneNumber: '(202) 500-1000', profileName: 'Mr. Fire' }}
|
||||
onVerify={() => console.log('onVerify')}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
58
ts/components/conversation/SafetyNumberNotification.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Contact {
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isGroup: boolean;
|
||||
contact: Contact;
|
||||
i18n: Localizer;
|
||||
onVerify: () => void;
|
||||
}
|
||||
|
||||
export class SafetyNumberNotification extends React.Component<Props> {
|
||||
public render() {
|
||||
const { contact, isGroup, i18n, onVerify } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-safety-number-notification">
|
||||
<div className="module-safety-number-notification__icon" />
|
||||
<div className="module-safety-number-notification__text">
|
||||
<Intl
|
||||
id={isGroup ? 'safetyNumberChangedGroup' : 'safetyNumberChanged'}
|
||||
components={[
|
||||
<span
|
||||
key="external-1"
|
||||
className="module-safety-number-notification__contact"
|
||||
>
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
module="module-verification-notification__contact"
|
||||
/>
|
||||
</span>,
|
||||
]}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
onClick={onVerify}
|
||||
className="module-verification-notification__button"
|
||||
>
|
||||
{i18n('verifyNewNumber')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
39
ts/components/conversation/TimerNotification.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
### From other
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<TimerNotification
|
||||
type="fromOther"
|
||||
phoneNumber="(202) 555-1000"
|
||||
profileName="Mr. Fire"
|
||||
timespan="1 hour"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### You changed
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<TimerNotification
|
||||
type="fromMe"
|
||||
phoneNumber="(202) 555-1000"
|
||||
timespan="1 hour"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Changed via sync
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<TimerNotification
|
||||
type="fromSync"
|
||||
phoneNumber="(202) 555-1000"
|
||||
timespan="1 hour"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
67
ts/components/conversation/TimerNotification.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
interface Props {
|
||||
type: 'fromOther' | 'fromMe' | 'fromSync';
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
timespan: string;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class TimerNotification extends React.Component<Props> {
|
||||
public renderContents() {
|
||||
const { i18n, name, phoneNumber, profileName, timespan, type } = this.props;
|
||||
|
||||
switch (type) {
|
||||
case 'fromOther':
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="theyChangedTheTimer"
|
||||
components={[
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
key="external-1"
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
name={name}
|
||||
/>,
|
||||
timespan,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case 'fromMe':
|
||||
return i18n('youChangedTheTimer', [timespan]);
|
||||
case 'fromSync':
|
||||
return i18n('timerSetOnSync', [timespan]);
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { timespan } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-timer-notification">
|
||||
<div className="module-timer-notification__icon-container">
|
||||
<div className="module-timer-notification__icon" />
|
||||
<div className="module-timer-notification__icon-label">
|
||||
{timespan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-timer-notification__message">
|
||||
{this.renderContents()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
167
ts/components/conversation/Timestamp.md
Normal file
|
@ -0,0 +1,167 @@
|
|||
### All major transitions
|
||||
|
||||
```jsx
|
||||
function get1201() {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 1, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
function getYesterday1159() {
|
||||
return get1201() - 2 * 60 * 1000;
|
||||
}
|
||||
function getJanuary1201() {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getFullYear(), 0, 1, 0, 1);
|
||||
return d.getTime();
|
||||
}
|
||||
function getDecember1159() {
|
||||
return getJanuary1201() - 2 * 60 * 1000;
|
||||
}
|
||||
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 500}
|
||||
text="500ms ago - all below 1 minute are 'now'"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 5 * 1000}
|
||||
text="Five seconds ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 30 * 1000}
|
||||
text="30 seconds ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 60 * 1000}
|
||||
text="One minute ago - in minutes"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 30 * 60 * 1000}
|
||||
text="30 minutes ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 45 * 60 * 1000}
|
||||
text="45 minutes ago (used to round up to 1 hour with moment)"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 60 * 60 * 1000}
|
||||
text="One hour ago - in hours"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={get1201()}
|
||||
text="12:01am today"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={getYesterday1159()}
|
||||
text="11:59pm yesterday - adds day name"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 24 * 60 * 60 * 1000}
|
||||
text="24 hours ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 2 * 24 * 60 * 60 * 1000}
|
||||
text="Two days ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 7 * 24 * 60 * 60 * 1000}
|
||||
text="Seven days ago - adds month"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 30 * 24 * 60 * 60 * 1000}
|
||||
text="Thirty days ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={getJanuary1201()}
|
||||
text="January 1st at 12:01am"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={getDecember1159()}
|
||||
text="December 31st at 11:59pm - adds year"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 366 * 24 * 60 * 60 * 1000}
|
||||
text="One year ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
66
ts/components/conversation/Timestamp.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
|
||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
timestamp: number;
|
||||
withImageNoCaption: boolean;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
module?: string;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
const UPDATE_FREQUENCY = 60 * 1000;
|
||||
|
||||
export class Timestamp extends React.Component<Props> {
|
||||
private interval: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const update = () => {
|
||||
this.setState({
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
};
|
||||
this.interval = setInterval(update, UPDATE_FREQUENCY);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
direction,
|
||||
i18n,
|
||||
module,
|
||||
timestamp,
|
||||
withImageNoCaption,
|
||||
} = this.props;
|
||||
const moduleName = module || 'module-timestamp';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
moduleName,
|
||||
`${moduleName}--${direction}`,
|
||||
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null
|
||||
)}
|
||||
title={moment(timestamp).format('llll')}
|
||||
>
|
||||
{formatRelativeTime(timestamp, { i18n, extended: true })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
49
ts/components/conversation/VerificationNotification.md
Normal file
|
@ -0,0 +1,49 @@
|
|||
### Marking as verified
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<VerificationNotification
|
||||
type="markVerified"
|
||||
isLocal={true}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<VerificationNotification
|
||||
type="markVerified"
|
||||
isLocal={false}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Marking as not verified
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<VerificationNotification
|
||||
type="markNotVerified"
|
||||
isLocal={true}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<VerificationNotification
|
||||
type="markNotVerified"
|
||||
isLocal={false}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
75
ts/components/conversation/VerificationNotification.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
interface Contact {
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: 'markVerified' | 'markNotVerified';
|
||||
isLocal: boolean;
|
||||
contact: Contact;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class VerificationNotification extends React.Component<Props> {
|
||||
public getStringId() {
|
||||
const { isLocal, type } = this.props;
|
||||
|
||||
switch (type) {
|
||||
case 'markVerified':
|
||||
return isLocal
|
||||
? 'youMarkedAsVerified'
|
||||
: 'youMarkedAsVerifiedOtherDevice';
|
||||
case 'markNotVerified':
|
||||
return isLocal
|
||||
? 'youMarkedAsNotVerified'
|
||||
: 'youMarkedAsNotVerifiedOtherDevice';
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public renderContents() {
|
||||
const { contact, i18n } = this.props;
|
||||
const id = this.getStringId();
|
||||
|
||||
return (
|
||||
<Intl
|
||||
id={id}
|
||||
components={[
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
key="external-1"
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
module="module-verification-notification__contact"
|
||||
/>,
|
||||
]}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { type } = this.props;
|
||||
const suffix =
|
||||
type === 'markVerified' ? 'mark-verified' : 'mark-not-verified';
|
||||
|
||||
return (
|
||||
<div className="module-verification-notification">
|
||||
<div className={`module-verification-notification__icon--${suffix}`} />
|
||||
{this.renderContents()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
```jsx
|
||||
const messages = [
|
||||
{
|
||||
id: '1',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.json',
|
||||
|
@ -10,6 +11,7 @@ const messages = [
|
|||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'bar.txt',
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 300,
|
||||
height: 200,
|
||||
}}
|
||||
>
|
||||
<EmptyState label="You have no attachments with media" />
|
||||
<EmptyState label={util.i18n('mediaEmptyState')} />
|
||||
</div>
|
||||
```
|
||||
|
||||
|
@ -24,6 +24,6 @@
|
|||
height: 500,
|
||||
}}
|
||||
>
|
||||
<EmptyState label="You have no documents with media" />
|
||||
<EmptyState label={util.i18n('documentsEmptyState')} />
|
||||
</div>
|
||||
```
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
### Media gallery with media and documents
|
||||
|
||||
```jsx
|
||||
const _ = util._;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MONTH_MS = 30 * DAY_MS;
|
||||
const YEAR_MS = 12 * MONTH_MS;
|
||||
|
@ -81,7 +82,14 @@ const messages = _.sortBy(
|
|||
```jsx
|
||||
const messages = [
|
||||
{
|
||||
attachments: [{ fileName: 'foo.jpg', contentType: 'application/json' }],
|
||||
id: '1',
|
||||
objectURL: 'https://placekitten.com/76/67',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.jpg',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
<MediaGallery i18n={util.i18n} media={messages} documents={messages} />;
|
||||
|
|
30
ts/components/conversation/media-gallery/MediaGridItem.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
## With image
|
||||
|
||||
```jsx
|
||||
const message = {
|
||||
id: '1',
|
||||
objectURL: 'https://placekitten.com/76/67',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.jpg',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
],
|
||||
};
|
||||
<MediaGridItem i18n={util.i18n} message={message} />;
|
||||
```
|
||||
|
||||
## Without image
|
||||
|
||||
```jsx
|
||||
const message = {
|
||||
id: '1',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.jpg',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
],
|
||||
};
|
||||
<MediaGridItem i18n={util.i18n} message={message} />;
|
||||
```
|
|
@ -1,17 +0,0 @@
|
|||
Rendering a real `Whisper.MessageView` using `<util.ConversationContext />` and
|
||||
`<util.BackboneWrapper />`.
|
||||
|
||||
```jsx
|
||||
const model = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'text',
|
||||
sent_at: Date.now() - 5000,
|
||||
});
|
||||
const View = Whisper.MessageView;
|
||||
const options = {
|
||||
model,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={options} />
|
||||
</util.ConversationContext>;
|
||||
```
|
|
@ -1,68 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
/** The View class, which will be instantiated then treated like a Backbone View */
|
||||
readonly View: BackboneViewConstructor;
|
||||
/** Options to be passed along to the view when constructed */
|
||||
readonly options: object;
|
||||
}
|
||||
|
||||
interface BackboneView {
|
||||
remove: () => void;
|
||||
render: () => void;
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
||||
interface BackboneViewConstructor {
|
||||
new (options: object): BackboneView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows Backbone Views to be rendered inside of React (primarily for the Style Guide)
|
||||
* while we slowly replace the internals of a given Backbone view with React.
|
||||
*/
|
||||
export class BackboneWrapper extends React.Component<Props> {
|
||||
protected el: HTMLElement | null = null;
|
||||
protected view: BackboneView | null = null;
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.teardown();
|
||||
}
|
||||
|
||||
public shouldComponentUpdate() {
|
||||
// we're handling all updates manually
|
||||
return false;
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div ref={this.setEl} />;
|
||||
}
|
||||
|
||||
protected setEl = (element: HTMLDivElement | null) => {
|
||||
this.el = element;
|
||||
this.setup();
|
||||
};
|
||||
|
||||
protected setup = () => {
|
||||
const { View, options } = this.props;
|
||||
|
||||
if (!this.el) {
|
||||
return;
|
||||
}
|
||||
this.view = new View(options);
|
||||
this.view.render();
|
||||
|
||||
// It's important to let the view create its own root DOM element. This ensures that
|
||||
// its tagName property actually takes effect.
|
||||
this.el.appendChild(this.view.el);
|
||||
};
|
||||
|
||||
protected teardown() {
|
||||
if (!this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.view.remove();
|
||||
this.view = null;
|
||||
}
|
||||
}
|
127
ts/selectors/message.ts
Normal file
|
@ -0,0 +1,127 @@
|
|||
export function messageSelector({ model, view }: { model: any; view: any }) {
|
||||
// tslint:disable-next-line
|
||||
console.log({ model, view });
|
||||
|
||||
return null;
|
||||
// const avatar = this.model.getAvatar();
|
||||
// const avatarPath = avatar && avatar.url;
|
||||
// const color = avatar && avatar.color;
|
||||
// const isMe = this.ourNumber === this.model.id;
|
||||
|
||||
// const attachments = this.model.get('attachments') || [];
|
||||
// const loadedAttachmentViews = Promise.all(
|
||||
// attachments.map(
|
||||
// attachment =>
|
||||
// new Promise(async resolve => {
|
||||
// const attachmentWithData = await loadAttachmentData(attachment);
|
||||
// const view = new Whisper.AttachmentView({
|
||||
// model: attachmentWithData,
|
||||
// timestamp: this.model.get('sent_at'),
|
||||
// });
|
||||
|
||||
// this.listenTo(view, 'update', () => {
|
||||
// // NOTE: Can we do without `updated` flag now that we use promises?
|
||||
// view.updated = true;
|
||||
// resolve(view);
|
||||
// });
|
||||
|
||||
// view.render();
|
||||
// })
|
||||
// )
|
||||
// );
|
||||
|
||||
// Wiring up TimerNotification
|
||||
|
||||
// this.conversation = this.model.getExpirationTimerUpdateSource();
|
||||
// this.listenTo(this.conversation, 'change', this.render);
|
||||
// this.listenTo(this.model, 'unload', this.remove);
|
||||
// this.listenTo(this.model, 'change', this.onChange);
|
||||
|
||||
// Wiring up SafetyNumberNotification
|
||||
|
||||
// this.conversation = this.model.getModelForKeyChange();
|
||||
// this.listenTo(this.conversation, 'change', this.render);
|
||||
// this.listenTo(this.model, 'unload', this.remove);
|
||||
|
||||
// Wiring up VerificationNotification
|
||||
|
||||
// this.conversation = this.model.getModelForVerifiedChange();
|
||||
// this.listenTo(this.conversation, 'change', this.render);
|
||||
// this.listenTo(this.model, 'unload', this.remove);
|
||||
|
||||
// this.contactView = new Whisper.ReactWrapperView({
|
||||
// className: 'contact-wrapper',
|
||||
// Component: window.Signal.Components.ContactListItem,
|
||||
// props: {
|
||||
// isMe,
|
||||
// color,
|
||||
// avatarPath,
|
||||
// phoneNumber: model.getNumber(),
|
||||
// name: model.getName(),
|
||||
// profileName: model.getProfileName(),
|
||||
// verified: model.isVerified(),
|
||||
// onClick: showIdentity,
|
||||
// },
|
||||
// });
|
||||
|
||||
// this.$el.append(this.contactView.el);
|
||||
}
|
||||
|
||||
// We actually don't listen to the model telling us that it's gone if it's disappearing
|
||||
// onDestroy() {
|
||||
// if (this.$el.hasClass('expired')) {
|
||||
// return;
|
||||
// }
|
||||
// this.onUnload();
|
||||
// },
|
||||
|
||||
// The backflips required to maintain scroll position when loading images
|
||||
// Key is only adding the img to the DOM when the image has loaded.
|
||||
//
|
||||
// How might we get similar behavior with React?
|
||||
//
|
||||
// this.trigger('beforeChangeHeight');
|
||||
// this.$('.attachments').append(view.el);
|
||||
// view.setElement(view.el);
|
||||
// this.trigger('afterChangeHeight');
|
||||
|
||||
// Timer code
|
||||
|
||||
// if (this.model.isExpired()) {
|
||||
// return this;
|
||||
// }
|
||||
// if (this.model.isExpiring()) {
|
||||
// this.render();
|
||||
// const totalTime = this.model.get('expireTimer') * 1000;
|
||||
// const remainingTime = this.model.msTilExpire();
|
||||
// const elapsed = (totalTime - remainingTime) / totalTime;
|
||||
// this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`);
|
||||
// this.$el.css('display', 'inline-block');
|
||||
// this.timeout = setTimeout(
|
||||
// this.update.bind(this),
|
||||
// Math.max(totalTime / 100, 500)
|
||||
// );
|
||||
// }
|
||||
|
||||
// Expiring message
|
||||
|
||||
// this.$el.addClass('expired');
|
||||
// this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => {
|
||||
// if (e.target === this.$('.bubble')[0]) {
|
||||
// this.remove();
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Failsafe: if in the background, animation events don't fire
|
||||
// setTimeout(this.remove.bind(this), 1000);
|
||||
|
||||
// Retrying a message
|
||||
// retryMessage() {
|
||||
// const retrys = _.filter(
|
||||
// this.model.get('errors'),
|
||||
// this.model.isReplayableError.bind(this.model)
|
||||
// );
|
||||
// _.map(retrys, 'number').forEach(number => {
|
||||
// this.model.resend(number);
|
||||
// });
|
||||
// },
|
|
@ -1,22 +1,12 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { default as _, padStart, sample } from 'lodash';
|
||||
import libphonenumber from 'google-libphonenumber';
|
||||
|
||||
import moment from 'moment';
|
||||
import QueryString from 'qs';
|
||||
|
||||
export { _ };
|
||||
|
||||
// Helper components used in the Style Guide, exposed at 'util' in the global scope via
|
||||
// the 'context' option in react-styleguidist.
|
||||
// This file provides helpers for the Style Guide, exposed at 'util' in the global scope
|
||||
// via the 'context' option in react-styleguidist.
|
||||
|
||||
import { default as _ } from 'lodash';
|
||||
export { ConversationContext } from './ConversationContext';
|
||||
export { BackboneWrapper } from '../components/utility/BackboneWrapper';
|
||||
|
||||
// @ts-ignore
|
||||
import * as Signal from '../../js/modules/signal';
|
||||
import { SignalService } from '../protobuf';
|
||||
export { _ };
|
||||
|
||||
// TypeScript wants two things when you import:
|
||||
// 1) a normal typescript file
|
||||
|
@ -25,6 +15,7 @@ import { SignalService } from '../protobuf';
|
|||
|
||||
// @ts-ignore
|
||||
import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif';
|
||||
// 320x240
|
||||
const gifObjectUrl = makeObjectUrl(gif, 'image/gif');
|
||||
// @ts-ignore
|
||||
import mp3 from '../../fixtures/incompetech-com-Agnus-Dei-X.mp3';
|
||||
|
@ -37,6 +28,7 @@ import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
|
|||
const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4');
|
||||
// @ts-ignore
|
||||
import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png';
|
||||
// 800×1200
|
||||
const pngObjectUrl = makeObjectUrl(png, 'image/png');
|
||||
|
||||
// @ts-ignore
|
||||
|
@ -63,9 +55,6 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
|
|||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
const ourNumber = '+12025559999';
|
||||
const groupNumber = '+12025550099';
|
||||
|
||||
export {
|
||||
mp3,
|
||||
mp3ObjectUrl,
|
||||
|
@ -87,13 +76,8 @@ export {
|
|||
landscapeRedObjectUrl,
|
||||
portraitTeal,
|
||||
portraitTealObjectUrl,
|
||||
ourNumber,
|
||||
groupNumber,
|
||||
};
|
||||
|
||||
// Required, or TypeScript complains about adding keys to window
|
||||
const parent = window as any;
|
||||
|
||||
const query = window.location.search.replace(/^\?/, '');
|
||||
const urlOptions = QueryString.parse(query);
|
||||
const theme = urlOptions.theme || 'light-theme';
|
||||
|
@ -104,123 +88,10 @@ import localeMessages from '../../_locales/en/messages.json';
|
|||
|
||||
// @ts-ignore
|
||||
import { setup } from '../../js/modules/i18n';
|
||||
import fileSize from 'filesize';
|
||||
|
||||
const i18n = setup(locale, localeMessages);
|
||||
|
||||
parent.filesize = fileSize;
|
||||
|
||||
parent.i18n = i18n;
|
||||
parent.React = React;
|
||||
parent.ReactDOM = ReactDOM;
|
||||
parent.moment = moment;
|
||||
|
||||
parent.moment.updateLocale(locale, {
|
||||
relativeTime: {
|
||||
h: parent.i18n('timestamp_h'),
|
||||
m: parent.i18n('timestamp_m'),
|
||||
s: parent.i18n('timestamp_s'),
|
||||
},
|
||||
});
|
||||
parent.moment.locale(locale);
|
||||
|
||||
export { theme, locale, i18n };
|
||||
|
||||
// Used by signal.js to set up code that deals with message attachments/avatars
|
||||
const Attachments = {
|
||||
createAbsolutePathGetter: () => () => '/fake/path',
|
||||
createDeleter: () => async () => undefined,
|
||||
createReader: () => async () => new ArrayBuffer(10),
|
||||
createWriterForExisting: () => async () => '/fake/path',
|
||||
createWriterForNew: () => async () => ({
|
||||
data: new ArrayBuffer(10),
|
||||
path: '/fake/path',
|
||||
}),
|
||||
getPath: (path: string) => path,
|
||||
};
|
||||
|
||||
parent.Signal = Signal.setup({
|
||||
Attachments,
|
||||
userDataPath: '/',
|
||||
// tslint:disable-next-line:no-backbone-get-set-outside-model
|
||||
getRegionCode: () => parent.storage.get('regionCode'),
|
||||
});
|
||||
parent.SignalService = SignalService;
|
||||
|
||||
parent.ConversationController._initialFetchComplete = true;
|
||||
parent.ConversationController._initialPromise = Promise.resolve();
|
||||
|
||||
const COLORS = [
|
||||
'red',
|
||||
'pink',
|
||||
'purple',
|
||||
'deep_purple',
|
||||
'indigo',
|
||||
'blue',
|
||||
'light_blue',
|
||||
'cyan',
|
||||
'teal',
|
||||
'green',
|
||||
'light_green',
|
||||
'orange',
|
||||
'deep_orange',
|
||||
'amber',
|
||||
'blue_grey',
|
||||
'grey',
|
||||
'default',
|
||||
];
|
||||
|
||||
const CONTACTS = COLORS.map((color, index) => {
|
||||
const title = `${sample(['Mr.', 'Mrs.', 'Ms.', 'Unknown'])} ${color} 🔥`;
|
||||
const key = sample(['name', 'profileName']) as string;
|
||||
const id = `+1202555${padStart(index.toString(), 4, '0')}`;
|
||||
|
||||
const contact = {
|
||||
color,
|
||||
[key]: title,
|
||||
id,
|
||||
type: 'private',
|
||||
};
|
||||
|
||||
return parent.ConversationController.dangerouslyCreateAndAdd(contact);
|
||||
});
|
||||
|
||||
const me = parent.ConversationController.dangerouslyCreateAndAdd({
|
||||
id: ourNumber,
|
||||
name: 'Me!',
|
||||
type: 'private',
|
||||
color: 'light_blue',
|
||||
});
|
||||
|
||||
const group = parent.ConversationController.dangerouslyCreateAndAdd({
|
||||
id: groupNumber,
|
||||
name: 'A place for sharing cats',
|
||||
type: 'group',
|
||||
});
|
||||
|
||||
group.contactCollection.add(me);
|
||||
group.contactCollection.add(CONTACTS[0]);
|
||||
group.contactCollection.add(CONTACTS[1]);
|
||||
group.contactCollection.add(CONTACTS[2]);
|
||||
|
||||
export { COLORS, CONTACTS, me, group };
|
||||
|
||||
parent.textsecure.storage.user.getNumber = () => ourNumber;
|
||||
parent.textsecure.messaging = {
|
||||
getProfile: async (phoneNumber: string): Promise<boolean> => {
|
||||
if (parent.ConversationController.get(phoneNumber)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error('User does not have Signal account');
|
||||
},
|
||||
};
|
||||
|
||||
parent.libphonenumber = libphonenumber.PhoneNumberUtil.getInstance();
|
||||
parent.libphonenumber.PhoneNumberFormat = libphonenumber.PhoneNumberFormat;
|
||||
|
||||
parent.storage.put('regionCode', 'US');
|
||||
|
||||
// Telling Lodash to relinquish _ for use by underscore
|
||||
// @ts-ignore
|
||||
_.noConflict();
|
||||
|
|
|
@ -70,10 +70,19 @@ export function contactSelector(
|
|||
contact: Contact,
|
||||
options: {
|
||||
regionCode: string;
|
||||
hasSignalAccount: boolean;
|
||||
getAbsoluteAttachmentPath: (path: string) => string;
|
||||
onSendMessage: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
) {
|
||||
const { regionCode, getAbsoluteAttachmentPath } = options;
|
||||
const {
|
||||
getAbsoluteAttachmentPath,
|
||||
hasSignalAccount,
|
||||
onClick,
|
||||
onSendMessage,
|
||||
regionCode,
|
||||
} = options;
|
||||
|
||||
let { avatar } = contact;
|
||||
if (avatar && avatar.avatar && avatar.avatar.path) {
|
||||
|
@ -88,6 +97,9 @@ export function contactSelector(
|
|||
|
||||
return {
|
||||
...contact,
|
||||
hasSignalAccount,
|
||||
onSendMessage,
|
||||
onClick,
|
||||
avatar,
|
||||
number:
|
||||
contact.number &&
|
||||
|
|
|
@ -6,3 +6,15 @@ export type RenderTextCallback = (
|
|||
) => JSX.Element | string;
|
||||
|
||||
export type Localizer = (key: string, values?: Array<string>) => string;
|
||||
|
||||
export type Color =
|
||||
| 'gray'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'deep_orange'
|
||||
| 'green'
|
||||
| 'indigo'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'teal';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as GoogleChrome from './GoogleChrome';
|
||||
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
|
||||
import { missingCaseError } from './missingCaseError';
|
||||
import { migrateColor } from './migrateColor';
|
||||
|
||||
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError };
|
||||
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError, migrateColor };
|
||||
|
|