Compare commits

...

31 Commits
main ... 5.1.x

Author SHA1 Message Date
Scott Nonnenberg
c35e0b8e85 v5.1.0
Some checks failed
Benchmark / linux (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / linux (push) Has been cancelled
CI / windows (push) Has been cancelled
2021-05-11 17:16:10 -07:00
Fedor Indutny
248ebe2158
Apply reactions to messages in "Notes to Self" 2021-05-11 14:36:33 -07:00
Fedor Indutny
2adab4d52b
Batch and de-duplicate profile key updates 2021-05-11 11:46:26 -07:00
Evan Hahn
b53a61fbde
Fix non-default disappearing message timers on group details screen 2021-05-11 13:27:19 -05:00
Evan Hahn
27b44aae50
Make it more difficult to blur avatars 2021-05-10 16:44:36 -07:00
Evan Hahn
a9149c870c
Center non-square avatar pictures 2021-05-10 13:57:45 -05:00
Fedor Indutny
450a5408f1
Backport several commits to fix flaky CI 2021-05-07 18:11:57 -07:00
Fedor Indutny
be9d5840be
Fix race conditions in challenge test 2021-05-07 14:16:43 -07:00
Fedor Indutny
76e42c8a3c
Prevent uncaught rejections in sql initialize 2021-05-07 12:30:07 -07:00
Scott Nonnenberg
c38c8ea02b
Fix race condition in challenge test 2021-05-07 09:40:34 -07:00
Fedor Indutny
03d6824d3a
Fix uncaught exception during shutdown on update 2021-05-06 17:18:20 -05:00
Scott Nonnenberg
6f5f229240
Revert "Update to RingRTC v2.9.6" 2021-05-06 16:30:57 -05:00
Scott Nonnenberg
8553aeee71 v5.1.0-beta.6
Some checks failed
Benchmark / linux (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / linux (push) Has been cancelled
CI / windows (push) Has been cancelled
2021-05-05 17:41:31 -07:00
Fedor Indutny
862d20fc86
Show challenge when requested by server 2021-05-05 17:39:38 -07:00
Scott Nonnenberg
279e0dc5b8
Update to RingRTC v2.9.6 (#1648)
Co-authored-by: Jim Gustafson <jim@signal.org>
2021-05-05 16:53:17 -07:00
Scott Nonnenberg
857cf54f9b
Fix hidden trash icons in group details screen (#1649)
Co-authored-by: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com>
2021-05-05 16:53:04 -07:00
Evan Hahn
6005c08327
Fix render loop in <ConversationHero> 2021-05-05 16:52:33 -07:00
Scott Nonnenberg
a17cb83d7e
Update to electron 12.0.6
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
2021-05-05 17:59:27 -05:00
Evan Hahn
c305622f11
Show "no groups in common" warning for relevant message requests 2021-05-05 15:46:09 -07:00
Evan Hahn
dbaa180099
Fix a number visual bugs with message forwarding
Co-authored-by: Scott Nonnenberg <scott@signal.org>
2021-05-05 14:26:12 -07:00
Evan Hahn
940b972bc5
When incoming message should've been sealed sender, reply with profile key 2021-05-05 14:25:38 -07:00
Evan Hahn
49ae89fb47
Mark links with a password as "sneaky" 2021-05-05 09:22:16 -07:00
Evan Hahn
53b050ffd4
Fix blurred avatars in message details 2021-05-04 19:19:02 -05:00
Fedor Indutny
4486c40487
benchmarks: cumulative output 2021-05-04 18:55:28 -05:00
Evan Hahn
d23edf2c3b
Left pane: Ensure pinned conversations show only once
Co-authored-by: Scott Nonnenberg <scott@signal.org>
2021-05-04 17:40:28 -05:00
Evan Hahn
581552282e
Use ES2020 type definitions 2021-05-04 17:34:03 -05:00
Evan Hahn
1db1d2156d
Remove lineNumber from lint exceptions 2021-05-04 12:13:08 -05:00
Scott Nonnenberg
5cdbc8be03
Fine-tuning of conversation lists (compose, forward, left pane) 2021-05-04 11:49:26 -05:00
Evan Hahn
3cbdb4e353
Render disappearing message timers generically 2021-05-04 09:16:49 -07:00
Scott Nonnenberg
c3814c30f9
Message Requests: Always open to top of conversation 2021-05-03 12:56:29 -05:00
Evan Hahn
5c83ded376
Blur avatars of unapproved conversations 2021-05-01 12:46:19 -05:00
166 changed files with 8491 additions and 6937 deletions

View File

@ -71,8 +71,21 @@ jobs:
RUN_COUNT: 10
ELECTRON_ENABLE_STACK_DUMPING: on
- name: Upload benchmark log
uses: actions/upload-artifact@v2
- name: Clone benchmark branch
uses: actions/checkout@v2
with:
name: benchmark.log
path: benchmark.log
repository: 'signalapp/Signal-Desktop-Benchmarks-Private'
path: 'benchmark-results'
token: ${{ secrets.AUTOMATED_GITHUB_PAT }}
- name: Push benchmark branch
working-directory: benchmark-results
run: |
npm ci
node ./bin/collect.js ../benchmark.log data.json
npm run build
git config --global user.email "no-reply@signal.org"
git config --global user.name "Signal Bot"
git add .
git commit --message "${GITHUB_REF} ${GITHUB_SHA}"
git push --force origin main

View File

@ -1538,6 +1538,33 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## humanize-duration
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
## intl-tel-input
The MIT License (MIT)

View File

@ -1505,6 +1505,10 @@
"message": "Send failed",
"description": "Shown on outgoing message if it fails to send"
},
"sendPaused": {
"message": "Send paused",
"description": "Shown on outgoing message if it cannot be sent immediately"
},
"partiallySent": {
"message": "Partially sent, click for details",
"description": "Shown on outgoing message if it is partially sent"
@ -1657,106 +1661,14 @@
}
}
},
"timerOption_0_seconds": {
"disappearingMessages__off": {
"message": "off",
"description": "Label for option to turn off message expiration in the timer menu"
},
"timerOption_5_seconds": {
"message": "5 seconds",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_10_seconds": {
"message": "10 seconds",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_30_seconds": {
"message": "30 seconds",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_minute": {
"message": "1 minute",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_5_minutes": {
"message": "5 minutes",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_30_minutes": {
"message": "30 minutes",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_hour": {
"message": "1 hour",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_6_hours": {
"message": "6 hours",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_12_hours": {
"message": "12 hours",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_day": {
"message": "1 day",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_week": {
"message": "1 week",
"description": "Label for a selectable option in the message expiration timer menu"
},
"disappearingMessages": {
"message": "Disappearing messages",
"description": "Conversation menu option to enable disappearing messages"
},
"timerOption_0_seconds_abbreviated": {
"message": "off",
"description": "Short format indicating current timer setting in the conversation list snippet"
},
"timerOption_5_seconds_abbreviated": {
"message": "5s",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_10_seconds_abbreviated": {
"message": "10s",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_30_seconds_abbreviated": {
"message": "30s",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_minute_abbreviated": {
"message": "1m",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_5_minutes_abbreviated": {
"message": "5m",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_30_minutes_abbreviated": {
"message": "30m",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_hour_abbreviated": {
"message": "1h",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_6_hours_abbreviated": {
"message": "6h",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_12_hours_abbreviated": {
"message": "12h",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_day_abbreviated": {
"message": "1d",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_week_abbreviated": {
"message": "1w",
"description": "Very short format indicating current timer setting in the conversation header"
},
"disappearingMessagesDisabled": {
"message": "Disappearing messages disabled",
"description": "Displayed in the left pane when the timer is turned off"
@ -3142,6 +3054,10 @@
"message": "No groups in common.",
"description": "Shown to indicate this user is not a member of any groups"
},
"no-groups-in-common-warning": {
"message": "No groups in common. Review requests carefully.",
"description": "When a user has no common groups, show this warning"
},
"acceptCall": {
"message": "Answer",
"description": "Shown in tooltip for the button to accept a call (audio or video)"
@ -5164,5 +5080,83 @@
"ForwardMessageModal--continue": {
"message": "Continue",
"description": "aria-label for the 'next' button in the forward a message modal dialog"
},
"MessageRequestWarning__learn-more": {
"message": "Learn more",
"description": "Shown on the message request warning. Clicking this button will open a dialog with more information"
},
"MessageRequestWarning__dialog__details": {
"message": "You have no groups in common with this person. Review requests carefully before accepting to avoid unwanted messages.",
"description": "Shown in the message request warning dialog. Gives more information about message requests"
},
"MessageRequestWarning__dialog__learn-even-more": {
"message": "About Message Requests",
"description": "Shown in the message request warning dialog. Clicking this button will open a page on Signal's support site"
},
"ContactSpoofing__same-name": {
"message": "Review requests carefully. Signal found another contact with the same name. $link$",
"description": "Shown in the timeline warning when you have a message request from someone with the same name as someone else",
"placeholders": {
"link": {
"content": "$1",
"example": "Review request"
}
}
},
"ContactSpoofing__same-name__link": {
"message": "Review request",
"description": "Shown in the timeline warning when you have a message request from someone with the same name as someone else"
},
"ContactSpoofingReviewDialog__title": {
"message": "Review request",
"description": "Title for the contact name spoofing review dialog"
},
"ContactSpoofingReviewDialog__description": {
"message": "If you're not sure who the request is from, review the contacts below and take action.",
"description": "Description for the contact spoofing review dialog"
},
"ContactSpoofingReviewDialog__possibly-unsafe-title": {
"message": "Request",
"description": "Header in the contact spoofing review dialog, shown above the potentially-unsafe user"
},
"ContactSpoofingReviewDialog__safe-title": {
"message": "Your contact",
"description": "Header in the contact spoofing review dialog, shown above the \"safe\" user"
},
"CaptchaDialog__title": {
"message": "Verify to continue messaging",
"description": "Header in the captcha dialog"
},
"CaptchaDialog__first-paragraph": {
"message": "To help prevent spam on Signal, please complete verification.",
"description": "First paragraph in the captcha dialog"
},
"CaptchaDialog__second-paragraph": {
"message": "After verifying, you can continue messaging. Any paused messages will automatically be sent.",
"description": "First paragraph in the captcha dialog"
},
"CaptchaDialog--can-close__title": {
"message": "Continue Without Verifying?",
"description": "Header in the captcha dialog that can be closed"
},
"CaptchaDialog--can-close__body": {
"message": "If you choose to skip verification, you may miss messages from other people and your messages may fail to send.",
"description": "Body of the captcha dialog that can be closed"
},
"CaptchaDialog--can_close__skip-verification": {
"message": "Skip verification",
"description": "Skip button of the captcha dialog that can be closed"
},
"verificationComplete": {
"message": "Verification complete.",
"description": "Displayed after successful captcha"
},
"verificationFailed": {
"message": "Verification failed. Please retry later.",
"description": "Displayed after unsuccessful captcha"
},
"deleteForEveryoneFailed": {
"message": "Failed to delete message for everyone. Please retry later.",
"description": "Displayed when delete-for-everyone has failed to send to all recepients"
}
}

BIN
fixtures/wide.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><g stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="m14 12.37-.1-4.56c-.0053-.17991-.0459-.35703-.1196-.52123-.0737-.16421-.179-.31229-.3099-.43579-.131-.1235-.285-.22001-.4532-.284-.1682-.064-.3474-.09423-.5273-.08898s-.357.04589-.5212.11959-.3123.17903-.4358.30996-.22.2849-.284.45313-.0943.34741-.089.52732"/><path d="m16.79 12.74-.11-3.65c-.0119-.36335-.1677-.70707-.4331-.95556-.2653-.24848-.6186-.38137-.9819-.36944s-.7071.16772-.9556.43308c-.2484.26537-.3813.61857-.3694.98192"/><path d="m11.3 12.46-.3-9.13c-.0053-.17991-.0459-.35703-.1196-.52123-.0737-.16421-.179-.31229-.3099-.43579-.131-.1235-.285-.22001-.4532-.284-.1682-.064-.34739-.09423-.5273-.08898s-.35702.04589-.52123.11959-.31229.17903-.43579.30996-.22.2849-.284.45313-.09423.34741-.08898.52732l.29 9.13v1.37.78s-1.71-2.28-3.15-4.48c-1.06-1.65-2.8-.23-2.07 1 2.84 5.24 4.49 11.27 10.55 10.79s5.78-5.2 5.72-7-.12-4.11-.12-4.11v-.45c-.0106-.3633-.1651-.7076-.4295-.95702-.2645-.24942-.6172-.38359-.9805-.37298s-.7076.16512-.957.42955c-.2494.26442-.3836.61715-.373.98045"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -67,7 +67,10 @@ async function start(options = {}) {
}
async function stop() {
logger.info('attachment_downloads/stop: disabling');
// If `.start()` wasn't called - the `logger` is `undefined`
if (logger) {
logger.info('attachment_downloads/stop: disabling');
}
enabled = false;
if (timeout) {
clearTimeout(timeout);

View File

@ -117,7 +117,7 @@ function isLinkSneaky(href) {
}
// Any links which contain auth are considered sneaky
if (url.username) {
if (url.username || url.password) {
return true;
}

70
main.js
View File

@ -106,8 +106,10 @@ const OS = require('./ts/OS');
const { isBeta } = require('./ts/util/version');
const {
isSgnlHref,
isCaptchaHref,
isSignalHttpsLink,
parseSgnlHref,
parseCaptchaHref,
parseSignalHttpsLink,
} = require('./ts/util/sgnlHref');
const {
@ -118,8 +120,10 @@ const {
TitleBarVisibility,
} = require('./ts/types/Settings');
const { Environment } = require('./ts/environment');
const { ChallengeMainHandler } = require('./ts/main/challengeMain');
const sql = new MainSQL();
const challengeHandler = new ChallengeMainHandler();
let sqlInitTimeStart = 0;
let sqlInitTimeEnd = 0;
@ -191,6 +195,12 @@ if (!process.mas) {
showWindow();
}
const incomingCaptchaHref = getIncomingCaptchaHref(argv);
if (incomingCaptchaHref) {
const { captcha } = parseCaptchaHref(incomingCaptchaHref, logger);
challengeHandler.handleCaptcha(captcha);
return true;
}
// Are they trying to open a sgnl:// href?
const incomingHref = getIncomingHref(argv);
if (incomingHref) {
@ -540,15 +550,8 @@ async function createWindow() {
mainWindow.once('ready-to-show', async () => {
console.log('main window is ready-to-show');
try {
await sqlInitPromise;
} catch (error) {
console.log(
'main window is ready, but sql has errored',
error && error.stack
);
return;
}
// Ignore sql errors and show the window anyway
await sqlInitPromise;
if (!mainWindow) {
return;
@ -564,9 +567,12 @@ async function createWindow() {
// Renderer asks if we are done with the database
ipc.on('database-ready', async event => {
try {
await sqlInitPromise;
} catch (error) {
const { error } = await sqlInitPromise;
if (error) {
console.log(
'database-ready requested, but got sql error',
error && error.stack
);
return;
}
@ -1018,11 +1024,18 @@ async function initializeSQL() {
}
sqlInitTimeStart = Date.now();
await sql.initialize({
configDir: userDataPath,
key,
});
sqlInitTimeEnd = Date.now();
try {
await sql.initialize({
configDir: userDataPath,
key,
});
} catch (error) {
return { ok: false, error };
} finally {
sqlInitTimeEnd = Date.now();
}
return { ok: true };
}
const sqlInitPromise = initializeSQL();
@ -1145,7 +1158,7 @@ app.on('ready', async () => {
loadingWindow.once('ready-to-show', async () => {
loadingWindow.show();
// Wait for sql initialization to complete
// Wait for sql initialization to complete, but ignore errors
await sqlInitPromise;
loadingWindow.destroy();
loadingWindow = null;
@ -1157,9 +1170,8 @@ app.on('ready', async () => {
// Run window preloading in parallel with database initialization.
await createWindow();
try {
await sqlInitPromise;
} catch (error) {
const { error: sqlError } = await sqlInitPromise;
if (sqlError) {
console.log('sql.initialize was unsuccessful; returning early');
const buttonIndex = dialog.showMessageBoxSync({
buttons: [
@ -1167,7 +1179,7 @@ app.on('ready', async () => {
locale.messages.deleteAndRestart.message,
],
defaultId: 0,
detail: redactAll(error.stack),
detail: redactAll(sqlError.stack),
message: locale.messages.databaseError.message,
noLink: true,
type: 'error',
@ -1175,7 +1187,7 @@ app.on('ready', async () => {
if (buttonIndex === 0) {
clipboard.writeText(
`Database startup error:\n\n${redactAll(error.stack)}`
`Database startup error:\n\n${redactAll(sqlError.stack)}`
);
} else {
await sql.sqlCall('removeDB', []);
@ -1386,11 +1398,19 @@ app.on('web-contents-created', (createEvent, contents) => {
});
app.setAsDefaultProtocolClient('sgnl');
app.setAsDefaultProtocolClient('signalcaptcha');
app.on('will-finish-launching', () => {
// open-url must be set from within will-finish-launching for macOS
// https://stackoverflow.com/a/43949291
app.on('open-url', (event, incomingHref) => {
event.preventDefault();
if (isCaptchaHref(incomingHref, logger)) {
const { captcha } = parseCaptchaHref(incomingHref, logger);
challengeHandler.handleCaptcha(captcha);
return;
}
handleSgnlHref(incomingHref);
});
});
@ -1651,6 +1671,10 @@ function getIncomingHref(argv) {
return argv.find(arg => isSgnlHref(arg, logger));
}
function getIncomingCaptchaHref(argv) {
return argv.find(arg => isCaptchaHref(arg, logger));
}
function handleSgnlHref(incomingHref) {
let command;
let args;

View File

@ -4,7 +4,7 @@
"description": "Private messaging from your desktop",
"desktopName": "signal.desktop",
"repository": "https://github.com/signalapp/Signal-Desktop.git",
"version": "5.1.0-beta.5",
"version": "5.1.0",
"license": "AGPL-3.0-only",
"author": {
"name": "Open Whisper Systems",
@ -97,6 +97,7 @@
"google-libphonenumber": "3.2.17",
"got": "8.3.2",
"history": "4.9.0",
"humanize-duration": "3.26.0",
"intl-tel-input": "12.1.15",
"jquery": "3.5.0",
"js-yaml": "3.13.1",
@ -186,6 +187,7 @@
"@types/google-libphonenumber": "7.4.14",
"@types/got": "9.4.1",
"@types/history": "4.7.2",
"@types/humanize-duration": "^3.18.1",
"@types/jquery": "3.5.0",
"@types/js-yaml": "3.12.0",
"@types/linkify-it": "2.1.0",
@ -232,7 +234,7 @@
"core-js": "2.6.9",
"cross-env": "5.2.0",
"css-loader": "3.2.0",
"electron": "12.0.3",
"electron": "12.0.6",
"electron-builder": "22.10.5",
"electron-mocha": "8.1.1",
"electron-notarize": "0.1.1",
@ -365,7 +367,8 @@
"protocols": {
"name": "sgnl-url-scheme",
"schemes": [
"sgnl"
"sgnl",
"signalcaptcha"
]
},
"asarUnpack": [

View File

@ -173,6 +173,12 @@ try {
Whisper.events.trigger('setupAsStandalone');
});
ipc.on('challenge:response', (_event, response) => {
Whisper.events.trigger('challengeResponse', response);
});
window.sendChallengeRequest = request =>
ipc.send('challenge:request', request);
{
let isFullScreen = config.isFullScreen === 'true';

View File

@ -106,7 +106,12 @@
}
}
// Smooth scrolling
// Utilities
@mixin rounded-corners() {
// This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
border-radius: 9999px;
}
@mixin smooth-scroll() {
scroll-behavior: smooth;
@ -472,7 +477,7 @@
}
@mixin button-small {
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
@include rounded-corners;
padding: 7px 14px;
}

View File

@ -297,6 +297,17 @@
);
}
}
.module-message__error--paused {
@include light-theme {
@include color-svg(
'../images/icons/v2/error-outline-24.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg('../images/icons/v2/error-solid-24.svg', $color-gray-45);
}
}
.module-message__error--outgoing {
left: 8px;
@ -1261,6 +1272,7 @@
margin-bottom: 2px;
}
.module-message__metadata__status-icon--paused,
.module-message__metadata__status-icon--sending {
animation: module-message__metadata__status-icon--spinning 4s linear infinite;
@ -1423,7 +1435,7 @@
&:focus {
outline: none;
.module-avatar {
.module-Avatar {
@include keyboard-mode {
box-shadow: 0 0 0 3px $ultramarine-ui-light;
}
@ -3257,6 +3269,7 @@ button.module-conversation-details__action-button {
}
&-panel-row {
$row-root-selector: '#{&}__root';
&__root {
align-items: center;
border-radius: 5px;
@ -3336,7 +3349,8 @@ button.module-conversation-details__action-button {
overflow: hidden;
opacity: 0;
&:focus-within {
#{$row-root-selector}:hover &,
#{$row-root-selector}:focus-within & {
opacity: 1;
}
}
@ -3929,6 +3943,46 @@ button.module-conversation-details__action-button {
@include font-body-2-bold;
}
}
&__message-request-warning {
@include font-body-2;
&__message {
display: flex;
margin-bottom: 12px;
align-items: center;
justify-content: center;
user-select: none;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
&::before {
content: '';
display: block;
height: 14px;
margin-right: 8px;
width: 14px;
@include light-theme {
@include color-svg(
'../images/icons/v2/info-outline-24.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/info-solid-24.svg',
$color-gray-25
);
}
}
}
}
}
// Module: Message Request Actions
@ -3981,322 +4035,6 @@ button.module-conversation-details__action-button {
}
}
// Module: Avatar
.module-avatar {
position: relative;
vertical-align: middle;
display: inline-block;
border-radius: 50%;
user-select: none;
img {
object-fit: cover;
border-radius: 50%;
}
}
.module-avatar-button {
@include button-reset;
// Ensures that the border of the item sticks tight to the inner contents
width: 100%;
line-height: 0;
border-radius: 50%;
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $ultramarine-ui-light;
}
}
}
.module-avatar__label {
width: 100%;
text-align: center;
font-weight: bold;
text-transform: uppercase;
@include light-theme {
color: $color-white;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-avatar__icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.module-avatar__icon--group {
@include light-theme {
@include color-svg('../images/icons/v2/group-outline-40.svg', $color-white);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/group-outline-40.svg',
$color-gray-05
);
}
}
.module-avatar__icon--direct {
@include light-theme {
@include color-svg(
'../images/icons/v2/profile-outline-40.svg',
$color-white
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/profile-outline-40.svg',
$color-gray-05
);
}
}
.module-avatar--28 {
min-width: 28px;
height: 28px;
width: 28px;
img {
height: 28px;
width: 28px;
}
}
.module-avatar__icon--28.module-avatar__icon--group {
height: 20px;
width: 20px;
@include light-theme {
@include color-svg('../images/icons/v2/group-outline-20.svg', $color-white);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/group-outline-20.svg',
$color-gray-05
);
}
}
.module-avatar__icon--28.module-avatar__icon--direct {
height: 20px;
width: 20px;
@include light-theme {
@include color-svg(
'../images/icons/v2/profile-outline-20.svg',
$color-white
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/profile-outline-20.svg',
$color-gray-05
);
}
}
.module-avatar__label--28 {
font-size: 14px;
line-height: 28px;
}
.module-avatar--32 {
height: 32px;
width: 32px;
min-width: 32px;
img {
height: 32px;
width: 32px;
}
}
.module-avatar__icon--32.module-avatar__icon--group {
height: 20px;
width: 20px;
@include light-theme {
@include color-svg('../images/icons/v2/group-outline-20.svg', $color-white);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/group-outline-20.svg',
$color-gray-05
);
}
}
.module-avatar__icon--32.module-avatar__icon--direct {
height: 20px;
width: 20px;
@include light-theme {
@include color-svg(
'../images/icons/v2/profile-outline-20.svg',
$color-white
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/profile-outline-20.svg',
$color-gray-05
);
}
}
.module-avatar__label--32 {
font-size: 14px;
line-height: 32px;
}
.module-avatar--52 {
height: 52px;
width: 52px;
min-width: 52px;
img {
height: 52px;
width: 52px;
}
}
.module-avatar__label--52 {
width: 52px;
font-size: 22px;
letter-spacing: 0.19px;
line-height: 52px;
}
.module-avatar__icon--52 {
height: 38px;
width: 38px;
}
.module-avatar__icon--52.module-avatar__icon--direct {
height: 42px;
width: 42px;
}
.module-avatar--80 {
height: 80px;
width: 80px;
min-width: 80px;
img {
height: 80px;
width: 80px;
}
}
.module-avatar__label--80 {
width: 80px;
font-size: 40px;
line-height: 80px;
}
.module-avatar__icon--80 {
height: 58px;
width: 58px;
}
.module-avatar__icon--80.module-avatar__icon--direct {
height: 62px;
width: 62px;
}
.module-avatar--96 {
height: 96px;
width: 96px;
min-width: 96px;
img {
height: 96px;
width: 96px;
}
}
.module-avatar__label--96 {
width: 96px;
font-size: 48px;
line-height: 96px;
}
.module-avatar__icon--96 {
height: 70px;
width: 70px;
}
.module-avatar--112 {
height: 112px;
width: 112px;
min-width: 112px;
img {
height: 112px;
width: 112px;
}
}
.module-avatar__label--112 {
width: 112px;
font-size: 56px;
line-height: 112px;
}
.module-avatar__icon--112 {
height: 81px;
width: 81px;
}
.module-avatar__icon--112.module-avatar__icon--direct {
height: 87px;
width: 87px;
}
.module-avatar__icon--note-to-self {
width: 70%;
height: 70%;
@include light-theme {
@include color-svg('../images/note-28.svg', $color-white);
}
@include dark-theme {
@include color-svg('../images/note-28.svg', $color-gray-05);
}
}
.module-avatar--no-image {
@include light-theme {
background-color: $color-conversation-grey;
}
@include dark-theme {
background-color: $color-conversation-grey-shade;
}
}
.module-avatar__spinner-container {
padding: 4px;
}
.module-avatar--signal-blue {
background-color: $ultramarine-ui-light;
}
@each $color, $value in $conversation-colors {
.module-avatar--#{$color} {
@include light-theme {
background-color: $value;
}
}
}
@each $color, $value in $conversation-colors-shade {
.module-avatar--#{$color} {
@include dark-theme {
background-color: $value;
}
}
}
// Module: Main Header
.module-main-header {
@ -5495,6 +5233,10 @@ button.module-image__border-overlay:focus {
}
}
.module-spinner__circle--on-captcha {
background-color: $color-white-alpha-40;
}
.module-spinner__circle--on-progress-dialog {
@include light-theme {
background-color: $color-white;
@ -5509,6 +5251,9 @@ button.module-image__border-overlay:focus {
.module-spinner__arc--on-avatar {
background-color: $color-white;
}
.module-spinner__arc--on-captcha {
background-color: $color-white;
}
// Module: Highlighted Message Body
@ -6685,7 +6430,7 @@ button.module-image__border-overlay:focus {
}
// The avatar image can be dragged on Windows.
.module-avatar img {
.module-Avatar img {
-webkit-user-drag: none;
-webkit-user-select: none;
}
@ -7264,6 +7009,21 @@ button.module-image__border-overlay:focus {
);
}
}
&--paused {
@include light-theme {
@include color-svg(
'../images/icons/v2/error-outline-12.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/error-solid-12.svg',
$color-gray-45
);
}
}
}
&__message-search-result-contents {

View File

@ -0,0 +1,148 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-Avatar {
align-items: center;
border-radius: 100%;
display: inline-flex;
justify-content: center;
line-height: 0;
overflow: hidden;
position: relative;
user-select: none;
vertical-align: middle;
&__button {
@include button-reset;
align-items: center;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $ultramarine-ui-light;
}
}
}
&__image {
background-position: center center;
background-size: cover;
display: flex;
height: 100%;
transition: filter 100ms ease-out;
width: 100%;
}
&__click-to-view {
@include font-body-2;
align-items: center;
background: $color-black-alpha-20;
color: $color-white;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
left: 0;
position: absolute;
top: 0;
width: 100%;
&::before {
@include color-svg(
'../images/icons/v2/click-outline-24.svg',
$color-white
);
content: '';
display: block;
height: 24px;
margin-bottom: 8px;
width: 24px;
}
&:hover {
background: $color-black-alpha-40;
}
}
&__label {
align-items: center;
display: flex;
font-weight: bold;
justify-content: center;
text-align: center;
text-transform: uppercase;
transition: font-size 100ms ease-out;
@include light-theme {
color: $color-white;
}
@include dark-theme {
color: $color-gray-05;
}
}
&__icon {
@mixin avatar-icon($icon) {
@include light-theme {
@include color-svg($icon, $color-white);
}
@include dark-theme {
@include color-svg($icon, $color-gray-05);
}
}
&--direct {
@include avatar-icon('../images/icons/v2/profile-outline-20.svg');
height: 60%;
width: 60%;
}
&--group {
@include avatar-icon('../images/icons/v2/group-outline-20.svg');
height: 60%;
width: 60%;
}
&--note-to-self {
@include avatar-icon('../images/note-28.svg');
height: 70%;
width: 70%;
}
}
&__spinner-container {
padding: 4px;
}
&--no-image {
@include light-theme {
background-color: $color-conversation-grey;
}
@include dark-theme {
background-color: $color-conversation-grey-shade;
}
}
&--signal-blue {
background-color: $ultramarine-ui-light;
}
@each $color, $value in $conversation-colors {
&--#{$color} {
@include light-theme {
background-color: $value;
}
}
}
@each $color, $value in $conversation-colors-shade {
&--#{$color} {
@include dark-theme {
background-color: $value;
}
}
}
}

View File

@ -18,8 +18,6 @@
}
@include button-reset;
@include font-body-1-bold;
border-radius: 4px;
padding: 8px 16px;
text-align: center;
@ -37,6 +35,16 @@
cursor: not-allowed;
}
&--medium {
@include font-body-1-bold;
}
&--small {
@include font-body-2;
@include rounded-corners;
padding: 6px 12px;
}
&--primary {
$color: $color-white;
$background-color: $ultramarine-ui-light;

View File

@ -2,8 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
.module-ContactPill {
@include rounded-corners;
align-items: center;
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
display: inline-flex;
user-select: none;
overflow: hidden;

View File

@ -279,9 +279,9 @@
&--join-call {
@include font-body-1;
@include rounded-corners;
align-items: center;
background-color: $color-accent-green;
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
color: $color-white;
display: flex;
outline: none;

View File

@ -60,8 +60,9 @@
&__scroller {
max-height: 300px;
min-height: 300px;
padding-right: 36px;
padding: 16px;
// Need more padding on the right to make room for floating emoji button
padding-right: 36px;
}
}
@ -79,14 +80,26 @@
}
}
&--cancel {
&--close {
@include button-reset;
position: absolute;
left: 16px;
top: 8px;
right: 16px;
height: 22px;
width: 22px;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
}
@include keyboard-mode {
&:focus {
color: $ultramarine-ui-light;
background-color: $ultramarine-ui-light;
}
}
}

View File

@ -39,20 +39,27 @@
height: 24px;
width: 24px;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
}
&::before {
content: '';
display: block;
width: 100%;
height: 100%;
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
}
&:focus {
@include keyboard-mode {
background-color: $ultramarine-ui-light;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
}
@include dark-keyboard-mode {
background-color: $ultramarine-ui-dark;
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
}
&:focus {
@include keyboard-mode {
background-color: $ultramarine-ui-light;
}
@include dark-keyboard-mode {
background-color: $ultramarine-ui-dark;
}
}
}
}
@ -93,4 +100,50 @@
margin-left: 8px;
}
}
// Overrides for a modal with important message
&--important {
padding: 10px 12px 16px 12px;
.module-Modal__header {
padding: 0;
}
.module-Modal__body {
padding: 0 12px 4px 12px !important;
}
.module-Modal__body p {
margin: 0 0 20px 0;
}
.module-Modal__title {
@include font-title-2;
text-align: center;
margin: 10px 0 22px 0;
flex-grow: 0;
flex-shrink: 0;
&--with-x-button {
margin-top: 31px;
}
}
.module-Modal__footer {
justify-content: center;
margin-top: 27px;
flex-grow: 0;
flex-shrink: 0;
.module-Button {
flex-grow: 1;
max-width: 152px;
&:not(:first-child) {
margin-left: 16px;
}
}
}
}
}

View File

@ -28,6 +28,7 @@
// New style: components
@import './components/AddGroupMembersModal.scss';
@import './components/Avatar.scss';
@import './components/AvatarInput.scss';
@import './components/Button.scss';
@import './components/ContactPill.scss';

View File

@ -148,6 +148,8 @@ describe('Link previews', () => {
describe('auth', () => {
it('returns true for hrefs with auth (or pretend auth)', () => {
assert.isTrue(isLinkSneaky('https://user:pass@example.com'));
assert.isTrue(isLinkSneaky('https://user:@example.com'));
assert.isTrue(isLinkSneaky('https://:pass@example.com'));
assert.isTrue(
isLinkSneaky('http://whatever.com&login=someuser@77777777')
);

View File

@ -5,6 +5,7 @@ import { DataMessageClass } from './textsecure.d';
import { MessageAttributesType } from './model-types.d';
import { WhatIsThis } from './window.d';
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
import { ChallengeHandler } from './challenge';
import { isWindowDragElement } from './util/isWindowDragElement';
import { assert } from './util/assert';
import { senderCertificateService } from './services/senderCertificate';
@ -12,7 +13,10 @@ import { routineProfileRefresh } from './routineProfileRefresh';
import { isMoreRecentThan, isOlderThan } from './util/timestamp';
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
import { ConversationModel } from './models/conversations';
import { getMessageById } from './models/messages';
import { createBatcher } from './util/batcher';
import { ourProfileKeyService } from './services/ourProfileKey';
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -30,6 +34,8 @@ export async function startApp(): Promise<void> {
);
}
ourProfileKeyService.initialize(window.storage);
let resolveOnAppView: (() => void) | undefined;
const onAppView = new Promise<void>(resolve => {
resolveOnAppView = resolve;
@ -51,6 +57,10 @@ export async function startApp(): Promise<void> {
concurrency: 1,
timeout: 1000 * 60 * 2,
});
const profileKeyResponseQueue = new window.PQueue();
profileKeyResponseQueue.pause();
window.Whisper.deliveryReceiptQueue = new window.PQueue({
concurrency: 1,
timeout: 1000 * 60 * 2,
@ -1421,7 +1431,62 @@ export async function startApp(): Promise<void> {
window.textsecure.messaging.sendRequestKeySyncMessage();
}
let challengeHandler: ChallengeHandler | undefined;
async function start() {
challengeHandler = new ChallengeHandler({
storage: window.storage,
getMessageById,
requestChallenge(request) {
window.sendChallengeRequest(request);
},
async sendChallengeResponse(data) {
await window.textsecure.messaging.sendChallengeResponse(data);
},
onChallengeFailed() {
// TODO: DESKTOP-1530
// Display humanized `retryAfter`
window.Whisper.ToastView.show(
window.Whisper.CaptchaFailedToast,
document.getElementsByClassName('conversation-stack')[0] ||
document.body
);
},
onChallengeSolved() {
window.Whisper.ToastView.show(
window.Whisper.CaptchaSolvedToast,
document.getElementsByClassName('conversation-stack')[0] ||
document.body
);
},
setChallengeStatus(challengeStatus) {
window.reduxActions.network.setChallengeStatus(challengeStatus);
},
});
window.Whisper.events.on('challengeResponse', response => {
if (!challengeHandler) {
throw new Error('Expected challenge handler to be there');
}
challengeHandler.onResponse(response);
});
window.storage.onready(async () => {
if (!challengeHandler) {
throw new Error('Expected challenge handler to be there');
}
await challengeHandler.load();
});
window.Signal.challengeHandler = challengeHandler;
window.dispatchEvent(new Event('storage_ready'));
window.log.info('Cleanup: starting...');
@ -1643,6 +1708,10 @@ export async function startApp(): Promise<void> {
// we get an online event. This waits a bit after getting an 'offline' event
// before disconnecting the socket manually.
disconnectTimer = setTimeout(disconnect, 1000);
if (challengeHandler) {
challengeHandler.onOffline();
}
}
function onOnline() {
@ -1800,8 +1869,10 @@ export async function startApp(): Promise<void> {
connectCount += 1;
window.Whisper.deliveryReceiptQueue.pause(); // avoid flood of delivery receipts until we catch up
window.Whisper.Notifications.disable(); // avoid notification flood until empty
// To avoid a flood of operations before we catch up, we pause some queues.
profileKeyResponseQueue.pause();
window.Whisper.deliveryReceiptQueue.pause();
window.Whisper.Notifications.disable();
// initialize the socket and start listening for messages
window.log.info('Initializing socket and listening for messages');
@ -2046,6 +2117,13 @@ export async function startApp(): Promise<void> {
);
}
});
if (!challengeHandler) {
throw new Error('Expected challenge handler to be initialized');
}
// Intentionally not awaiting
challengeHandler.onOnline();
} finally {
connecting = false;
}
@ -2122,6 +2200,7 @@ export async function startApp(): Promise<void> {
newVersion
);
profileKeyResponseQueue.start();
window.Whisper.deliveryReceiptQueue.start();
window.Whisper.Notifications.enable();
@ -2193,6 +2272,7 @@ export async function startApp(): Promise<void> {
// scenarios where we're coming back from sleep, we can get offline/online events
// very fast, and it looks like a network blip. But we need to suppress
// notifications in these scenarios too. So we listen for 'reconnect' events.
profileKeyResponseQueue.pause();
window.Whisper.deliveryReceiptQueue.pause();
window.Whisper.Notifications.disable();
}
@ -2376,7 +2456,7 @@ export async function startApp(): Promise<void> {
// special case for syncing details about ourselves
if (details.profileKey) {
window.log.info('Got sync message with our own profile key');
window.storage.put('profileKey', details.profileKey);
ourProfileKeyService.set(details.profileKey);
}
}
@ -2603,6 +2683,30 @@ export async function startApp(): Promise<void> {
return confirm();
}
const respondWithProfileKeyBatcher = createBatcher<ConversationModel>({
name: 'respondWithProfileKeyBatcher',
processBatch(batch) {
const deduped = new Set(batch);
deduped.forEach(async sender => {
try {
if (!(await shouldRespondWithProfileKey(sender))) {
return;
}
} catch (error) {
window.log.error(
'respondWithProfileKeyBatcher error',
error && error.stack
);
}
sender.queueJob(() => sender.sendProfileKeyUpdate());
});
},
wait: 200,
maxSize: Infinity,
});
// Note: We do very little in this function, since everything in handleDataMessage is
// inside a conversation-specific queue(). Any code here might run before an earlier
// message is processed in handleDataMessage().
@ -2629,6 +2733,18 @@ export async function startApp(): Promise<void> {
const message = initIncomingMessage(data, messageDescriptor);
if (message.isIncoming() && message.get('unidentifiedDeliveryReceived')) {
const sender = message.getContact();
if (!sender) {
throw new Error('MessageModel has no sender.');
}
profileKeyResponseQueue.add(() => {
respondWithProfileKeyBatcher.add(sender);
});
}
if (data.message.reaction) {
window.normalizeUuids(
data.message.reaction,

485
ts/challenge.ts Normal file
View File

@ -0,0 +1,485 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable no-restricted-syntax */
// `ChallengeHandler` is responsible for:
// 1. tracking the messages that failed to send with 428 error and could be
// retried when user solves the challenge
// 2. presenting the challenge to user and sending the challenge response back
// to the server
//
// The tracked messages are persisted in the database, and are imported back
// to the `ChallengeHandler` on `.load()` call (from `ts/background.ts`). They
// are not immediately retried, however, until `.onOnline()` is called from
// when we are actually online.
import { MessageModel } from './models/messages';
import { assert } from './util/assert';
import { isNotNil } from './util/isNotNil';
import { isOlderThan } from './util/timestamp';
import { parseRetryAfter } from './util/parseRetryAfter';
import { getEnvironment, Environment } from './environment';
export type ChallengeResponse = {
readonly captcha: string;
};
export type IPCRequest = {
readonly seq: number;
};
export type IPCResponse = {
readonly seq: number;
readonly data: ChallengeResponse;
};
export enum RetryMode {
Retry = 'Retry',
NoImmediateRetry = 'NoImmediateRetry',
}
type Handler = {
readonly token: string | undefined;
resolve(response: ChallengeResponse): void;
reject(error: Error): void;
};
export type ChallengeData = {
readonly type: 'recaptcha';
readonly token: string;
readonly captcha: string;
};
export type MinimalMessage = Pick<
MessageModel,
'id' | 'idForLogging' | 'getLastChallengeError' | 'retrySend'
> & {
isNormalBubble(): boolean;
get(name: 'sent_at'): number;
on(event: 'sent', callback: () => void): void;
off(event: 'sent', callback: () => void): void;
};
export type Options = {
readonly storage: {
get(key: string): ReadonlyArray<StoredEntity>;
put(key: string, value: ReadonlyArray<StoredEntity>): Promise<void>;
};
requestChallenge(request: IPCRequest): void;
getMessageById(messageId: string): Promise<MinimalMessage | undefined>;
sendChallengeResponse(data: ChallengeData): Promise<void>;
setChallengeStatus(challengeStatus: 'idle' | 'required' | 'pending'): void;
onChallengeSolved(): void;
onChallengeFailed(retryAfter?: number): void;
expireAfter?: number;
};
export type StoredEntity = {
readonly messageId: string;
readonly createdAt: number;
};
type TrackedEntry = {
readonly message: MinimalMessage;
readonly createdAt: number;
};
const DEFAULT_EXPIRE_AFTER = 24 * 3600 * 1000; // one day
const MAX_RETRIES = 5;
const CAPTCHA_URL = 'https://signalcaptchas.org/challenge/generate.html';
const CAPTCHA_STAGING_URL =
'https://signalcaptchas.org/staging/challenge/generate.html';
function shouldRetrySend(message: MinimalMessage): boolean {
const error = message.getLastChallengeError();
if (!error || error.retryAfter <= Date.now()) {
return true;
}
return false;
}
export function getChallengeURL(): string {
if (getEnvironment() === Environment.Staging) {
return CAPTCHA_STAGING_URL;
}
return CAPTCHA_URL;
}
// Note that even though this is a class - only one instance of
// `ChallengeHandler` should be in memory at the same time because they could
// overwrite each others storage data.
export class ChallengeHandler {
private isLoaded = false;
private challengeToken: string | undefined;
private seq = 0;
private isOnline = false;
private readonly responseHandlers = new Map<number, Handler>();
private readonly trackedMessages = new Map<string, TrackedEntry>();
private readonly retryTimers = new Map<string, NodeJS.Timeout>();
private readonly pendingRetries = new Set<MinimalMessage>();
private readonly retryCountById = new Map<string, number>();
constructor(private readonly options: Options) {}
public async load(): Promise<void> {
if (this.isLoaded) {
return;
}
this.isLoaded = true;
const stored: ReadonlyArray<StoredEntity> =
this.options.storage.get('challenge:retry-message-ids') || [];
window.log.info(`challenge: loading ${stored.length} messages`);
const entityMap = new Map<string, StoredEntity>();
for (const entity of stored) {
entityMap.set(entity.messageId, entity);
}
const retryIds = new Set<string>(stored.map(({ messageId }) => messageId));
const maybeMessages: ReadonlyArray<
MinimalMessage | undefined
> = await Promise.all(
Array.from(retryIds).map(async messageId =>
this.options.getMessageById(messageId)
)
);
const messages: Array<MinimalMessage> = maybeMessages.filter(isNotNil);
window.log.info(`challenge: loaded ${messages.length} messages`);
await Promise.all(
messages.map(async message => {
const entity = entityMap.get(message.id);
if (!entity) {
window.log.error(
'challenge: unexpected missing entity ' +
`for ${message.idForLogging()}`
);
return;
}
const expireAfter = this.options.expireAfter || DEFAULT_EXPIRE_AFTER;
if (isOlderThan(entity.createdAt, expireAfter)) {
window.log.info(
`challenge: expired entity for ${message.idForLogging()}`
);
return;
}
// The initialization order is following:
//
// 1. `.load()` when the `window.storage` is ready
// 2. `.onOnline()` when we connected to the server
//
// Wait for `.onOnline()` to trigger the retries instead of triggering
// them here immediately (if the message is ready to be retried).
await this.register(message, RetryMode.NoImmediateRetry, entity);
})
);
}
public async onOffline(): Promise<void> {
this.isOnline = false;
window.log.info('challenge: offline');
}
public async onOnline(): Promise<void> {
this.isOnline = true;
const pending = Array.from(this.pendingRetries.values());
this.pendingRetries.clear();
window.log.info(`challenge: online, retrying ${pending.length} messages`);
// Retry messages that matured while we were offline
await Promise.all(pending.map(message => this.retryOne(message)));
await this.retrySend();
}
public async register(
message: MinimalMessage,
retry = RetryMode.Retry,
entity?: StoredEntity
): Promise<void> {
if (this.isRegistered(message)) {
window.log.info(
`challenge: message already registered ${message.idForLogging()}`
);
return;
}
this.trackedMessages.set(message.id, {
message,
createdAt: entity ? entity.createdAt : Date.now(),
});
await this.persist();
// Message is already retryable - initiate new send
if (retry === RetryMode.Retry && shouldRetrySend(message)) {
window.log.info(
`challenge: sending message immediately ${message.idForLogging()}`
);
await this.retryOne(message);
return;
}
const error = message.getLastChallengeError();
if (!error) {
window.log.error('Unexpected message without challenge error');
return;
}
const waitTime = Math.max(0, error.retryAfter - Date.now());
const oldTimer = this.retryTimers.get(message.id);
if (oldTimer) {
clearTimeout(oldTimer);
}
this.retryTimers.set(
message.id,
setTimeout(() => {
this.retryTimers.delete(message.id);
this.retryOne(message);
}, waitTime)
);
window.log.info(
`challenge: tracking ${message.idForLogging()} ` +
`with waitTime=${waitTime}`
);
if (!error.data.options || !error.data.options.includes('recaptcha')) {
window.log.error(
`challenge: unexpected options ${JSON.stringify(error.data.options)}`
);
}
if (!error.data.token) {
window.log.error(
`challenge: no token in challenge error ${JSON.stringify(error.data)}`
);
} else if (message.isNormalBubble()) {
// Display challenge dialog only for core messages
// (e.g. text, attachment, embedded contact, or sticker)
//
// Note: not waiting on this call intentionally since it waits for
// challenge to be fully completed.
this.solve(error.data.token);
} else {
window.log.info(
`challenge: not a bubble message ${message.idForLogging()}`
);
}
}
public onResponse(response: IPCResponse): void {
const handler = this.responseHandlers.get(response.seq);
if (!handler) {
return;
}
this.responseHandlers.delete(response.seq);
handler.resolve(response.data);
}
public async unregister(message: MinimalMessage): Promise<void> {
window.log.info(`challenge: unregistered ${message.idForLogging()}`);
this.trackedMessages.delete(message.id);
this.pendingRetries.delete(message);
const timer = this.retryTimers.get(message.id);
this.retryTimers.delete(message.id);
if (timer) {
clearTimeout(timer);
}
await this.persist();
}
private async persist(): Promise<void> {
assert(
this.isLoaded,
'ChallengeHandler has to be loaded before persisting new data'
);
await this.options.storage.put(
'challenge:retry-message-ids',
Array.from(this.trackedMessages.entries()).map(
([messageId, { createdAt }]) => {
return { messageId, createdAt };
}
)
);
}
private isRegistered(message: MinimalMessage): boolean {
return this.trackedMessages.has(message.id);
}
private async retrySend(force = false): Promise<void> {
window.log.info(`challenge: retrySend force=${force}`);
const retries = Array.from(this.trackedMessages.values())
.map(({ message }) => message)
// Sort messages in `sent_at` order
.sort((a, b) => a.get('sent_at') - b.get('sent_at'))
.filter(message => force || shouldRetrySend(message))
.map(message => this.retryOne(message));
await Promise.all(retries);
}
private async retryOne(message: MinimalMessage): Promise<void> {
// Send is already pending
if (!this.isRegistered(message)) {
return;
}
// We are not online
if (!this.isOnline) {
this.pendingRetries.add(message);
return;
}
const retryCount = this.retryCountById.get(message.id) || 0;
window.log.info(
`challenge: retrying sending ${message.idForLogging()}, ` +
`retry count: ${retryCount}`
);
if (retryCount === MAX_RETRIES) {
window.log.info(
`challenge: dropping message ${message.idForLogging()}, ` +
'too many failed retries'
);
// Keep the message registered so that we'll retry sending it on app
// restart.
return;
}
await this.unregister(message);
let sent = false;
const onSent = () => {
sent = true;
};
message.on('sent', onSent);
try {
await message.retrySend();
} catch (error) {
window.log.error(
`challenge: failed to send ${message.idForLogging()} due to ` +
`error: ${error && error.stack}`
);
} finally {
message.off('sent', onSent);
}
if (sent) {
window.log.info(`challenge: message ${message.idForLogging()} sent`);
this.retryCountById.delete(message.id);
if (this.trackedMessages.size === 0) {
this.options.setChallengeStatus('idle');
}
} else {
window.log.info(`challenge: message ${message.idForLogging()} not sent`);
this.retryCountById.set(message.id, retryCount + 1);
await this.register(message, RetryMode.NoImmediateRetry);
}
}
private async solve(token: string): Promise<void> {
const request: IPCRequest = { seq: this.seq };
this.seq += 1;
this.options.setChallengeStatus('required');
this.options.requestChallenge(request);
this.challengeToken = token || '';
const response = await new Promise<ChallengeResponse>((resolve, reject) => {
this.responseHandlers.set(request.seq, { token, resolve, reject });
});
// Another `.solve()` has completed earlier than us
if (this.challengeToken === undefined) {
return;
}
const lastToken = this.challengeToken;
this.challengeToken = undefined;
this.options.setChallengeStatus('pending');
window.log.info('challenge: sending challenge to server');
try {
await this.sendChallengeResponse({
type: 'recaptcha',
token: lastToken,
captcha: response.captcha,
});
} catch (error) {
window.log.error(
`challenge: challenge failure, error: ${error && error.stack}`
);
this.options.setChallengeStatus('required');
return;
}
window.log.info('challenge: challenge success. force sending');
this.options.setChallengeStatus('idle');
this.retrySend(true);
}
private async sendChallengeResponse(data: ChallengeData): Promise<void> {
try {
await this.options.sendChallengeResponse(data);
} catch (error) {
if (
!(error instanceof Error) ||
error.name !== 'HTTPError' ||
error.code !== 413 ||
!error.responseHeaders
) {
this.options.onChallengeFailed();
throw error;
}
const retryAfter = parseRetryAfter(
error.responseHeaders['retry-after'].toString()
);
window.log.info(`challenge: retry after ${retryAfter}ms`);
this.options.onChallengeFailed(retryAfter);
return;
}
this.options.onChallengeSolved();
}
}

View File

@ -1,13 +1,14 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { isBoolean } from 'lodash';
import { storiesOf } from '@storybook/react';
import { boolean, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { Avatar, Props } from './Avatar';
import { Avatar, AvatarBlur, Props } from './Avatar';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { Colors, ColorType } from '../types/Colors';
@ -30,7 +31,11 @@ const conversationTypeMap: Record<string, Props['conversationType']> = {
};
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
acceptedMessageRequest: isBoolean(overrideProps.acceptedMessageRequest)
? overrideProps.acceptedMessageRequest
: true,
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
blur: overrideProps.blur,
color: select('color', colorMap, overrideProps.color || 'blue'),
conversationType: select(
'conversationType',
@ -38,13 +43,15 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.conversationType || 'direct'
),
i18n,
isMe: false,
loading: boolean('loading', overrideProps.loading || false),
name: text('name', overrideProps.name || ''),
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onClick: action('onClick'),
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
sharedGroupNames: [],
size: 80,
title: '',
title: overrideProps.title || '',
});
const sizes: Array<Props['size']> = [112, 96, 80, 52, 32, 28];
@ -57,17 +64,41 @@ story.add('Avatar', () => {
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('One-word Name', () => {
story.add('Wide image', () => {
const props = createProps({
name: 'John',
avatarPath: '/fixtures/wide.jpg',
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Multi-word Name', () => {
story.add('One-word Name', () => {
const props = createProps({
name: 'John Smith',
title: 'John',
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Two-word Name', () => {
const props = createProps({
title: 'John Smith',
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Wide initials', () => {
const props = createProps({
title: 'Walter White',
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Three-word name', () => {
const props = createProps({
title: 'Walter H. White',
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
@ -133,3 +164,30 @@ story.add('Loading', () => {
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Blurred based on props', () => {
const props = createProps({
acceptedMessageRequest: false,
avatarPath: '/fixtures/kitten-3-64-64.jpg',
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Force-blurred', () => {
const props = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
blur: AvatarBlur.BlurPicture,
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Blurred with "click to view"', () => {
const props = createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
blur: AvatarBlur.BlurPictureWithClickToView,
});
return <Avatar {...props} size={112} />;
});

View File

@ -1,14 +1,29 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import React, {
FunctionComponent,
ReactNode,
useEffect,
useState,
} from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
import { Spinner } from './Spinner';
import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
import * as log from '../logging/log';
import { assert } from '../util/assert';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
export enum AvatarBlur {
NoBlur,
BlurPicture,
BlurPictureWithClickToView,
}
export enum AvatarSize {
TWENTY_EIGHT = 28,
@ -21,16 +36,21 @@ export enum AvatarSize {
export type Props = {
avatarPath?: string;
blur?: AvatarBlur;
color?: ColorType;
loading?: boolean;
acceptedMessageRequest: boolean;
conversationType: 'group' | 'direct';
noteToSelf?: boolean;
title: string;
isMe: boolean;
name?: string;
noteToSelf?: boolean;
phoneNumber?: string;
profileName?: string;
sharedGroupNames: Array<string>;
size: AvatarSize;
title: string;
unblurredAvatarPath?: string;
onClick?: () => unknown;
@ -40,111 +60,68 @@ export type Props = {
i18n: LocalizerType;
} & Pick<React.HTMLProps<HTMLDivElement>, 'className'>;
type State = {
readonly imageBroken: boolean;
readonly lastAvatarPath?: string;
};
const getDefaultBlur = (
...args: Parameters<typeof shouldBlurAvatar>
): AvatarBlur =>
shouldBlurAvatar(...args) ? AvatarBlur.BlurPicture : AvatarBlur.NoBlur;
export class Avatar extends React.Component<Props, State> {
public handleImageErrorBound: () => void;
export const Avatar: FunctionComponent<Props> = ({
acceptedMessageRequest,
avatarPath,
className,
color,
conversationType,
i18n,
isMe,
innerRef,
loading,
noteToSelf,
onClick,
sharedGroupNames,
size,
title,
unblurredAvatarPath,
blur = getDefaultBlur({
acceptedMessageRequest,
avatarPath,
isMe,
sharedGroupNames,
unblurredAvatarPath,
}),
}) => {
const [imageBroken, setImageBroken] = useState(false);
public constructor(props: Props) {
super(props);
useEffect(() => {
setImageBroken(false);
}, [avatarPath]);
this.handleImageErrorBound = this.handleImageError.bind(this);
useEffect(() => {
if (!avatarPath) {
return noop;
}
this.state = {
lastAvatarPath: props.avatarPath,
imageBroken: false,
const image = new Image();
image.src = avatarPath;
image.onerror = () => {
log.warn('Avatar: Image failed to load; failing over to placeholder');
setImageBroken(true);
};
}
public static getDerivedStateFromProps(props: Props, state: State): State {
if (props.avatarPath !== state.lastAvatarPath) {
return {
...state,
lastAvatarPath: props.avatarPath,
imageBroken: false,
};
}
return () => {
image.onerror = noop;
};
}, [avatarPath]);
return state;
}
const initials = getInitials(title);
const hasImage = !noteToSelf && avatarPath && !imageBroken;
const shouldUseInitials =
!hasImage && conversationType === 'direct' && Boolean(initials);
public handleImageError(): void {
window.log.info(
'Avatar: Image failed to load; failing over to placeholder'
);
this.setState({
imageBroken: true,
});
}
public renderImage(): JSX.Element | null {
const { avatarPath, i18n, title } = this.props;
const { imageBroken } = this.state;
if (!avatarPath || imageBroken) {
return null;
}
return (
<img
onError={this.handleImageErrorBound}
alt={i18n('contactAvatarAlt', [title])}
src={avatarPath}
/>
);
}
public renderNoImage(): JSX.Element {
const { conversationType, noteToSelf, size, title } = this.props;
const initials = getInitials(title);
const isGroup = conversationType === 'group';
if (noteToSelf) {
return (
<div
className={classNames(
'module-avatar__icon',
'module-avatar__icon--note-to-self',
`module-avatar__icon--${size}`
)}
/>
);
}
if (!isGroup && initials) {
return (
<div
className={classNames(
'module-avatar__label',
`module-avatar__label--${size}`
)}
>
{initials}
</div>
);
}
return (
<div
className={classNames(
'module-avatar__icon',
`module-avatar__icon--${conversationType}`,
`module-avatar__icon--${size}`
)}
/>
);
}
public renderLoading(): JSX.Element {
const { size } = this.props;
let contents: ReactNode;
if (loading) {
const svgSize = size < 40 ? 'small' : 'normal';
return (
<div className="module-avatar__spinner-container">
contents = (
<div className="module-Avatar__spinner-container">
<Spinner
size={`${size - 8}px`}
svgSize={svgSize}
@ -152,58 +129,88 @@ export class Avatar extends React.Component<Props, State> {
/>
</div>
);
}
} else if (hasImage) {
assert(avatarPath, 'avatarPath should be defined here');
public render(): JSX.Element {
const {
avatarPath,
color,
innerRef,
loading,
noteToSelf,
onClick,
size,
className,
} = this.props;
const { imageBroken } = this.state;
assert(
blur !== AvatarBlur.BlurPictureWithClickToView || size >= 100,
'Rendering "click to view" for a small avatar. This may not render correctly'
);
const hasImage = !noteToSelf && avatarPath && !imageBroken;
if (![28, 32, 52, 80, 96, 112].includes(size)) {
throw new Error(`Size ${size} is not supported!`);
}
let contents;
if (loading) {
contents = this.renderLoading();
} else if (onClick) {
contents = (
<button
type="button"
className="module-avatar-button"
onClick={onClick}
>
{hasImage ? this.renderImage() : this.renderNoImage()}
</button>
);
} else {
contents = hasImage ? this.renderImage() : this.renderNoImage();
}
return (
const isBlurred =
blur === AvatarBlur.BlurPicture ||
blur === AvatarBlur.BlurPictureWithClickToView;
contents = (
<>
<div
className="module-Avatar__image"
style={{
backgroundImage: `url('${encodeURI(avatarPath)}')`,
...(isBlurred ? { filter: `blur(${Math.ceil(size / 2)}px)` } : {}),
}}
/>
{blur === AvatarBlur.BlurPictureWithClickToView && (
<div className="module-Avatar__click-to-view">{i18n('view')}</div>
)}
</>
);
} else if (noteToSelf) {
contents = (
<div
className={classNames(
'module-avatar',
`module-avatar--${size}`,
hasImage ? 'module-avatar--with-image' : 'module-avatar--no-image',
!hasImage ? `module-avatar--${color}` : null,
className
'module-Avatar__icon',
'module-Avatar__icon--note-to-self'
)}
ref={innerRef}
/>
);
} else if (shouldUseInitials) {
contents = (
<div
aria-hidden="true"
className="module-Avatar__label"
style={{ fontSize: Math.ceil(size * 0.5) }}
>
{contents}
{initials}
</div>
);
} else {
contents = (
<div
className={classNames(
'module-Avatar__icon',
`module-Avatar__icon--${conversationType}`
)}
/>
);
}
}
if (onClick) {
contents = (
<button className="module-Avatar__button" type="button" onClick={onClick}>
{contents}
</button>
);
}
return (
<div
aria-label={i18n('contactAvatarAlt', [title])}
className={classNames(
'module-Avatar',
hasImage ? 'module-Avatar--with-image' : 'module-Avatar--no-image',
{
[`module-Avatar--${color}`]: !hasImage,
},
className
)}
style={{
minWidth: size,
width: size,
height: size,
}}
ref={innerRef}
>
{contents}
</div>
);
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -28,6 +28,7 @@ const conversationTypeMap: Record<string, Props['conversationType']> = {
};
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
acceptedMessageRequest: true,
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
color: select('color', colorMap, overrideProps.color || 'blue'),
conversationType: select(
@ -36,6 +37,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.conversationType || 'direct'
),
i18n,
isMe: true,
name: text('name', overrideProps.name || ''),
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onClick: action('onClick'),
@ -43,6 +45,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
onViewPreferences: action('onViewPreferences'),
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
profileName: text('profileName', overrideProps.profileName || ''),
sharedGroupNames: [],
size: 80,
style: {},
title: text('title', overrideProps.title || ''),

View File

@ -5,30 +5,39 @@ import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Button, ButtonVariant } from './Button';
import { Button, ButtonSize, ButtonVariant } from './Button';
const story = storiesOf('Components/Button', module);
story.add('Kitchen sink', () => (
<>
{[
ButtonVariant.Primary,
ButtonVariant.Secondary,
ButtonVariant.SecondaryAffirmative,
ButtonVariant.SecondaryDestructive,
ButtonVariant.Destructive,
].map(variant => (
<React.Fragment key={variant}>
<p>
<Button onClick={action('onClick')} variant={variant}>
Hello world
</Button>
</p>
<p>
<Button disabled onClick={action('onClick')} variant={variant}>
Hello world
</Button>
</p>
{[ButtonSize.Medium, ButtonSize.Small].map(size => (
<React.Fragment key={size}>
{[
ButtonVariant.Primary,
ButtonVariant.Secondary,
ButtonVariant.SecondaryAffirmative,
ButtonVariant.SecondaryDestructive,
ButtonVariant.Destructive,
].map(variant => (
<React.Fragment key={variant}>
<p>
<Button onClick={action('onClick')} size={size} variant={variant}>
Hello world
</Button>
</p>
<p>
<Button
disabled
onClick={action('onClick')}
size={size}
variant={variant}
>
Hello world
</Button>
</p>
</React.Fragment>
))}
</React.Fragment>
))}
</>

View File

@ -6,6 +6,11 @@ import classNames from 'classnames';
import { assert } from '../util/assert';
export enum ButtonSize {
Medium,
Small,
}
export enum ButtonVariant {
Primary,
Secondary,
@ -17,6 +22,7 @@ export enum ButtonVariant {
type PropsType = {
className?: string;
disabled?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
} & (
| {
@ -41,6 +47,11 @@ type PropsType = {
}
);
const SIZE_CLASS_NAMES = new Map<ButtonSize, string>([
[ButtonSize.Medium, 'module-Button--medium'],
[ButtonSize.Small, 'module-Button--small'],
]);
const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
[ButtonVariant.Primary, 'module-Button--primary'],
[ButtonVariant.Secondary, 'module-Button--secondary'],
@ -61,6 +72,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
children,
className,
disabled = false,
size = ButtonSize.Medium,
variant = ButtonVariant.Primary,
} = props;
const ariaLabel = props['aria-label'];
@ -75,13 +87,21 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
({ type } = props);
}
const sizeClassName = SIZE_CLASS_NAMES.get(size);
assert(sizeClassName, '<Button> size not found');
const variantClassName = VARIANT_CLASS_NAMES.get(variant);
assert(variantClassName, '<Button> variant not found');
return (
<button
aria-label={ariaLabel}
className={classNames('module-Button', variantClassName, className)}
className={classNames(
'module-Button',
sizeClassName,
variantClassName,
className
)}
disabled={disabled}
onClick={onClick}
ref={ref}

View File

@ -24,18 +24,19 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const getConversation = () => ({
id: '3051234567',
avatarPath: undefined,
color: select('Callee color', Colors, 'ultramarine' as ColorType),
title: text('Callee Title', 'Rick Sanchez'),
name: text('Callee Name', 'Rick Sanchez'),
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct' as ConversationTypeType,
lastUpdated: Date.now(),
});
const getConversation = () =>
getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: select('Callee color', Colors, 'ultramarine' as ColorType),
title: text('Callee Title', 'Rick Sanchez'),
name: text('Callee Name', 'Rick Sanchez'),
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct' as ConversationTypeType,
lastUpdated: Date.now(),
});
const getCommonActiveCallData = () => ({
conversation: getConversation(),

View File

@ -6,17 +6,22 @@ import { LocalizerType } from '../types/Util';
import { Avatar } from './Avatar';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
import { ColorType } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
type Props = {
conversation: {
avatarPath?: string;
color?: ColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
conversation: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
>;
i18n: LocalizerType;
close: () => void;
};
@ -39,15 +44,18 @@ export const CallNeedPermissionScreen: React.FC<Props> = ({
return (
<div className="module-call-need-permission-screen">
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPath={conversation.avatarPath}
color={conversation.color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe={conversation.isMe}
name={conversation.name}
phoneNumber={conversation.phoneNumber}
profileName={conversation.profileName}
title={conversation.title}
sharedGroupNames={conversation.sharedGroupNames}
size={112}
/>

View File

@ -28,7 +28,7 @@ const MAX_PARTICIPANTS = 32;
const i18n = setupI18n('en', enMessages);
const conversation = {
const conversation = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: Colors[0],
@ -36,10 +36,7 @@ const conversation = {
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
});
type OverridePropsBase = {
hasLocalAudio?: boolean;

View File

@ -273,15 +273,20 @@ export const CallScreen: React.FC<PropsType> = ({
<div className="module-ongoing-call__local-preview-fullsize">
<CallBackgroundBlur avatarPath={me.avatarPath} color={me.color}>
<Avatar
acceptedMessageRequest
avatarPath={me.avatarPath}
color={me.color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe
name={me.name}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}
// `sharedGroupNames` makes no sense for yourself, but `<Avatar>` needs it
// to determine blurring.
sharedGroupNames={[]}
size={80}
/>
<div className="module-calling__video-off--container">
@ -336,15 +341,19 @@ export const CallScreen: React.FC<PropsType> = ({
{!hasLocalVideo && !isLonelyInGroup ? (
<CallBackgroundBlur avatarPath={me.avatarPath} color={me.color}>
<Avatar
acceptedMessageRequest
avatarPath={me.avatarPath}
color={me.color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe
name={me.name}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}
// See comment above about `sharedGroupNames`.
sharedGroupNames={[]}
size={80}
/>
</CallBackgroundBlur>

View File

@ -93,12 +93,17 @@ export const CallingParticipantsList = React.memo(
>
<div>
<Avatar
acceptedMessageRequest={
participant.acceptedMessageRequest
}
avatarPath={participant.avatarPath}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={32}
/>
{participant.uuid === ourUuid ? (

View File

@ -7,7 +7,7 @@ import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { ColorType } from '../types/Colors';
import { ConversationTypeType } from '../state/ducks/conversations';
import { ConversationType } from '../state/ducks/conversations';
import { CallingPip, PropsType } from './CallingPip';
import {
ActiveCallType,
@ -16,13 +16,14 @@ import {
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const conversation = {
const conversation: ConversationType = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: 'ultramarine' as ColorType,
@ -30,10 +31,7 @@ const conversation = {
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct' as ConversationTypeType,
lastUpdated: Date.now(),
};
});
const getCommonActiveCallData = () => ({
conversation,
@ -73,7 +71,7 @@ story.add('Default', () => {
return <CallingPip {...props} />;
});
story.add('Contact (with avatar)', () => {
story.add('Contact (with avatar and no video)', () => {
const props = createProps({
activeCall: {
...defaultCall,
@ -81,6 +79,7 @@ story.add('Contact (with avatar)', () => {
...conversation,
avatarPath: 'https://www.fillmurray.com/64/64',
},
remoteParticipants: [{ hasRemoteVideo: false }],
},
});
return <CallingPip {...props} />;

View File

@ -32,11 +32,14 @@ const NoVideo = ({
i18n: LocalizerType;
}): JSX.Element => {
const {
acceptedMessageRequest,
avatarPath,
color,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
} = activeCall.conversation;
@ -45,16 +48,19 @@ const NoVideo = ({
<CallBackgroundBlur avatarPath={avatarPath} color={color}>
<div className="module-calling-pip__video--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={52}
sharedGroupNames={sharedGroupNames}
/>
</div>
</CallBackgroundBlur>

View File

@ -0,0 +1,33 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { CaptchaDialog } from './CaptchaDialog';
import { Button } from './Button';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const story = storiesOf('Components/CaptchaDialog', module);
const i18n = setupI18n('en', enMessages);
story.add('CaptchaDialog', () => {
const [isSkipped, setIsSkipped] = useState(false);
if (isSkipped) {
return <Button onClick={() => setIsSkipped(false)}>Show again</Button>;
}
return (
<CaptchaDialog
i18n={i18n}
isPending={boolean('isPending', false)}
onContinue={action('onContinue')}
onSkip={() => setIsSkipped(true)}
/>
);
});

View File

@ -0,0 +1,99 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useState } from 'react';
import { LocalizerType } from '../types/Util';
import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { Spinner } from './Spinner';
type PropsType = {
i18n: LocalizerType;
isPending: boolean;
onContinue: () => void;
onSkip: () => void;
};
export function CaptchaDialog(props: Readonly<PropsType>): JSX.Element {
const { i18n, isPending, onSkip, onContinue } = props;
const [isClosing, setIsClosing] = useState(false);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const onCancelClick = (event: React.MouseEvent) => {
event.preventDefault();
setIsClosing(false);
};
const onSkipClick = (event: React.MouseEvent) => {
event.preventDefault();
onSkip();
};
if (isClosing && !isPending) {
return (
<Modal
moduleClassName="module-Modal"
i18n={i18n}
title={i18n('CaptchaDialog--can-close__title')}
>
<section>
<p>{i18n('CaptchaDialog--can-close__body')}</p>
</section>
<Modal.Footer>
<Button onClick={onCancelClick} variant={ButtonVariant.Secondary}>
{i18n('cancel')}
</Button>
<Button onClick={onSkipClick} variant={ButtonVariant.Destructive}>
{i18n('CaptchaDialog--can_close__skip-verification')}
</Button>
</Modal.Footer>
</Modal>
);
}
const onContinueClick = (event: React.MouseEvent) => {
event.preventDefault();
onContinue();
};
const updateButtonRef = (button: HTMLButtonElement): void => {
buttonRef.current = button;
if (button) {
button.focus();
}
};
return (
<Modal
moduleClassName="module-Modal--important"
i18n={i18n}
title={i18n('CaptchaDialog__title')}
hasXButton
onClose={() => setIsClosing(true)}
>
<section>
<p>{i18n('CaptchaDialog__first-paragraph')}</p>
<p>{i18n('CaptchaDialog__second-paragraph')}</p>
</section>
<Modal.Footer>
<Button
disabled={isPending}
onClick={onContinueClick}
ref={updateButtonRef}
variant={ButtonVariant.Primary}
>
{isPending ? (
<Spinner size="22px" svgSize="small" direction="on-captcha" />
) : (
'Continue'
)}
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -8,6 +8,7 @@ import { boolean, select } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { CompositionInput, Props } from './CompositionInput';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
@ -104,20 +105,12 @@ story.add('Emojis', () => {
story.add('Mentions', () => {
const props = createProps({
sortedGroupMembers: [
{
id: '0',
type: 'direct',
lastUpdated: 0,
getDefaultConversation({
title: 'Kate Beaton',
markedUnread: false,
},
{
id: '0',
type: 'direct',
lastUpdated: 0,
}),
getDefaultConversation({
title: 'Parry Gripp',
markedUnread: false,
},
}),
],
draftText: 'send _ a message',
draftBodyRanges: [

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -18,12 +18,15 @@ storiesOf('Components/ContactListItem', module)
.add("It's me!", () => {
return (
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe
title="Someone 🔥 Somewhere"
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
sharedGroupNames={[]}
avatarPath={gifUrl}
onClick={onClick}
/>
@ -33,21 +36,29 @@ storiesOf('Components/ContactListItem', module)
return (
<div>
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
title="Someone 🔥 Somewhere"
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
sharedGroupNames={[]}
about="👍 Free to chat"
avatarPath={gifUrl}
onClick={onClick}
/>
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
title="Another ❄️ Yes"
name="Another ❄️ Yes"
phoneNumber="(202) 555-0011"
profileName="❄Ice❄"
sharedGroupNames={[]}
about="🙏 Be kind"
avatarPath={gifUrl}
onClick={onClick}
@ -58,25 +69,48 @@ storiesOf('Components/ContactListItem', module)
.add('With name and profile, admin', () => {
return (
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
isAdmin
title="Someone 🔥 Somewhere"
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
sharedGroupNames={[]}
about="👍 This is my really long status message that I have in order to test line breaking"
avatarPath={gifUrl}
onClick={onClick}
/>
);
})
.add('With a group with no avatarPath', () => {
return (
<ContactListItem
type="group"
i18n={i18n}
isMe={false}
isAdmin
title="Group!"
sharedGroupNames={[]}
acceptedMessageRequest
about="👍 Free to chat"
onClick={onClick}
/>
);
})
.add('With just number, admin', () => {
return (
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
isAdmin
title="(202) 555-0011"
phoneNumber="(202) 555-0011"
sharedGroupNames={[]}
about="👍 Free to chat"
avatarPath={gifUrl}
onClick={onClick}
@ -86,12 +120,16 @@ storiesOf('Components/ContactListItem', module)
.add('With name and profile, no avatar', () => {
return (
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
title="Someone 🔥 Somewhere"
name="Someone 🔥 Somewhere"
color="teal"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
sharedGroupNames={[]}
about="👍 Free to chat"
onClick={onClick}
/>
@ -100,10 +138,15 @@ storiesOf('Components/ContactListItem', module)
.add('Profile, no name, no avatar', () => {
return (
<ContactListItem
type="direct"
acceptedMessageRequest
color="blue"
i18n={i18n}
isMe={false}
phoneNumber="(202) 555-0011"
title="🔥Flames🔥"
profileName="🔥Flames🔥"
sharedGroupNames={[]}
about="👍 Free to chat"
onClick={onClick}
/>
@ -112,8 +155,12 @@ storiesOf('Components/ContactListItem', module)
.add('No name, no profile, no avatar, no about', () => {
return (
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
phoneNumber="(202) 555-0011"
sharedGroupNames={[]}
title="(202) 555-0011"
onClick={onClick}
/>
@ -122,9 +169,13 @@ storiesOf('Components/ContactListItem', module)
.add('No name, no profile, no avatar', () => {
return (
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
title="(202) 555-0011"
about="👍 Free to chat"
sharedGroupNames={[]}
phoneNumber="(202) 555-0011"
onClick={onClick}
/>
@ -132,6 +183,14 @@ storiesOf('Components/ContactListItem', module)
})
.add('No name, no profile, no number', () => {
return (
<ContactListItem i18n={i18n} title="Unknown contact" onClick={onClick} />
<ContactListItem
type="direct"
acceptedMessageRequest
i18n={i18n}
isMe={false}
title="Unknown contact"
sharedGroupNames={[]}
onClick={onClick}
/>
);
});

View File

@ -10,45 +10,60 @@ import { Emojify } from './conversation/Emojify';
import { InContactsIcon } from './InContactsIcon';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
type Props = {
about?: string;
avatarPath?: string;
color?: ColorType;
i18n: LocalizerType;
isAdmin?: boolean;
isMe?: boolean;
name?: string;
onClick?: () => void;
phoneNumber?: string;
profileName?: string;
title: string;
};
} & Pick<
ConversationType,
| 'about'
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarPath'
>;
export class ContactListItem extends React.Component<Props> {
public renderAvatar(): JSX.Element {
const {
acceptedMessageRequest,
avatarPath,
i18n,
color,
i18n,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
type,
unblurredAvatarPath,
} = this.props;
return (
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType="direct"
conversationType={type}
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={52}
unblurredAvatarPath={unblurredAvatarPath}
/>
);
}

View File

@ -3,35 +3,45 @@
import React, { FunctionComponent } from 'react';
import { ColorType } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
import { LocalizerType } from '../types/Util';
import { ContactName } from './conversation/ContactName';
import { Avatar, AvatarSize } from './Avatar';
export type PropsType = {
avatarPath?: string;
color?: ColorType;
firstName?: string;
i18n: LocalizerType;
id: string;
isMe?: boolean;
name?: string;
onClickRemove: (id: string) => void;
phoneNumber?: string;
profileName?: string;
title: string;
};
} & Pick<
ConversationType,
| 'about'
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'firstName'
| 'id'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
>;
export const ContactPill: FunctionComponent<PropsType> = ({
acceptedMessageRequest,
avatarPath,
color,
firstName,
i18n,
isMe,
id,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
onClickRemove,
}) => {
const removeLabel = i18n('ContactPill--remove');
@ -39,16 +49,20 @@ export const ContactPill: FunctionComponent<PropsType> = ({
return (
<div className="module-ContactPill">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
unblurredAvatarPath={unblurredAvatarPath}
/>
<ContactName
firstName={firstName}

View File

@ -12,6 +12,7 @@ import enMessages from '../../_locales/en/messages.json';
import { ContactPills } from './ContactPills';
import { ContactPill, PropsType as ContactPillPropsType } from './ContactPill';
import { gifUrl } from '../storybook/Fixtures';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
@ -19,30 +20,32 @@ const story = storiesOf('Components/Contact Pills', module);
type ContactType = Omit<ContactPillPropsType, 'i18n' | 'onClickRemove'>;
const contacts: Array<ContactType> = times(50, index => ({
color: 'red',
id: `contact-${index}`,
isMe: false,
name: `Contact ${index}`,
phoneNumber: '(202) 555-0001',
profileName: `C${index}`,
title: `Contact ${index}`,
}));
const contacts: Array<ContactType> = times(50, index =>
getDefaultConversation({
color: 'red',
id: `contact-${index}`,
name: `Contact ${index}`,
phoneNumber: '(202) 555-0001',
profileName: `C${index}`,
title: `Contact ${index}`,
})
);
const contactPillProps = (
overrideProps?: ContactType
): ContactPillPropsType => ({
...(overrideProps || {
avatarPath: gifUrl,
color: 'red',
firstName: 'John',
id: 'abc123',
isMe: false,
name: 'John Bon Bon Jovi',
phoneNumber: '(202) 555-0001',
profileName: 'JohnB',
title: 'John Bon Bon Jovi',
}),
...(overrideProps ||
getDefaultConversation({
avatarPath: gifUrl,
color: 'red',
firstName: 'John',
id: 'abc123',
isMe: false,
name: 'John Bon Bon Jovi',
phoneNumber: '(202) 555-0001',
profileName: 'JohnB',
title: 'John Bon Bon Jovi',
})),
i18n,
onClickRemove: action('onClickRemove'),
});

View File

@ -24,32 +24,21 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/ConversationList', module);
const defaultConversations: Array<ConversationListItemPropsType> = [
{
getDefaultConversation({
id: 'fred-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Fred Willard',
type: 'direct',
},
{
}),
getDefaultConversation({
id: 'marc-convo',
isSelected: true,
lastUpdated: Date.now(),
markedUnread: false,
unreadCount: 12,
title: 'Marc Barraca',
type: 'direct',
},
{
}),
getDefaultConversation({
id: 'long-name-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title:
'Pablo Diego José Francisco de Paula Juan Nepomuceno María de los Remedios Cipriano de la Santísima Trinidad Ruiz y Picasso',
type: 'direct',
},
}),
getDefaultConversation(),
];
@ -247,6 +236,7 @@ story.add('Contact checkboxes: disabled', () => (
'lastUpdated',
new Date(overrideProps.lastUpdated || Date.now() - 5 * 60 * 1000)
),
sharedGroupNames: [],
});
const renderConversation = (

View File

@ -4,7 +4,6 @@
import React, { useRef, useEffect } from 'react';
import { SetRendererCanvasType } from '../state/ducks/calling';
import { ConversationType } from '../state/ducks/conversations';
import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { Avatar } from './Avatar';
@ -43,33 +42,43 @@ export const DirectCallRemoteParticipant: React.FC<PropsType> = ({
function renderAvatar(
i18n: LocalizerType,
{
acceptedMessageRequest,
avatarPath,
color,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
}: {
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
}
}: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>
): JSX.Element {
return (
<div className="module-ongoing-call__remote-video-disabled">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={112}
/>
</div>

View File

@ -250,13 +250,11 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
</button>
) : (
<button
aria-label={i18n('cancel')}
className="module-ForwardMessageModal__header--cancel"
aria-label={i18n('close')}
className="module-ForwardMessageModal__header--close"
onClick={onClose}
type="button"
>
{i18n('cancel')}
</button>
/>
)}
<h1>{i18n('forwardMessage')}</h1>
</div>

View File

@ -54,13 +54,16 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
const { getFrameBuffer, getGroupCallVideoFrameSource, i18n } = props;
const {
acceptedMessageRequest,
avatarPath,
color,
demuxId,
hasRemoteAudio,
hasRemoteVideo,
isBlocked,
isMe,
profileName,
sharedGroupNames,
title,
videoAspectRatio,
} = props.remoteParticipant;
@ -285,13 +288,16 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
</>
) : (
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe={isMe}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={avatarSize}
/>
)}

View File

@ -99,8 +99,15 @@ GroupDialog.Contacts = ({ contacts, i18n }: Readonly<ContactsPropsType>) => (
{contacts.map(contact => (
<li key={contact.id} className="module-GroupDialog__contacts__contact">
<Avatar
{...contact}
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
color={contact.color}
conversationType={contact.type}
isMe={contact.isMe}
noteToSelf={contact.isMe}
title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath}
sharedGroupNames={contact.sharedGroupNames}
size={AvatarSize.TWENTY_EIGHT}
i18n={i18n}
/>

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -8,37 +8,30 @@ import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { GroupV1MigrationDialog, PropsType } from './GroupV1MigrationDialog';
import { ConversationType } from '../state/ducks/conversations';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const contact1 = {
const contact1: ConversationType = getDefaultConversation({
title: 'Alice',
number: '+1 (300) 555-0000',
phoneNumber: '+1 (300) 555-0000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
});
const contact2 = {
const contact2: ConversationType = getDefaultConversation({
title: 'Bob',
number: '+1 (300) 555-0001',
phoneNumber: '+1 (300) 555-0001',
id: 'guid-2',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
});
const contact3 = {
const contact3: ConversationType = getDefaultConversation({
title: 'Chet',
number: '+1 (300) 555-0002',
phoneNumber: '+1 (300) 555-0002',
id: 'guid-3',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
});
function booleanOr(value: boolean | undefined, defaultValue: boolean): boolean {
return isBoolean(value) ? value : defaultValue;

View File

@ -4,7 +4,7 @@
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { Avatar } from './Avatar';
import { Avatar, AvatarBlur } from './Avatar';
import { Spinner } from './Spinner';
import { Button, ButtonVariant } from './Button';
@ -77,10 +77,14 @@ export const GroupV2JoinDialog = React.memo((props: PropsType) => {
/>
<div className="module-group-v2-join-dialog__avatar">
<Avatar
acceptedMessageRequest={false}
avatarPath={avatar ? avatar.url : undefined}
blur={AvatarBlur.NoBlur}
loading={avatar && !avatar.url}
conversationType="group"
title={title}
isMe={false}
sharedGroupNames={[]}
size={80}
i18n={i18n}
/>

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -10,6 +10,7 @@ import { IncomingCallBar } from './IncomingCallBar';
import { Colors, ColorType } from '../types/Colors';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
@ -21,15 +22,15 @@ const defaultProps = {
isIncoming: true,
isVideoCall: true,
},
conversation: {
conversation: getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
contactColor: 'ultramarine' as ColorType,
color: 'ultramarine' as ColorType,
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
title: 'Rick Sanchez',
},
}),
declineCall: action('decline-call'),
i18n,
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -7,7 +7,7 @@ import { Tooltip } from './Tooltip';
import { Theme } from '../util/theme';
import { ContactName } from './conversation/ContactName';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
import { AcceptCallType, DeclineCallType } from '../state/ducks/calling';
export type PropsType = {
@ -17,15 +17,19 @@ export type PropsType = {
call: {
isVideoCall: boolean;
};
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
conversation: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
};
type CallButtonProps = {
@ -66,12 +70,15 @@ export const IncomingCallBar = ({
const { isVideoCall } = call;
const {
id: conversationId,
acceptedMessageRequest,
avatarPath,
color,
title,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
} = conversation;
return (
@ -79,15 +86,18 @@ export const IncomingCallBar = ({
<div className="module-incoming-call__contact">
<div className="module-incoming-call__contact--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={52}
/>
</div>

View File

@ -4,86 +4,67 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { select } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { LeftPane, LeftPaneMode, PropsType } from './LeftPane';
import { PropsData as ConversationListItemPropsType } from './conversationList/ConversationListItem';
import { CaptchaDialog } from './CaptchaDialog';
import { ConversationType } from '../state/ducks/conversations';
import { MessageSearchResult } from './conversationList/MessageSearchResult';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/LeftPane', module);
const defaultConversations: Array<ConversationListItemPropsType> = [
{
const defaultConversations: Array<ConversationType> = [
getDefaultConversation({
id: 'fred-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Fred Willard',
type: 'direct',
},
{
}),
getDefaultConversation({
id: 'marc-convo',
isSelected: true,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Marc Barraca',
type: 'direct',
},
}),
];
const defaultGroups: Array<ConversationListItemPropsType> = [
{
const defaultGroups: Array<ConversationType> = [
getDefaultConversation({
id: 'biking-group',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Mtn Biking Arizona 🚵☀️⛰',
type: 'group',
},
{
sharedGroupNames: [],
}),
getDefaultConversation({
id: 'dance-group',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Are we dancers? 💃',
type: 'group',
},
sharedGroupNames: [],
}),
];
const defaultArchivedConversations: Array<ConversationListItemPropsType> = [
{
const defaultArchivedConversations: Array<ConversationType> = [
getDefaultConversation({
id: 'michelle-archive-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Michelle Mercure',
type: 'direct',
},
isArchived: true,
}),
];
const pinnedConversations: Array<ConversationListItemPropsType> = [
{
const pinnedConversations: Array<ConversationType> = [
getDefaultConversation({
id: 'philly-convo',
isPinned: true,
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Philip Glass',
type: 'direct',
},
{
}),
getDefaultConversation({
id: 'robbo-convo',
isPinned: true,
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Robert Moog',
type: 'direct',
},
}),
];
const defaultModeSpecificProps = {
@ -106,6 +87,12 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
modeSpecificProps: defaultModeSpecificProps,
openConversationInternal: action('openConversationInternal'),
regionCode: 'US',
challengeStatus: select(
'challengeStatus',
['idle', 'required', 'pending'],
'idle'
),
setChallengeStatus: action('setChallengeStatus'),
renderExpiredBuildDialog: () => <div />,
renderMainHeader: () => <div />,
renderMessageSearchResult: (id: string, style: React.CSSProperties) => (
@ -126,6 +113,14 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
renderNetworkStatus: () => <div />,
renderRelinkDialog: () => <div />,
renderUpdateDialog: () => <div />,
renderCaptchaDialog: () => (
<CaptchaDialog
i18n={i18n}
isPending={overrideProps.challengeStatus === 'pending'}
onContinue={action('onCaptchaContinue')}
onSkip={action('onCaptchaSkip')}
/>
),
selectedConversationId: undefined,
selectedMessageId: undefined,
setComposeSearchTerm: action('setComposeSearchTerm'),
@ -468,3 +463,33 @@ story.add('Compose: some contacts, some groups, with a search term', () => (
})}
/>
));
// Captcha flow
story.add('Captcha dialog: required', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
conversations: defaultConversations,
archivedConversations: [],
},
challengeStatus: 'required',
})}
/>
));
story.add('Captcha dialog: pending', () => (
<LeftPane
{...createProps({
modeSpecificProps: {
mode: LeftPaneMode.Inbox,
pinnedConversations,
conversations: defaultConversations,
archivedConversations: [],
},
challengeStatus: 'pending',
})}
/>
));

View File

@ -79,6 +79,8 @@ export type PropsType = {
selectedConversationId: undefined | string;
selectedMessageId: undefined | string;
regionCode: string;
challengeStatus: 'idle' | 'required' | 'pending';
setChallengeStatus: (status: 'idle') => void;
// Action Creators
cantAddContactToGroup: (conversationId: string) => void;
@ -110,6 +112,7 @@ export type PropsType = {
renderNetworkStatus: () => JSX.Element;
renderRelinkDialog: () => JSX.Element;
renderUpdateDialog: () => JSX.Element;
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
};
export const LeftPane: React.FC<PropsType> = ({
@ -121,6 +124,8 @@ export const LeftPane: React.FC<PropsType> = ({
createGroup,
i18n,
modeSpecificProps,
challengeStatus,
setChallengeStatus,
openConversationInternal,
renderExpiredBuildDialog,
renderMainHeader,
@ -128,6 +133,7 @@ export const LeftPane: React.FC<PropsType> = ({
renderNetworkStatus,
renderRelinkDialog,
renderUpdateDialog,
renderCaptchaDialog,
selectedConversationId,
selectedMessageId,
setComposeSearchTerm,
@ -464,6 +470,12 @@ export const LeftPane: React.FC<PropsType> = ({
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}
{challengeStatus !== 'idle' &&
renderCaptchaDialog({
onSkip() {
setChallengeStatus('idle');
},
})}
</div>
);
};

View File

@ -32,6 +32,7 @@ export type PropsType = {
isMe?: boolean;
name?: string;
color?: ColorType;
disabled?: boolean;
isVerified?: boolean;
profileName?: string;
title: string;
@ -339,6 +340,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
const {
avatarPath,
color,
disabled,
i18n,
name,
startComposing,
@ -366,15 +368,20 @@ export class MainHeader extends React.Component<PropsType, StateType> {
<Reference>
{({ ref }) => (
<Avatar
acceptedMessageRequest
avatarPath={avatarPath}
className="module-main-header__avatar"
color={color}
conversationType="direct"
i18n={i18n}
isMe
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
// `sharedGroupNames` makes no sense for yourself, but `<Avatar>` needs it
// to determine blurring.
sharedGroupNames={[]}
size={28}
innerRef={ref}
onClick={this.showAvatarPopup}
@ -386,8 +393,10 @@ export class MainHeader extends React.Component<PropsType, StateType> {
<Popper placement="bottom-end">
{({ ref, style }) => (
<AvatarPopup
acceptedMessageRequest
innerRef={ref}
i18n={i18n}
isMe
style={{ ...style, zIndex: 1 }}
color={color}
conversationType="direct"
@ -397,6 +406,8 @@ export class MainHeader extends React.Component<PropsType, StateType> {
title={title}
avatarPath={avatarPath}
size={28}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onViewPreferences={() => {
showSettings();
this.hideAvatarPopup();
@ -436,6 +447,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
/>
)}
<input
disabled={disabled}
type="text"
ref={this.inputRef}
className={classNames(

View File

@ -13,6 +13,7 @@ type PropsType = {
children: ReactNode;
hasXButton?: boolean;
i18n: LocalizerType;
moduleClassName?: string;
onClose?: () => void;
title?: ReactNode;
theme?: Theme;
@ -22,6 +23,7 @@ export function Modal({
children,
hasXButton,
i18n,
moduleClassName,
onClose = noop,
title,
theme,
@ -35,7 +37,8 @@ export function Modal({
<div
className={classNames(
'module-Modal',
hasHeader ? 'module-Modal--has-header' : 'module-Modal--no-header'
hasHeader ? 'module-Modal--has-header' : 'module-Modal--no-header',
moduleClassName
)}
>
{hasHeader && (
@ -45,12 +48,22 @@ export function Modal({
aria-label={i18n('close')}
type="button"
className="module-Modal__close-button"
tabIndex={0}
onClick={() => {
onClose();
}}
/>
)}
{title && <h1 className="module-Modal__title">{title}</h1>}
{title && (
<h1
className={classNames(
'module-Modal__title',
hasXButton ? 'module-Modal__title--with-x-button' : null
)}
>
{title}
</h1>
)}
</div>
)}
<div

View File

@ -19,6 +19,7 @@ const defaultProps = {
socketStatus: 0,
manualReconnect: action('manual-reconnect'),
withinConnectingGracePeriod: false,
challengeStatus: 'idle' as const,
};
const permutations = [

View File

@ -10,26 +10,13 @@ import { NewlyCreatedGroupInvitedContactsDialog } from './NewlyCreatedGroupInvit
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { ConversationType } from '../state/ducks/conversations';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const conversations: Array<ConversationType> = [
{
id: 'fred-convo',
isSelected: false,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Fred Willard',
type: 'direct',
},
{
id: 'marc-convo',
isSelected: true,
lastUpdated: Date.now(),
markedUnread: false,
title: 'Marc Barraca',
type: 'direct',
},
getDefaultConversation({ title: 'Fred Willard' }),
getDefaultConversation({ title: 'Marc Barraca' }),
];
const story = storiesOf(

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -6,13 +6,13 @@ import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog';
import { ConversationType } from '../state/ducks/conversations';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const contactWithAllData = {
const contactWithAllData = getDefaultConversation({
id: 'abc',
avatarPath: undefined,
color: 'signal-blue',
@ -20,9 +20,9 @@ const contactWithAllData = {
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '(305) 123-4567',
} as ConversationType;
});
const contactWithJustProfile = {
const contactWithJustProfile = getDefaultConversation({
id: 'def',
avatarPath: undefined,
color: 'signal-blue',
@ -30,9 +30,9 @@ const contactWithJustProfile = {
profileName: '-*Smartest Dude*-',
name: undefined,
phoneNumber: '(305) 123-4567',
} as ConversationType;
});
const contactWithJustNumber = {
const contactWithJustNumber = getDefaultConversation({
id: 'xyz',
avatarPath: undefined,
color: 'signal-blue',
@ -40,9 +40,9 @@ const contactWithJustNumber = {
name: undefined,
title: '(305) 123-4567',
phoneNumber: '(305) 123-4567',
} as ConversationType;
});
const contactWithNothing = {
const contactWithNothing = getDefaultConversation({
id: 'some-guid',
avatarPath: undefined,
color: 'signal-blue',
@ -50,7 +50,7 @@ const contactWithNothing = {
name: undefined,
phoneNumber: undefined,
title: 'Unknown contact',
} as ConversationType;
});
storiesOf('Components/SafetyNumberChangeDialog', module)
.add('Single Contact Dialog', () => {

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -86,15 +86,19 @@ export const SafetyNumberChangeDialog = ({
key={contact.id}
>
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
color={contact.color}
conversationType="direct"
i18n={i18n}
isMe={contact.isMe}
name={contact.name}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
title={contact.title}
sharedGroupNames={contact.sharedGroupNames}
size={52}
unblurredAvatarPath={contact.unblurredAvatarPath}
/>
<div className="module-SafetyNumberChangeDialog__contact--wrapper">
<div className="module-SafetyNumberChangeDialog__contact--name">

View File

@ -11,6 +11,7 @@ export const SpinnerDirections = [
'outgoing',
'incoming',
'on-background',
'on-captcha',
'on-progress-dialog',
'on-avatar',
] as const;

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -7,6 +7,7 @@ import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { ContactModal, PropsType } from './ContactModal';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
@ -16,16 +17,13 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/ContactModal', module);
const defaultContact: ConversationType = {
const defaultContact: ConversationType = getDefaultConversation({
id: 'abcdef',
lastUpdated: Date.now(),
markedUnread: false,
areWeAdmin: false,
title: 'Pauline Oliveros',
type: 'direct',
phoneNumber: '(333) 444-5515',
about: '👍 Free to chat',
};
});
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactPortal } from 'react';
@ -105,14 +105,18 @@ export const ContactModal = ({
aria-label={i18n('close')}
/>
<Avatar
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
color={contact.color}
conversationType="direct"
i18n={i18n}
isMe={contact.isMe}
name={contact.name}
profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames}
size={96}
title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath}
/>
<div className="module-contact-modal__name">{contact.title}</div>
<div className="module-about__container">

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ComponentProps } from 'react';
@ -6,6 +6,7 @@ import React, { ComponentProps } from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import {
@ -27,9 +28,10 @@ type ConversationHeaderStory = {
};
const commonProps = {
...getDefaultConversation(),
showBackButton: false,
outgoingCallButtonStyle: OutgoingCallButtonStyle.Both,
markedUnread: false,
i18n,

View File

@ -17,12 +17,9 @@ import { Avatar, AvatarSize } from '../Avatar';
import { InContactsIcon } from '../InContactsIcon';
import { LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { ConversationType } from '../../state/ducks/conversations';
import { MuteOption, getMuteOptions } from '../../util/getMuteOptions';
import {
ExpirationTimerOptions,
TimerOption,
} from '../../util/ExpirationTimerOptions';
import * as expirationTimer from '../../util/expirationTimer';
import { isMuted } from '../../util/isMuted';
import { missingCaseError } from '../../util/missingCaseError';
@ -35,33 +32,33 @@ export enum OutgoingCallButtonStyle {
export type PropsDataType = {
conversationTitle?: string;
id: string;
name?: string;
phoneNumber?: string;
profileName?: string;
color?: ColorType;
avatarPath?: string;
type: 'direct' | 'group';
title: string;
acceptedMessageRequest?: boolean;
isVerified?: boolean;
isMe?: boolean;
isArchived?: boolean;
isPinned?: boolean;
isMissingMandatoryProfileSharing?: boolean;
left?: boolean;
markedUnread?: boolean;
groupVersion?: number;
canChangeTimer?: boolean;
expireTimer?: number;
muteExpiresAt?: number;
showBackButton?: boolean;
outgoingCallButtonStyle: OutgoingCallButtonStyle;
};
showBackButton?: boolean;
} & Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'canChangeTimer'
| 'color'
| 'expireTimer'
| 'groupVersion'
| 'id'
| 'isArchived'
| 'isMe'
| 'isPinned'
| 'isVerified'
| 'left'
| 'markedUnread'
| 'muteExpiresAt'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarPath'
>;
export type PropsActionsType = {
onSetMuteNotifications: (seconds: number) => void;
@ -180,6 +177,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
private renderAvatar(): ReactNode {
const {
acceptedMessageRequest,
avatarPath,
color,
i18n,
@ -188,22 +186,28 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
} = this.props;
return (
<span className="module-ConversationHeader__header__avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={type}
i18n={i18n}
isMe={isMe}
noteToSelf={isMe}
title={title}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
unblurredAvatarPath={unblurredAvatarPath}
/>
</span>
);
@ -212,16 +216,13 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
private renderExpirationLength(): ReactNode {
const { i18n, expireTimer } = this.props;
const expirationSettingName = expireTimer
? ExpirationTimerOptions.getAbbreviated(i18n, expireTimer)
: undefined;
if (!expirationSettingName) {
if (!expireTimer) {
return null;
}
return (
<div className="module-ConversationHeader__header__info__subtitle__expiration">
{expirationSettingName}
{expirationTimer.format(i18n, expireTimer)}
</div>
);
}
@ -427,16 +428,18 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
<ContextMenu id={triggerId}>
{disableTimerChanges ? null : (
<SubMenu title={disappearingTitle}>
{ExpirationTimerOptions.map((item: typeof TimerOption) => (
<MenuItem
key={item.get('seconds')}
onClick={() => {
onSetDisappearingMessages(item.get('seconds'));
}}
>
{item.getName(i18n)}
</MenuItem>
))}
{expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(
(seconds: number) => (
<MenuItem
key={seconds}
onClick={() => {
onSetDisappearingMessages(seconds);
}}
>
{expirationTimer.format(i18n, seconds)}
</MenuItem>
)
)}
</SubMenu>
)}
<SubMenu title={muteTitle}>

View File

@ -1,9 +1,10 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { number as numberKnob, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { ConversationHero } from './ConversationHero';
import { setup as setupI18n } from '../../../js/modules/i18n';
@ -19,20 +20,26 @@ const getAvatarPath = () =>
text('avatarPath', '/fixtures/kitten-4-112-112.jpg');
const getPhoneNumber = () => text('phoneNumber', '+1 (646) 327-2700');
const updateSharedGroups = action('updateSharedGroups');
storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (Three Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party', 'Friends 🌿']}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
@ -42,14 +49,18 @@ storiesOf('Components/Conversation/ConversationHero', module)
<div style={{ width: '480px' }}>
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
@ -59,14 +70,18 @@ storiesOf('Components/Conversation/ConversationHero', module)
<div style={{ width: '480px' }}>
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={['NYC Rock Climbers']}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
@ -76,14 +91,18 @@ storiesOf('Components/Conversation/ConversationHero', module)
<div style={{ width: '480px' }}>
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
profileName={text('profileName', '')}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
@ -93,14 +112,18 @@ storiesOf('Components/Conversation/ConversationHero', module)
<div style={{ width: '480px' }}>
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={text('title', 'Cayce Bollard (profile)')}
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
@ -110,14 +133,18 @@ storiesOf('Components/Conversation/ConversationHero', module)
<div style={{ width: '480px' }}>
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={text('title', '+1 (646) 327-2700')}
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={text('profileName', '')}
phoneNumber={getPhoneNumber()}
conversationType="direct"
updateSharedGroups={updateSharedGroups}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
/>
</div>
);
@ -127,13 +154,37 @@ storiesOf('Components/Conversation/ConversationHero', module)
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
isMe={false}
title={text('title', 'Unknown contact')}
acceptedMessageRequest
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={text('profileName', '')}
phoneNumber={text('phoneNumber', '')}
conversationType="direct"
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
})
.add('Direct (No Groups, No Data, Not Accepted)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
isMe={false}
title={text('title', 'Unknown contact')}
acceptedMessageRequest={false}
avatarPath={getAvatarPath()}
name={text('name', '')}
profileName={text('profileName', '')}
phoneNumber={text('phoneNumber', '')}
conversationType="direct"
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
@ -142,11 +193,16 @@ storiesOf('Components/Conversation/ConversationHero', module)
return (
<div style={{ width: '480px' }}>
<ConversationHero
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
membersCount={numberKnob('membersCount', 22)}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
@ -155,11 +211,16 @@ storiesOf('Components/Conversation/ConversationHero', module)
return (
<div style={{ width: '480px' }}>
<ConversationHero
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
membersCount={1}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
@ -168,11 +229,16 @@ storiesOf('Components/Conversation/ConversationHero', module)
return (
<div style={{ width: '480px' }}>
<ConversationHero
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={text('title', 'NYC Rock Climbers')}
name={text('groupName', 'NYC Rock Climbers')}
conversationType="group"
membersCount={0}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
@ -181,11 +247,16 @@ storiesOf('Components/Conversation/ConversationHero', module)
return (
<div style={{ width: '480px' }}>
<ConversationHero
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={text('title', 'Unknown group')}
name={text('groupName', '')}
conversationType="group"
membersCount={0}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);
@ -194,11 +265,15 @@ storiesOf('Components/Conversation/ConversationHero', module)
return (
<div style={{ width: '480px' }}>
<ConversationHero
acceptedMessageRequest
i18n={i18n}
isMe
title={getTitle()}
conversationType="direct"
phoneNumber={getPhoneNumber()}
sharedGroupNames={[]}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={updateSharedGroups}
/>
</div>
);

View File

@ -1,36 +1,53 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import Measure from 'react-measure';
import { take } from 'lodash';
import { Avatar, Props as AvatarProps } from '../Avatar';
import { Avatar, AvatarBlur, Props as AvatarProps } from '../Avatar';
import { ContactName } from './ContactName';
import { About } from './About';
import { Emojify } from './Emojify';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Button, ButtonSize, ButtonVariant } from '../Button';
import { assert } from '../../util/assert';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
export type Props = {
about?: string;
acceptedMessageRequest?: boolean;
i18n: LocalizerType;
isMe?: boolean;
sharedGroupNames?: Array<string>;
isMe: boolean;
membersCount?: number;
phoneNumber?: string;
onHeightChange?: () => unknown;
updateSharedGroups?: () => unknown;
phoneNumber?: string;
sharedGroupNames?: Array<string>;
unblurAvatar: () => void;
unblurredAvatarPath?: string;
updateSharedGroups: () => unknown;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
const renderMembershipRow = ({
i18n,
phoneNumber,
sharedGroupNames = [],
acceptedMessageRequest,
conversationType,
i18n,
isMe,
onClickMessageRequestWarning,
phoneNumber,
sharedGroupNames,
}: Pick<
Props,
'i18n' | 'phoneNumber' | 'sharedGroupNames' | 'conversationType' | 'isMe'
>) => {
| 'acceptedMessageRequest'
| 'conversationType'
| 'i18n'
| 'isMe'
| 'phoneNumber'
> &
Required<Pick<Props, 'sharedGroupNames'>> & {
onClickMessageRequestWarning: () => void;
}) => {
const className = 'module-conversation-hero__membership';
const nameClassName = `${className}__name`;
@ -111,17 +128,33 @@ const renderMembershipRow = ({
);
}
}
if (!phoneNumber) {
if (acceptedMessageRequest) {
if (phoneNumber) {
return null;
}
return <div className={className}>{i18n('no-groups-in-common')}</div>;
}
return null;
return (
<div className="module-conversation-hero__message-request-warning">
<div className="module-conversation-hero__message-request-warning__message">
{i18n('no-groups-in-common-warning')}
</div>
<Button
onClick={onClickMessageRequestWarning}
size={ButtonSize.Small}
variant={ButtonVariant.SecondaryAffirmative}
>
{i18n('MessageRequestWarning__learn-more')}
</Button>
</div>
);
};
export const ConversationHero = ({
i18n,
about,
acceptedMessageRequest,
avatarPath,
color,
conversationType,
@ -133,39 +166,62 @@ export const ConversationHero = ({
profileName,
title,
onHeightChange,
unblurAvatar,
unblurredAvatarPath,
updateSharedGroups,
}: Props): JSX.Element => {
const firstRenderRef = React.useRef(true);
const firstRenderRef = useRef(true);
// TODO: DESKTOP-686
/* eslint-disable react-hooks/exhaustive-deps */
React.useEffect(() => {
// If any of the depenencies for this hook change then the height of this
// component may have changed. The cleanup function notifies listeners of
// any potential height changes.
return () => {
// Kick off the expensive hydration of the current sharedGroupNames
if (updateSharedGroups) {
updateSharedGroups();
}
const previousHeightRef = useRef<undefined | number>();
const [height, setHeight] = useState<undefined | number>();
if (onHeightChange && !firstRenderRef.current) {
onHeightChange();
} else {
firstRenderRef.current = false;
}
};
}, [
firstRenderRef,
onHeightChange,
// Avoid collisions in these dependencies by prefixing them
// These dependencies may be dynamic, and therefore may cause height changes
`mc-${membersCount}`,
`n-${name}`,
`pn-${profileName}`,
sharedGroupNames.map(g => `g-${g}`).join(' '),
]);
/* eslint-enable react-hooks/exhaustive-deps */
const [
isShowingMessageRequestWarning,
setIsShowingMessageRequestWarning,
] = useState(false);
const closeMessageRequestWarning = () => {
setIsShowingMessageRequestWarning(false);
};
useEffect(() => {
// Kick off the expensive hydration of the current sharedGroupNames
updateSharedGroups();
}, [updateSharedGroups]);
useEffect(() => {
firstRenderRef.current = false;
}, []);
useEffect(() => {
// We only want to kick off a "height change" when the height goes from number to
// number. We DON'T want to do it when we measure the height for the first time, as
// this will cause a re-render loop.
const previousHeight = previousHeightRef.current;
if (previousHeight && height && previousHeight !== height) {
onHeightChange?.();
}
}, [height, onHeightChange]);
useEffect(() => {
previousHeightRef.current = height;
}, [height]);
let avatarBlur: AvatarBlur;
let avatarOnClick: undefined | (() => void);
if (
shouldBlurAvatar({
acceptedMessageRequest,
avatarPath,
isMe,
sharedGroupNames,
unblurredAvatarPath,
})
) {
avatarBlur = AvatarBlur.BlurPictureWithClickToView;
avatarOnClick = unblurAvatar;
} else {
avatarBlur = AvatarBlur.NoBlur;
}
const phoneNumberOnly = Boolean(
!name && !profileName && conversationType === 'direct'
@ -173,56 +229,95 @@ export const ConversationHero = ({
/* eslint-disable no-nested-ternary */
return (
<div className="module-conversation-hero">
<Avatar
i18n={i18n}
color={color}
noteToSelf={isMe}
avatarPath={avatarPath}
conversationType={conversationType}
name={name}
profileName={profileName}
title={title}
size={112}
className="module-conversation-hero__avatar"
/>
<h1 className="module-conversation-hero__profile-name">
{isMe ? (
i18n('noteToSelf')
) : (
<ContactName
title={title}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
i18n={i18n}
/>
<>
<Measure
bounds
onResize={({ bounds }) => {
assert(bounds, 'We should be measuring the bounds');
setHeight(bounds.height);
}}
>
{({ measureRef }) => (
<div className="module-conversation-hero" ref={measureRef}>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
blur={avatarBlur}
className="module-conversation-hero__avatar"
color={color}
conversationType={conversationType}
i18n={i18n}
isMe={isMe}
name={name}
noteToSelf={isMe}
onClick={avatarOnClick}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
size={112}
title={title}
/>
<h1 className="module-conversation-hero__profile-name">
{isMe ? (
i18n('noteToSelf')
) : (
<ContactName
title={title}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
i18n={i18n}
/>
)}
</h1>
{about && !isMe && (
<div className="module-about__container">
<About text={about} />
</div>
)}
{!isMe ? (
<div className="module-conversation-hero__with">
{membersCount === 1
? i18n('ConversationHero--members-1')
: membersCount !== undefined
? i18n('ConversationHero--members', [`${membersCount}`])
: phoneNumberOnly
? null
: phoneNumber}
</div>
) : null}
{renderMembershipRow({
acceptedMessageRequest,
conversationType,
i18n,
isMe,
onClickMessageRequestWarning() {
setIsShowingMessageRequestWarning(true);
},
phoneNumber,
sharedGroupNames,
})}
</div>
)}
</h1>
{about && !isMe && (
<div className="module-about__container">
<About text={about} />
</div>
</Measure>
{isShowingMessageRequestWarning && (
<ConfirmationDialog
i18n={i18n}
onClose={closeMessageRequestWarning}
actions={[
{
text: i18n('MessageRequestWarning__dialog__learn-even-more'),
action: () => {
window.location.href =
'https://support.signal.org/hc/articles/360007459591';
closeMessageRequestWarning();
},
},
]}
>
{i18n('MessageRequestWarning__dialog__details')}
</ConfirmationDialog>
)}
{!isMe ? (
<div className="module-conversation-hero__with">
{membersCount === 1
? i18n('ConversationHero--members-1')
: membersCount !== undefined
? i18n('ConversationHero--members', [`${membersCount}`])
: phoneNumberOnly
? null
: phoneNumber}
</div>
) : null}
{renderMembershipRow({
conversationType,
i18n,
isMe,
phoneNumber,
sharedGroupNames,
})}
</div>
</>
);
/* eslint-enable no-nested-ternary */
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable-next-line max-classes-per-file */
@ -7,29 +7,24 @@ import { storiesOf } from '@storybook/react';
import { isBoolean } from 'lodash';
import { boolean } from '@storybook/addon-knobs';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { GroupV1Migration, PropsType } from './GroupV1Migration';
const i18n = setupI18n('en', enMessages);
const contact1 = {
const contact1 = getDefaultConversation({
title: 'Alice',
number: '+1 (300) 555-000',
phoneNumber: '+1 (300) 555-000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
});
const contact2 = {
const contact2 = getDefaultConversation({
title: 'Bob',
number: '+1 (300) 555-000',
phoneNumber: '+1 (300) 555-000',
id: 'guid-2',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
});
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeInvited: boolean(

View File

@ -5,7 +5,7 @@ import * as React from 'react';
import { isBoolean } from 'lodash';
import { action } from '@storybook/addon-actions';
import { boolean, number, text, select } from '@storybook/addon-knobs';
import { boolean, number, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { Colors } from '../../types/Colors';
@ -24,6 +24,7 @@ import { computePeaks } from '../GlobalAudioContext';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { pngUrl } from '../../storybook/Fixtures';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
@ -68,10 +69,7 @@ const renderAudioAttachment: Props['renderAudioAttachment'] = props => (
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
attachments: overrideProps.attachments,
authorId: overrideProps.authorId || 'some-id',
authorColor: select('authorColor', Colors, Colors[0]),
authorAvatarPath: overrideProps.authorAvatarPath,
authorTitle: text('authorTitle', overrideProps.authorTitle || ''),
author: overrideProps.author || getDefaultConversation(),
bodyRanges: overrideProps.bodyRanges,
canReply: true,
canDownload: true,
@ -212,7 +210,7 @@ story.add('Pending', () => {
story.add('Collapsed Metadata', () => {
const props = createProps({
authorTitle: 'Fred Willard',
author: getDefaultConversation({ title: 'Fred Willard' }),
collapseMetadata: true,
conversationType: 'group',
text: 'Hello there from a pal!',
@ -246,83 +244,83 @@ story.add('Reactions (wider message)', () => {
reactions: [
{
emoji: '👍',
from: {
from: getDefaultConversation({
isMe: true,
id: '+14155552672',
phoneNumber: '+14155552672',
name: 'Me',
title: 'Me',
},
}),
timestamp: Date.now() - 10,
},
{
emoji: '👍',
from: {
from: getDefaultConversation({
id: '+14155552672',
phoneNumber: '+14155552672',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now() - 10,
},
{
emoji: '👍',
from: {
from: getDefaultConversation({
id: '+14155552673',
phoneNumber: '+14155552673',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now() - 10,
},
{
emoji: '😂',
from: {
from: getDefaultConversation({
id: '+14155552674',
phoneNumber: '+14155552674',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now() - 10,
},
{
emoji: '😂',
from: {
from: getDefaultConversation({
id: '+14155552676',
phoneNumber: '+14155552676',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now() - 10,
},
{
emoji: '😡',
from: {
from: getDefaultConversation({
id: '+14155552677',
phoneNumber: '+14155552677',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now() - 10,
},
{
emoji: '👎',
from: {
from: getDefaultConversation({
id: '+14155552678',
phoneNumber: '+14155552678',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now() - 10,
},
{
emoji: '❤️',
from: {
from: getDefaultConversation({
id: '+14155552679',
phoneNumber: '+14155552679',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now() - 10,
},
],
@ -338,83 +336,83 @@ story.add('Reactions (short message)', () => {
reactions: [
{
emoji: '👍',
from: {
from: getDefaultConversation({
isMe: true,
id: '+14155552672',
phoneNumber: '+14155552672',
name: 'Me',
title: 'Me',
},
}),
timestamp: Date.now(),
},
{
emoji: '👍',
from: {
from: getDefaultConversation({
id: '+14155552672',
phoneNumber: '+14155552672',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '👍',
from: {
from: getDefaultConversation({
id: '+14155552673',
phoneNumber: '+14155552673',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '😂',
from: {
from: getDefaultConversation({
id: '+14155552674',
phoneNumber: '+14155552674',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '😂',
from: {
from: getDefaultConversation({
id: '+14155552676',
phoneNumber: '+14155552676',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '😡',
from: {
from: getDefaultConversation({
id: '+14155552677',
phoneNumber: '+14155552677',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '👎',
from: {
from: getDefaultConversation({
id: '+14155552678',
phoneNumber: '+14155552678',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '❤️',
from: {
from: getDefaultConversation({
id: '+14155552679',
phoneNumber: '+14155552679',
name: 'Amelia Briggs',
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
],
@ -425,7 +423,7 @@ story.add('Reactions (short message)', () => {
story.add('Avatar in Group', () => {
const props = createProps({
authorAvatarPath: pngUrl,
author: getDefaultConversation({ avatarPath: pngUrl }),
conversationType: 'group',
status: 'sent',
text: 'Hello it is me, the saxophone.',
@ -481,6 +479,15 @@ story.add('Error', () => {
return renderBothDirections(props);
});
story.add('Paused', () => {
const props = createProps({
status: 'paused',
text: 'I am up to a challenge',
});
return renderBothDirections(props);
});
story.add('Partial Send', () => {
const props = createProps({
status: 'partial-sent',
@ -921,15 +928,16 @@ story.add('Dangerous File Type', () => {
});
story.add('Colors', () => {
const defaultProps = createProps({
text:
'Hello there from a pal! I am sending a long message so that it will wrap a bit, since I like that look.',
});
return (
<>
{Colors.map(color => (
<Message {...defaultProps} authorColor={color} />
<Message
{...createProps({
author: getDefaultConversation({ color }),
text:
'Hello there from a pal! I am sending a long message so that it will wrap a bit, since I like that look.',
})}
/>
))}
</>
);

View File

@ -8,6 +8,7 @@ import { drop, groupBy, orderBy, take } from 'lodash';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { Manager, Popper, Reference } from 'react-popper';
import { ConversationType } from '../../state/ducks/conversations';
import { Avatar } from '../Avatar';
import { Spinner } from '../Spinner';
import { MessageBody } from './MessageBody';
@ -63,6 +64,7 @@ const THREE_HOURS = 3 * 60 * 60 * 1000;
export const MessageStatuses = [
'delivered',
'error',
'paused',
'partial-sent',
'read',
'sending',
@ -105,12 +107,20 @@ export type PropsData = {
timestamp: number;
status?: MessageStatusType;
contact?: ContactType;
authorId: string;
authorTitle: string;
authorName?: string;
authorProfileName?: string;
authorPhoneNumber?: string;
authorColor?: ColorType;
author: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
>;
conversationType: ConversationTypesType;
attachments?: Array<AttachmentType>;
quote?: {
@ -128,7 +138,6 @@ export type PropsData = {
referencedMessageNotFound: boolean;
};
previews: Array<LinkPreviewType>;
authorAvatarPath?: string;
isExpired?: boolean;
isTapToView?: boolean;
@ -512,8 +521,31 @@ export class Message extends React.PureComponent<Props, State> {
const isError = status === 'error' && direction === 'outgoing';
const isPartiallySent =
status === 'partial-sent' && direction === 'outgoing';
const isPaused = status === 'paused';
if (isError || isPartiallySent || isPaused) {
let statusInfo: React.ReactChild;
if (isError) {
statusInfo = i18n('sendFailed');
} else if (isPaused) {
statusInfo = i18n('sendPaused');
} else {
statusInfo = (
<button
type="button"
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showMessageDetail(id);
}}
>
{i18n('partiallySent')}
</button>
);
}
if (isError || isPartiallySent) {
return (
<span
className={classNames({
@ -523,22 +555,7 @@ export class Message extends React.PureComponent<Props, State> {
'module-message__metadata__date--with-image-no-caption': withImageNoCaption,
})}
>
{isError ? (
i18n('sendFailed')
) : (
<button
type="button"
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showMessageDetail(id);
}}
>
{i18n('partiallySent')}
</button>
)}
{statusInfo}
</span>
);
}
@ -635,10 +652,7 @@ export class Message extends React.PureComponent<Props, State> {
public renderAuthor(): JSX.Element | null {
const {
authorTitle,
authorName,
authorPhoneNumber,
authorProfileName,
author,
collapseMetadata,
conversationType,
direction,
@ -655,7 +669,7 @@ export class Message extends React.PureComponent<Props, State> {
if (
direction !== 'incoming' ||
conversationType !== 'group' ||
!authorTitle
!author.title
) {
return null;
}
@ -671,10 +685,10 @@ export class Message extends React.PureComponent<Props, State> {
return (
<div className={moduleName}>
<ContactName
title={authorTitle}
phoneNumber={authorPhoneNumber}
name={authorName}
profileName={authorProfileName}
title={author.title}
phoneNumber={author.phoneNumber}
name={author.name}
profileName={author.profileName}
module={moduleName}
i18n={i18n}
/>
@ -998,8 +1012,8 @@ export class Message extends React.PureComponent<Props, State> {
public renderQuote(): JSX.Element | null {
const {
author,
conversationType,
authorColor,
direction,
disableScroll,
i18n,
@ -1014,7 +1028,7 @@ export class Message extends React.PureComponent<Props, State> {
const withContentAbove =
conversationType === 'group' && direction === 'incoming';
const quoteColor =
direction === 'incoming' ? authorColor : quote.authorColor;
direction === 'incoming' ? author.color : quote.authorColor;
const { referencedMessageNotFound } = quote;
const clickHandler = disableScroll
@ -1106,14 +1120,8 @@ export class Message extends React.PureComponent<Props, State> {
public renderAvatar(): JSX.Element | undefined {
const {
authorAvatarPath,
authorId,
authorName,
authorPhoneNumber,
authorProfileName,
authorTitle,
author,
collapseMetadata,
authorColor,
conversationType,
direction,
i18n,
@ -1137,19 +1145,23 @@ export class Message extends React.PureComponent<Props, State> {
<button
type="button"
className="module-message__author-avatar"
onClick={() => showContactModal(authorId)}
onClick={() => showContactModal(author.id)}
tabIndex={0}
>
<Avatar
avatarPath={authorAvatarPath}
color={authorColor}
acceptedMessageRequest={author.acceptedMessageRequest}
avatarPath={author.avatarPath}
color={author.color}
conversationType="direct"
i18n={i18n}
name={authorName}
phoneNumber={authorPhoneNumber}
profileName={authorProfileName}
title={authorTitle}
isMe={author.isMe}
name={author.name}
phoneNumber={author.phoneNumber}
profileName={author.profileName}
sharedGroupNames={author.sharedGroupNames}
size={28}
title={author.title}
unblurredAvatarPath={author.unblurredAvatarPath}
/>
</button>
</div>
@ -1206,7 +1218,15 @@ export class Message extends React.PureComponent<Props, State> {
public renderError(isCorrectSide: boolean): JSX.Element | null {
const { status, direction } = this.props;
if (!isCorrectSide || (status !== 'error' && status !== 'partial-sent')) {
if (!isCorrectSide) {
return null;
}
if (
status !== 'paused' &&
status !== 'error' &&
status !== 'partial-sent'
) {
return null;
}
@ -1215,7 +1235,8 @@ export class Message extends React.PureComponent<Props, State> {
<div
className={classNames(
'module-message__error',
`module-message__error--${direction}`
`module-message__error--${direction}`,
`module-message__error--${status}`
)}
/>
</div>
@ -1420,7 +1441,9 @@ export class Message extends React.PureComponent<Props, State> {
const { canDeleteForEveryone } = this.state;
const showRetry =
(status === 'error' || status === 'partial-sent') &&
(status === 'paused' ||
status === 'error' ||
status === 'partial-sent') &&
direction === 'outgoing';
const multipleAttachments = attachments && attachments.length > 1;
@ -2193,7 +2216,7 @@ export class Message extends React.PureComponent<Props, State> {
public renderContainer(): JSX.Element {
const {
authorColor,
author,
deletedForEveryone,
direction,
isSticker,
@ -2218,13 +2241,13 @@ export class Message extends React.PureComponent<Props, State> {
? 'module-message__container--with-tap-to-view-expired'
: null,
!isSticker && direction === 'incoming'
? `module-message__container--incoming-${authorColor}`
? `module-message__container--incoming-${author.color}`
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? 'module-message__container--with-tap-to-view-pending'
: null,
isTapToView && isAttachmentPending && !isTapToViewExpired
? `module-message__container--${direction}-${authorColor}-tap-to-view-pending`
? `module-message__container--${direction}-${author.color}-tap-to-view-pending`
: null,
isTapToViewError
? 'module-message__container--with-tap-to-view-error'
@ -2251,7 +2274,7 @@ export class Message extends React.PureComponent<Props, State> {
public render(): JSX.Element | null {
const {
authorId,
author,
attachments,
direction,
id,
@ -2262,7 +2285,7 @@ export class Message extends React.PureComponent<Props, State> {
// This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique.
const triggerId = String(id || `${authorId}-${timestamp}`);
const triggerId = String(id || `${author.id}-${timestamp}`);
if (expired) {
return null;

View File

@ -9,6 +9,7 @@ import { storiesOf } from '@storybook/react';
import { PropsData as MessageDataPropsType } from './Message';
import { MessageDetail, Props } from './MessageDetail';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
@ -17,8 +18,10 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/MessageDetail', module);
const defaultMessage: MessageDataPropsType = {
authorId: 'some-id',
authorTitle: 'Max',
author: getDefaultConversation({
id: 'some-id',
title: 'Max',
}),
canReply: true,
canDeleteForEveryone: true,
canDownload: true,
@ -37,13 +40,15 @@ const defaultMessage: MessageDataPropsType = {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
contacts: overrideProps.contacts || [
{
color: 'green',
...getDefaultConversation({
color: 'green',
title: 'Just Max',
}),
isOutgoingKeyError: false,
isUnidentifiedDelivery: false,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'delivered',
title: 'Just Max',
},
],
errors: overrideProps.errors || [],
@ -96,49 +101,59 @@ story.add('Message Statuses', () => {
const props = createProps({
contacts: [
{
color: 'green',
...getDefaultConversation({
color: 'green',
title: 'Max',
}),
isOutgoingKeyError: false,
isUnidentifiedDelivery: false,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'sent',
title: 'Max',
},
{
color: 'blue',
...getDefaultConversation({
color: 'blue',
title: 'Sally',
}),
isOutgoingKeyError: false,
isUnidentifiedDelivery: false,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'sending',
title: 'Sally',
},
{
color: 'brown',
...getDefaultConversation({
color: 'brown',
title: 'Terry',
}),
isOutgoingKeyError: false,
isUnidentifiedDelivery: false,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'partial-sent',
title: 'Terry',
},
{
color: 'light_green',
...getDefaultConversation({
color: 'light_green',
title: 'Theo',
}),
isOutgoingKeyError: false,
isUnidentifiedDelivery: false,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'delivered',
title: 'Theo',
},
{
color: 'blue_grey',
...getDefaultConversation({
color: 'blue_grey',
title: 'Nikki',
}),
isOutgoingKeyError: false,
isUnidentifiedDelivery: false,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'read',
title: 'Nikki',
},
],
message: {
@ -189,16 +204,21 @@ story.add('All Errors', () => {
},
contacts: [
{
color: 'green',
...getDefaultConversation({
color: 'green',
title: 'Max',
}),
isOutgoingKeyError: true,
isUnidentifiedDelivery: false,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'error',
title: 'Max',
},
{
color: 'blue',
...getDefaultConversation({
color: 'blue',
title: 'Sally',
}),
errors: [
{
name: 'Big Error',
@ -210,16 +230,17 @@ story.add('All Errors', () => {
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'error',
title: 'Sally',
},
{
color: 'brown',
...getDefaultConversation({
color: 'brown',
title: 'Terry',
}),
isOutgoingKeyError: true,
isUnidentifiedDelivery: true,
onSendAnyway: action('onSendAnyway'),
onShowSafetyNumber: action('onShowSafetyNumber'),
status: 'error',
title: 'Terry',
},
],
});

View File

@ -15,20 +15,27 @@ import {
PropsData as MessagePropsDataType,
} from './Message';
import { LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { ConversationType } from '../../state/ducks/conversations';
import { assert } from '../../util/assert';
export type Contact = {
export type Contact = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
> & {
status: MessageStatusType | null;
title: string;
phoneNumber?: string;
name?: string;
profileName?: string;
avatarPath?: string;
color?: ColorType;
isOutgoingKeyError: boolean;
isUnidentifiedDelivery: boolean;
unblurredAvatarPath?: string;
errors?: Array<Error>;
@ -89,25 +96,33 @@ export class MessageDetail extends React.Component<Props> {
public renderAvatar(contact: Contact): JSX.Element {
const { i18n } = this.props;
const {
acceptedMessageRequest,
avatarPath,
color,
phoneNumber,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
} = contact;
return (
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType="direct"
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={52}
unblurredAvatarPath={unblurredAvatarPath}
/>
);
}

View File

@ -1,10 +1,11 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { ProfileChangeNotification } from './ProfileChangeNotification';
@ -16,14 +17,12 @@ storiesOf('Components/Conversation/ProfileChangeNotification', module)
return (
<ProfileChangeNotification
i18n={i18n}
changedContact={{
changedContact={getDefaultConversation({
id: 'some-guid',
type: 'direct',
title: 'Mr. Fire 🔥',
name: 'Mr. Fire 🔥',
lastUpdated: Date.now(),
markedUnread: false,
}}
})}
change={{
type: 'name',
oldName: 'Mr. Fire 🔥 Old',
@ -36,13 +35,11 @@ storiesOf('Components/Conversation/ProfileChangeNotification', module)
return (
<ProfileChangeNotification
i18n={i18n}
changedContact={{
changedContact={getDefaultConversation({
id: 'some-guid',
type: 'direct',
title: 'Mr. Fire 🔥',
lastUpdated: Date.now(),
markedUnread: false,
}}
})}
change={{
type: 'name',
oldName: 'Mr. Fire 🔥 Old',

View File

@ -21,14 +21,17 @@ import {
import { Props, Quote } from './Quote';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/Quote', module);
const defaultMessageProps: MessagesProps = {
authorId: 'some-id',
authorTitle: 'Person X',
author: getDefaultConversation({
id: 'some-id',
title: 'Person X',
}),
canReply: true,
canDeleteForEveryone: true,
canDownload: true,

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -9,6 +9,7 @@ import { storiesOf } from '@storybook/react';
import { Props, ReactionViewer } from './ReactionViewer';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
@ -28,94 +29,94 @@ story.add('All Reactions', () => {
{
emoji: '❤️',
timestamp: 1,
from: {
from: getDefaultConversation({
id: '+14155552671',
phoneNumber: '+14155552671',
profileName: 'Ameila Briggs',
title: 'Amelia',
},
}),
},
{
emoji: '❤️',
timestamp: 2,
from: {
from: getDefaultConversation({
id: '+14155552672',
name: 'Adam Burrel',
title: 'Adam',
},
}),
},
{
emoji: '❤️',
timestamp: 3,
from: {
from: getDefaultConversation({
id: '+14155552673',
name: 'Rick Owens',
title: 'Rick',
},
}),
},
{
emoji: '❤️',
timestamp: 4,
from: {
from: getDefaultConversation({
id: '+14155552674',
name: 'Bojack Horseman',
title: 'Bojack',
},
}),
},
{
emoji: '👍',
timestamp: 9,
from: {
from: getDefaultConversation({
id: '+14155552678',
phoneNumber: '+14155552678',
profileName: 'Adam Burrel',
title: 'Adam',
},
}),
},
{
emoji: '👎',
timestamp: 10,
from: {
from: getDefaultConversation({
id: '+14155552673',
name: 'Rick Owens',
title: 'Rick',
},
}),
},
{
emoji: '😂',
timestamp: 11,
from: {
from: getDefaultConversation({
id: '+14155552674',
name: 'Bojack Horseman',
title: 'Bojack',
},
}),
},
{
emoji: '😮',
timestamp: 12,
from: {
from: getDefaultConversation({
id: '+14155552675',
name: 'Cayce Pollard',
title: 'Cayce',
},
}),
},
{
emoji: '😢',
timestamp: 13,
from: {
from: getDefaultConversation({
id: '+14155552676',
name: 'Foo McBarrington',
title: 'Foo',
},
}),
},
{
emoji: '😡',
timestamp: 14,
from: {
from: getDefaultConversation({
id: '+14155552676',
name: 'Foo McBarrington',
title: 'Foo',
},
}),
},
],
});
@ -128,22 +129,22 @@ story.add('Picked Reaction', () => {
reactions: [
{
emoji: '❤️',
from: {
from: getDefaultConversation({
id: '+14155552671',
name: 'Amelia Briggs',
isMe: true,
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '👍',
from: {
from: getDefaultConversation({
id: '+14155552671',
phoneNumber: '+14155552671',
profileName: 'Joel Ferrari',
title: 'Joel',
},
}),
timestamp: Date.now(),
},
],
@ -157,22 +158,22 @@ story.add('Picked Missing Reaction', () => {
reactions: [
{
emoji: '❤️',
from: {
from: getDefaultConversation({
id: '+14155552671',
name: 'Amelia Briggs',
isMe: true,
title: 'Amelia',
},
}),
timestamp: Date.now(),
},
{
emoji: '👍',
from: {
from: getDefaultConversation({
id: '+14155552671',
phoneNumber: '+14155552671',
profileName: 'Joel Ferrari',
title: 'Joel',
},
}),
timestamp: Date.now(),
},
],
@ -196,11 +197,11 @@ const createReaction = (
timestamp = Date.now()
) => ({
emoji,
from: {
from: getDefaultConversation({
id: '+14155552671',
name,
title: name,
},
}),
timestamp,
});

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -8,22 +8,25 @@ import { ContactName } from './ContactName';
import { Avatar, Props as AvatarProps } from '../Avatar';
import { Emoji } from '../emoji/Emoji';
import { useRestoreFocus } from '../../util/hooks';
import { ColorType } from '../../types/Colors';
import { ConversationType } from '../../state/ducks/conversations';
import { emojiToData, EmojiData } from '../emoji/lib';
export type Reaction = {
emoji: string;
timestamp: number;
from: {
id: string;
color?: ColorType;
avatarPath?: string;
name?: string;
profileName?: string;
title: string;
isMe?: boolean;
phoneNumber?: string;
};
from: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
>;
};
export type OwnProps = {
@ -212,9 +215,12 @@ export const ReactionViewer = React.forwardRef<HTMLDivElement, Props>(
>
<div className="module-reaction-viewer__body__row__avatar">
<Avatar
acceptedMessageRequest={from.acceptedMessageRequest}
avatarPath={from.avatarPath}
conversationType="direct"
sharedGroupNames={from.sharedGroupNames}
size={32}
isMe={from.isMe}
color={from.color}
name={from.name}
profileName={from.profileName}

View File

@ -6,6 +6,7 @@ import { storiesOf } from '@storybook/react';
import { text, boolean, number } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { PropsType, Timeline } from './Timeline';
@ -34,8 +35,10 @@ const items: Record<string, TimelineItemType> = {
id: 'id-1',
direction: 'incoming',
timestamp: Date.now(),
authorPhoneNumber: '(202) 555-2001',
authorColor: 'green',
author: {
phoneNumber: '(202) 555-2001',
color: 'green',
},
text: '🔥',
},
},
@ -46,7 +49,9 @@ const items: Record<string, TimelineItemType> = {
conversationType: 'group',
direction: 'incoming',
timestamp: Date.now(),
authorColor: 'green',
author: {
color: 'green',
},
text: 'Hello there from the new world! http://somewhere.com',
},
},
@ -69,7 +74,9 @@ const items: Record<string, TimelineItemType> = {
collapseMetadata: true,
direction: 'incoming',
timestamp: Date.now(),
authorColor: 'red',
author: {
color: 'red',
},
text: 'Hello there from the new world!',
},
},
@ -153,7 +160,9 @@ const items: Record<string, TimelineItemType> = {
direction: 'outgoing',
timestamp: Date.now(),
status: 'sent',
authorColor: 'pink',
author: {
color: 'pink',
},
text: '🔥',
},
},
@ -164,7 +173,9 @@ const items: Record<string, TimelineItemType> = {
direction: 'outgoing',
timestamp: Date.now(),
status: 'read',
authorColor: 'pink',
author: {
color: 'pink',
},
text: 'Hello there from the new world! http://somewhere.com',
},
},
@ -186,7 +197,9 @@ const items: Record<string, TimelineItemType> = {
direction: 'outgoing',
status: 'sent',
timestamp: Date.now(),
authorColor: 'blue',
author: {
color: 'blue',
},
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
},
@ -260,6 +273,8 @@ const actions = () => ({
returnToActiveCall: action('returnToActiveCall'),
contactSupport: action('contactSupport'),
unblurAvatar: action('unblurAvatar'),
});
const renderItem = (id: string) => (
@ -293,7 +308,9 @@ const getPhoneNumber = () => text('phoneNumber', '+1 (808) 555-1234');
const renderHeroRow = () => (
<ConversationHero
about={getAbout()}
acceptedMessageRequest
i18n={i18n}
isMe={false}
title={getTitle()}
avatarPath={getAvatarPath()}
name={getName()}
@ -301,16 +318,21 @@ const renderHeroRow = () => (
phoneNumber={getPhoneNumber()}
conversationType="direct"
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={noop}
/>
);
const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
const renderTypingBubble = () => (
<TypingBubble
acceptedMessageRequest
color="red"
conversationType="direct"
phoneNumber="+18005552222"
i18n={i18n}
isMe={false}
title="title"
sharedGroupNames={[]}
/>
);
@ -319,7 +341,14 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false),
haveOldest: boolean('haveOldest', overrideProps.haveOldest !== false),
isLoadingMessages: false,
isIncomingMessageRequest: boolean(
'isIncomingMessageRequest',
overrideProps.isIncomingMessageRequest === true
),
isLoadingMessages: boolean(
'isLoadingMessages',
overrideProps.isLoadingMessages === false
),
items: overrideProps.items || Object.keys(items),
resetCounter: 0,
scrollToIndex: overrideProps.scrollToIndex,
@ -351,6 +380,40 @@ story.add('Oldest and Newest', () => {
return <Timeline {...props} />;
});
story.add('With active message request', () => {
const props = createProps({
isIncomingMessageRequest: true,
});
return <Timeline {...props} />;
});
story.add('Without Newest Message', () => {
const props = createProps({
haveNewest: false,
});
return <Timeline {...props} />;
});
story.add('Without newest message, active message request', () => {
const props = createProps({
haveOldest: false,
isIncomingMessageRequest: true,
});
return <Timeline {...props} />;
});
story.add('Without Oldest Message', () => {
const props = createProps({
haveOldest: false,
scrollToIndex: -1,
});
return <Timeline {...props} />;
});
story.add('Empty (just hero)', () => {
const props = createProps({
items: [],
@ -384,36 +447,17 @@ story.add('Typing Indicator', () => {
return <Timeline {...props} />;
});
story.add('Without Newest Message', () => {
const props = createProps({
haveNewest: false,
});
return <Timeline {...props} />;
});
story.add('Without Oldest Message', () => {
const props = createProps({
haveOldest: false,
scrollToIndex: -1,
});
return <Timeline {...props} />;
});
story.add('With invited contacts for a newly-created group', () => {
const props = createProps({
invitedContactsForNewlyCreatedGroup: [
{
getDefaultConversation({
id: 'abc123',
title: 'John Bon Bon Jovi',
type: 'direct',
},
{
}),
getDefaultConversation({
id: 'def456',
title: 'Bon John Bon Jovi',
type: 'direct',
},
}),
],
});

View File

@ -47,9 +47,10 @@ export type PropsDataType = {
type PropsHousekeepingType = {
id: string;
unreadCount?: number;
typingContact?: unknown;
isGroupV1AndDisabled?: boolean;
isIncomingMessageRequest: boolean;
typingContact?: unknown;
unreadCount?: number;
selectedMessageId?: string;
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
@ -65,6 +66,7 @@ type PropsHousekeepingType = {
renderHeroRow: (
id: string,
resizeHeroRow: () => unknown,
unblurAvatar: () => void,
updateSharedGroups: () => unknown
) => JSX.Element;
renderLoadingRow: (id: string) => JSX.Element;
@ -87,6 +89,7 @@ type PropsActionsType = {
markMessageRead: (messageId: string) => unknown;
selectMessage: (messageId: string, conversationId: string) => unknown;
clearSelectedMessage: () => unknown;
unblurAvatar: () => void;
updateSharedGroups: () => unknown;
} & MessageActionsType &
SafetyNumberActionsType;
@ -167,11 +170,16 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
constructor(props: PropsType) {
super(props);
const { scrollToIndex } = this.props;
const oneTimeScrollRow = this.getLastSeenIndicatorRow();
const { scrollToIndex, isIncomingMessageRequest } = this.props;
const oneTimeScrollRow = isIncomingMessageRequest
? undefined
: this.getLastSeenIndicatorRow();
// We only stick to the bottom if this is not an incoming message request.
const atBottom = !isIncomingMessageRequest;
this.state = {
atBottom: true,
atBottom,
atTop: false,
oneTimeScrollRow,
propScrollToIndex: scrollToIndex,
@ -331,6 +339,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
haveNewest,
haveOldest,
id,
isIncomingMessageRequest,
setIsNearBottom,
setLoadCountdownStart,
} = this.props;
@ -353,8 +362,12 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
scrollHeight - clientHeight - scrollTop
);
const atBottom =
haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD;
// If there's an active message request, we won't stick to the bottom of the
// conversation as new messages come in.
const atBottom = isIncomingMessageRequest
? false
: haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD;
const isNearBottom =
haveNewest && this.offsetFromBottom <= NEAR_BOTTOM_THRESHOLD;
const atTop = scrollTop <= AT_TOP_THRESHOLD;
@ -552,6 +565,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
renderLoadingRow,
renderLastSeenIndicator,
renderTypingBubble,
unblurAvatar,
updateSharedGroups,
} = this.props;
@ -567,7 +581,12 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
if (haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={styleWithWidth} role="row">
{renderHeroRow(id, this.resizeHeroRow, updateSharedGroups)}
{renderHeroRow(
id,
this.resizeHeroRow,
unblurAvatar,
updateSharedGroups
)}
</div>
);
} else if (!haveOldest && row === 0) {
@ -730,10 +749,12 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
selectMessage(lastMessageId, id);
}
const oneTimeScrollRow =
items && items.length > 0 ? items.length - 1 : undefined;
this.setState({
propScrollToIndex: undefined,
oneTimeScrollRow: undefined,
atBottom: true,
oneTimeScrollRow,
});
};
@ -804,8 +825,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
public componentDidUpdate(prevProps: PropsType): void {
const {
id,
clearChangedMessages,
id,
isIncomingMessageRequest,
items,
messageHeightChangeIndex,
oldestUnreadIndex,
@ -830,12 +852,17 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.resize();
}
const oneTimeScrollRow = this.getLastSeenIndicatorRow();
// We want to come in at the top of the conversation if it's a message request
const oneTimeScrollRow = isIncomingMessageRequest
? undefined
: this.getLastSeenIndicatorRow();
const atBottom = !isIncomingMessageRequest;
// TODO: DESKTOP-688
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
oneTimeScrollRow,
atBottom: true,
atBottom,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
});
@ -954,13 +981,13 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
const rowCount = this.getRowCount();
const targetMessage = isNumber(propScrollToIndex)
const targetMessageRow = isNumber(propScrollToIndex)
? this.fromItemIndexToRow(propScrollToIndex)
: undefined;
const scrollToBottom = atBottom ? rowCount - 1 : undefined;
if (isNumber(targetMessage)) {
return targetMessage;
if (isNumber(targetMessageRow)) {
return targetMessageRow;
}
if (isNumber(oneTimeScrollRow)) {

View File

@ -84,8 +84,10 @@ storiesOf('Components/Conversation/TimelineItem', module)
id: 'id-1',
direction: 'incoming',
timestamp: Date.now(),
authorPhoneNumber: '(202) 555-2001',
authorColor: 'green',
author: {
phoneNumber: '(202) 555-2001',
color: 'green',
},
text: '🔥',
},
} as TimelineItemProps['item'];

View File

@ -1,9 +1,10 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import * as moment from 'moment';
import { storiesOf } from '@storybook/react';
import { boolean, select, text } from '@storybook/addon-knobs';
import { boolean, number, select, text } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
@ -30,60 +31,69 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
text('profileName', overrideProps.profileName || '') || undefined,
title: text('title', overrideProps.title || ''),
name: text('name', overrideProps.name || '') || undefined,
disabled: boolean('disabled', overrideProps.disabled || false),
timespan: text('timespan', overrideProps.timespan || ''),
...(boolean('disabled', overrideProps.disabled || false)
? {
disabled: true,
}
: {
disabled: false,
expireTimer: number(
'expireTimer',
('expireTimer' in overrideProps ? overrideProps.expireTimer : 0) || 0
),
}),
});
story.add('Set By Other', () => {
const props = createProps({
expireTimer: moment.duration(1, 'hour').asSeconds(),
type: 'fromOther',
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
title: 'Mr. Fire',
timespan: '1 hour',
});
return (
<>
<TimerNotification {...props} />
<div style={{ padding: '1em' }} />
<TimerNotification {...props} disabled timespan="Off" />
<TimerNotification {...props} disabled />
</>
);
});
story.add('Set By You', () => {
const props = createProps({
expireTimer: moment.duration(1, 'hour').asSeconds(),
type: 'fromMe',
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
title: 'Mr. Fire',
timespan: '1 hour',
});
return (
<>
<TimerNotification {...props} />
<div style={{ padding: '1em' }} />
<TimerNotification {...props} disabled timespan="Off" />
<TimerNotification {...props} disabled />
</>
);
});
story.add('Set By Sync', () => {
const props = createProps({
expireTimer: moment.duration(1, 'hour').asSeconds(),
type: 'fromSync',
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
title: 'Mr. Fire',
timespan: '1 hour',
});
return (
<>
<TimerNotification {...props} />
<div style={{ padding: '1em' }} />
<TimerNotification {...props} disabled timespan="Off" />
<TimerNotification {...props} disabled />
</>
);
});

View File

@ -1,12 +1,13 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { FunctionComponent, ReactNode } from 'react';
import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
import * as expirationTimer from '../../util/expirationTimer';
export type TimerNotificationType =
| 'fromOther'
@ -14,15 +15,22 @@ export type TimerNotificationType =
| 'fromSync'
| 'fromMember';
// We can't always use destructuring assignment because of the complexity of this props
// type.
/* eslint-disable react/destructuring-assignment */
export type PropsData = {
type: TimerNotificationType;
phoneNumber?: string;
profileName?: string;
title: string;
name?: string;
disabled: boolean;
timespan: string;
};
} & (
| { disabled: true }
| {
disabled: false;
expireTimer: number;
}
);
type PropsHousekeeping = {
i18n: LocalizerType;
@ -30,82 +38,74 @@ type PropsHousekeeping = {
export type Props = PropsData & PropsHousekeeping;
export class TimerNotification extends React.Component<Props> {
public renderContents(): JSX.Element | string | null {
const {
i18n,
name,
phoneNumber,
profileName,
title,
timespan,
type,
disabled,
} = this.props;
const changeKey = disabled
? 'disabledDisappearingMessages'
: 'theyChangedTheTimer';
export const TimerNotification: FunctionComponent<Props> = props => {
const { disabled, i18n, name, phoneNumber, profileName, title, type } = props;
switch (type) {
case 'fromOther':
return (
<Intl
i18n={i18n}
id={changeKey}
components={{
name: (
<ContactName
key="external-1"
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
name={name}
i18n={i18n}
/>
),
time: timespan,
}}
/>
);
case 'fromMe':
return disabled
? i18n('youDisabledDisappearingMessages')
: i18n('youChangedTheTimer', [timespan]);
case 'fromSync':
return disabled
? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]);
case 'fromMember':
return disabled
? i18n('disappearingMessagesDisabledByMember')
: i18n('timerSetByMember', [timespan]);
default:
window.log.warn('TimerNotification: unsupported type provided:', type);
return null;
}
let changeKey: string;
let timespan: string;
if (props.disabled) {
changeKey = 'disabledDisappearingMessages';
timespan = ''; // Set to the empty string to satisfy types
} else {
changeKey = 'theyChangedTheTimer';
timespan = expirationTimer.format(i18n, props.expireTimer);
}
public render(): JSX.Element {
const { timespan, disabled } = this.props;
let message: ReactNode;
switch (type) {
case 'fromOther':
message = (
<Intl
i18n={i18n}
id={changeKey}
components={{
name: (
<ContactName
key="external-1"
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
name={name}
i18n={i18n}
/>
),
time: timespan,
}}
/>
);
break;
case 'fromMe':
message = disabled
? i18n('youDisabledDisappearingMessages')
: i18n('youChangedTheTimer', [timespan]);
break;
case 'fromSync':
message = disabled
? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]);
break;
case 'fromMember':
message = disabled
? i18n('disappearingMessagesDisabledByMember')
: i18n('timerSetByMember', [timespan]);
break;
default:
window.log.warn('TimerNotification: unsupported type provided:', type);
break;
}
return (
<div className="module-timer-notification">
<div className="module-timer-notification__icon-container">
<div
className={classNames(
'module-timer-notification__icon',
disabled ? 'module-timer-notification__icon--disabled' : null
)}
/>
<div className="module-timer-notification__icon-label">
{timespan}
</div>
</div>
<div className="module-timer-notification__message">
{this.renderContents()}
</div>
return (
<div className="module-timer-notification">
<div className="module-timer-notification__icon-container">
<div
className={classNames(
'module-timer-notification__icon',
disabled ? 'module-timer-notification__icon--disabled' : null
)}
/>
<div className="module-timer-notification__icon-label">{timespan}</div>
</div>
);
}
}
<div className="module-timer-notification__message">{message}</div>
</div>
);
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -15,6 +15,8 @@ const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/TypingBubble', module);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
acceptedMessageRequest: true,
isMe: false,
i18n,
color: select(
'color',
@ -29,6 +31,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
{ group: 'group', direct: 'direct' },
overrideProps.conversationType || 'direct'
),
sharedGroupNames: [],
});
story.add('Direct', () => {

View File

@ -1,4 +1,4 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -8,15 +8,20 @@ import { TypingAnimation } from './TypingAnimation';
import { Avatar } from '../Avatar';
import { LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { ConversationType } from '../../state/ducks/conversations';
export type Props = {
avatarPath?: string;
color: ColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
export type Props = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
> & {
conversationType: 'group' | 'direct';
i18n: LocalizerType;
};
@ -24,14 +29,17 @@ export type Props = {
export class TypingBubble extends React.PureComponent<Props> {
public renderAvatar(): JSX.Element | null {
const {
acceptedMessageRequest,
avatarPath,
color,
conversationType,
i18n,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
conversationType,
i18n,
} = this.props;
if (conversationType !== 'group') {
@ -42,14 +50,17 @@ export class TypingBubble extends React.PureComponent<Props> {
<div className="module-message__author-avatar-container">
<div className="module-message__author-avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType="direct"
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={28}
/>
</div>

View File

@ -1,10 +1,10 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { Avatar } from '../Avatar';
import { Avatar, AvatarBlur } from '../Avatar';
import { Spinner } from '../Spinner';
import { LocalizerType } from '../../types/Util';
@ -45,11 +45,15 @@ export function renderAvatar({
return (
<Avatar
acceptedMessageRequest={false}
avatarPath={avatarPath}
blur={AvatarBlur.NoBlur}
color="grey"
conversationType="direct"
i18n={i18n}
isMe
title={title}
sharedGroupNames={[]}
size={size}
/>
);

View File

@ -151,14 +151,17 @@ export const ChooseGroupMembersModal: FunctionComponent<PropsType> = ({
{selectedContacts.map(contact => (
<ContactPill
key={contact.id}
acceptedMessageRequest={contact.acceptedMessageRequest}
avatarPath={contact.avatarPath}
color={contact.color}
firstName={contact.firstName}
i18n={i18n}
isMe={contact.isMe}
id={contact.id}
name={contact.name}
phoneNumber={contact.phoneNumber}
profileName={contact.profileName}
sharedGroupNames={contact.sharedGroupNames}
title={contact.title}
onClickRemove={() => {
removeSelectedContact(contact.id);

View File

@ -20,32 +20,13 @@ const story = storiesOf(
module
);
const conversation: ConversationType = {
const conversation: ConversationType = getDefaultConversation({
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: Array.from(Array(32)).map((_, i) => ({
isAdmin: i === 1,
member: getDefaultConversation({
isMe: i === 2,
}),
metadata: {
conversationId: '',
joinedAtVersion: 0,
role: 2,
},
})),
pendingMemberships: Array.from(Array(16)).map(() => ({
member: getDefaultConversation({}),
metadata: {
conversationId: '',
role: 2,
timestamp: Date.now(),
},
})),
title: 'Some Conversation',
type: 'group',
};
sharedGroupNames: [],
});
const createProps = (hasGroupLink = false): Props => ({
addMembers: async () => {
@ -58,6 +39,12 @@ const createProps = (hasGroupLink = false): Props => ({
i18n,
isAdmin: false,
loadRecentMediaItems: action('loadRecentMediaItems'),
memberships: times(32, i => ({
isAdmin: i === 1,
member: getDefaultConversation({
isMe: i === 2,
}),
})),
setDisappearingMessages: action('setDisappearingMessages'),
showAllMedia: action('showAllMedia'),
showContactModal: action('showContactModal'),
@ -91,13 +78,12 @@ story.add('as last admin', () => {
<ConversationDetails
{...props}
isAdmin
conversation={{
...conversation,
memberships: conversation.memberships?.map(membership => ({
...membership,
isAdmin: Boolean(membership.member.isMe),
})),
}}
memberships={times(32, i => ({
isAdmin: i === 2,
member: getDefaultConversation({
isMe: i === 2,
}),
}))}
/>
);
});
@ -109,15 +95,14 @@ story.add('as only admin', () => {
<ConversationDetails
{...props}
isAdmin
conversation={{
...conversation,
memberships: conversation.memberships
?.filter(membership => membership.member.isMe)
.map(membership => ({
...membership,
isAdmin: true,
})),
}}
memberships={[
{
isAdmin: true,
member: getDefaultConversation({
isMe: true,
}),
},
]}
/>
);
});

View File

@ -5,10 +5,8 @@ import React, { useState, ReactNode } from 'react';
import { ConversationType } from '../../../state/ducks/conversations';
import { assert } from '../../../util/assert';
import {
ExpirationTimerOptions,
TimerOption,
} from '../../../util/ExpirationTimerOptions';
import * as expirationTimer from '../../../util/expirationTimer';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { missingCaseError } from '../../../util/missingCaseError';
@ -20,7 +18,10 @@ import { ConversationDetailsActions } from './ConversationDetailsActions';
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
import {
ConversationDetailsMembershipList,
GroupV2Membership,
} from './ConversationDetailsMembershipList';
import { EditConversationAttributesModal } from './EditConversationAttributesModal';
import { RequestState } from './util';
@ -39,6 +40,7 @@ export type StateProps = {
i18n: LocalizerType;
isAdmin: boolean;
loadRecentMediaItems: (limit: number) => void;
memberships: Array<GroupV2Membership>;
setDisappearingMessages: (seconds: number) => void;
showAllMedia: () => void;
showContactModal: (conversationId: string) => void;
@ -61,6 +63,10 @@ export type StateProps = {
export type Props = StateProps;
const expirationTimerDefaultSet = new Set<number>(
expirationTimer.DEFAULT_DURATIONS_IN_SECONDS
);
export const ConversationDetails: React.ComponentType<Props> = ({
addMembers,
canEditGroupInfo,
@ -70,6 +76,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
i18n,
isAdmin,
loadRecentMediaItems,
memberships,
setDisappearingMessages,
showAllMedia,
showContactModal,
@ -101,7 +108,6 @@ export const ConversationDetails: React.ComponentType<Props> = ({
throw new Error('ConversationDetails rendered without a conversation');
}
const memberships = conversation.memberships || [];
const pendingMemberships = conversation.pendingMemberships || [];
const pendingApprovalMemberships =
conversation.pendingApprovalMemberships || [];
@ -194,6 +200,13 @@ export const ConversationDetails: React.ComponentType<Props> = ({
throw missingCaseError(modalState);
}
const expireTimer = conversation.expireTimer || 0;
let expirationTimerDurations = expirationTimer.DEFAULT_DURATIONS_IN_SECONDS;
if (!expirationTimerDefaultSet.has(expireTimer)) {
expirationTimerDurations = [...expirationTimerDurations, expireTimer];
}
return (
<div className="conversation-details-panel">
<ConversationDetailsHeader
@ -220,19 +233,15 @@ export const ConversationDetails: React.ComponentType<Props> = ({
label={i18n('ConversationDetails--disappearing-messages-label')}
right={
<div className="module-conversation-details-select">
<select
onChange={updateExpireTimer}
value={conversation.expireTimer || 0}
>
{ExpirationTimerOptions.map((item: typeof TimerOption) => (
<option
value={item.get('seconds')}
key={item.get('seconds')}
aria-label={item.getName(i18n)}
>
{item.getName(i18n)}
</option>
))}
<select onChange={updateExpireTimer} value={expireTimer}>
{expirationTimerDurations.map((seconds: number) => {
const label = expirationTimer.format(i18n, seconds);
return (
<option value={seconds} key={seconds} aria-label={label}>
{label}
</option>
);
})}
</select>
</div>
}

View File

@ -7,6 +7,7 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { number, text } from '@storybook/addon-knobs';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../../js/modules/i18n';
import enMessages from '../../../../_locales/en/messages.json';
import { ConversationType } from '../../../state/ducks/conversations';
@ -20,14 +21,14 @@ const story = storiesOf(
module
);
const createConversation = (): ConversationType => ({
id: '',
markedUnread: false,
type: 'group',
lastUpdated: 0,
title: text('conversation title', 'Some Conversation'),
memberships: new Array(number('conversation members length', 0)),
});
const createConversation = (): ConversationType =>
getDefaultConversation({
id: '',
type: 'group',
lastUpdated: 0,
title: text('conversation title', 'Some Conversation'),
memberships: new Array(number('conversation members length', 0)),
});
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversation: createConversation(),

View File

@ -32,6 +32,7 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
i18n={i18n}
size={80}
{...conversation}
sharedGroupNames={[]}
/>
<div>
<div className={bem('title')}>{conversation.title}</div>

View File

@ -33,8 +33,6 @@ const createMemberships = (
).map(
(_, i): GroupV2Membership => ({
isAdmin: i % 3 === 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: {} as any,
member: getDefaultConversation({
isMe: i === 2,
}),

View File

@ -8,13 +8,11 @@ import { Avatar } from '../../Avatar';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import { ConversationType } from '../../../state/ducks/conversations';
import { GroupV2MemberType } from '../../../model-types.d';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
export type GroupV2Membership = {
isAdmin: boolean;
metadata: GroupV2MemberType;
member: ConversationType;
};

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -35,20 +35,20 @@ function getConversation(
groupLink?: string,
accessControlAddFromInviteLink?: number
): ConversationType {
return {
return getDefaultConversation({
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: Array(32).fill({ member: getDefaultConversation({}) }),
pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }),
title: 'Some Conversation',
type: 'group',
sharedGroupNames: [],
groupLink,
accessControlAddFromInviteLink:
accessControlAddFromInviteLink !== undefined
? accessControlAddFromInviteLink
: AccessEnum.UNSATISFIABLE,
};
});
}
const createProps = (

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -19,15 +19,15 @@ const story = storiesOf(
module
);
const conversation: ConversationType = {
const conversation: ConversationType = getDefaultConversation({
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: Array(32).fill({ member: getDefaultConversation({}) }),
pendingMemberships: Array(16).fill({ member: getDefaultConversation({}) }),
title: 'Some Conversation',
type: 'group',
};
sharedGroupNames: [],
});
class AccessEnum {
static ANY = 0;

View File

@ -1,7 +1,8 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { times } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@ -26,50 +27,16 @@ const sortedGroupMembers = Array.from(Array(32)).map((_, i) =>
);
const conversation: ConversationType = {
acceptedMessageRequest: true,
areWeAdmin: true,
id: '',
lastUpdated: 0,
markedUnread: false,
memberships: sortedGroupMembers.map(member => ({
isAdmin: false,
member,
metadata: {
conversationId: 'abc123',
joinedAtVersion: 1,
role: 1,
},
})),
pendingMemberships: Array.from(Array(4))
.map(() => ({
member: getDefaultConversation({}),
metadata: {
addedByUserId: 'abc123',
conversationId: 'xyz789',
role: 1,
timestamp: Date.now(),
},
}))
.concat(
Array.from(Array(8)).map(() => ({
member: getDefaultConversation({}),
metadata: {
addedByUserId: 'def456',
conversationId: 'xyz789',
role: 1,
timestamp: Date.now(),
},
}))
),
pendingApprovalMemberships: Array.from(Array(5)).map(() => ({
member: getDefaultConversation({}),
metadata: {
conversationId: 'xyz789',
timestamp: Date.now(),
},
})),
isMe: false,
sortedGroupMembers,
title: 'Some Conversation',
type: 'group',
sharedGroupNames: [],
};
const createProps = (): PropsType => ({
@ -77,6 +44,23 @@ const createProps = (): PropsType => ({
conversation,
i18n,
ourConversationId: 'abc123',
pendingApprovalMemberships: times(5, () => ({
member: getDefaultConversation(),
})),
pendingMemberships: [
...times(4, () => ({
member: getDefaultConversation(),
metadata: {
addedByUserId: 'abc123',
},
})),
...times(8, () => ({
member: getDefaultConversation(),
metadata: {
addedByUserId: 'def456',
},
})),
],
revokePendingMemberships: action('revokePendingMemberships'),
});

View File

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -12,26 +12,27 @@ import { ConfirmationDialog } from '../../ConfirmationDialog';
import { PanelSection } from './PanelSection';
import { PanelRow } from './PanelRow';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
import {
GroupV2PendingAdminApprovalType,
GroupV2PendingMemberType,
} from '../../../model-types.d';
export type PropsType = {
conversation?: ConversationType;
readonly conversation?: ConversationType;
readonly i18n: LocalizerType;
ourConversationId?: string;
readonly ourConversationId?: string;
readonly pendingApprovalMemberships: ReadonlyArray<
GroupV2RequestingMembership
>;
readonly pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
readonly approvePendingMembership: (conversationId: string) => void;
readonly revokePendingMemberships: (conversationIds: Array<string>) => void;
};
export type GroupV2PendingMembership = {
metadata: GroupV2PendingMemberType;
metadata: {
addedByUserId?: string;
};
member: ConversationType;
};
export type GroupV2RequestingMembership = {
metadata: GroupV2PendingAdminApprovalType;
member: ConversationType;
};
@ -56,6 +57,8 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
conversation,
i18n,
ourConversationId,
pendingMemberships,
pendingApprovalMemberships,
revokePendingMemberships,
}) => {
if (!conversation || !ourConversationId) {
@ -69,10 +72,6 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
StagedMembershipType
> | null>(null);
const allPendingMemberships = conversation.pendingMemberships || [];
const allRequestingMemberships =
conversation.pendingApprovalMemberships || [];
return (
<div className="conversation-details-panel">
<div className="module-conversation-details__tabs">
@ -94,7 +93,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
tabIndex={0}
>
{i18n('PendingInvites--tab-requests', {
count: String(allRequestingMemberships.length),
count: String(pendingApprovalMemberships.length),
})}
</div>
@ -116,7 +115,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
tabIndex={0}
>
{i18n('PendingInvites--tab-invites', {
count: String(allPendingMemberships.length),
count: String(pendingMemberships.length),
})}
</div>
</div>
@ -125,7 +124,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
<MembersPendingAdminApproval
conversation={conversation}
i18n={i18n}
memberships={allRequestingMemberships}
memberships={pendingApprovalMemberships}
setStagedMemberships={setStagedMemberships}
/>
) : null}
@ -134,7 +133,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
conversation={conversation}
i18n={i18n}
members={conversation.sortedGroupMembers || []}
memberships={allPendingMemberships}
memberships={pendingMemberships}
ourConversationId={ourConversationId}
setStagedMemberships={setStagedMemberships}
/>
@ -232,12 +231,12 @@ function getConfirmationMessage({
members,
ourConversationId,
stagedMemberships,
}: {
}: Readonly<{
i18n: LocalizerType;
members: Array<ConversationType>;
members: ReadonlyArray<ConversationType>;
ourConversationId: string;
stagedMemberships: Array<StagedMembershipType>;
}): string {
stagedMemberships: ReadonlyArray<StagedMembershipType>;
}>): string {
if (!stagedMemberships || !stagedMemberships.length) {
return '';
}
@ -299,12 +298,12 @@ function MembersPendingAdminApproval({
i18n,
memberships,
setStagedMemberships,
}: {
}: Readonly<{
conversation: ConversationType;
i18n: LocalizerType;
memberships: Array<GroupV2RequestingMembership>;
memberships: ReadonlyArray<GroupV2RequestingMembership>;
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => void;
}) {
}>) {
return (
<PanelSection>
{memberships.map(membership => (
@ -370,14 +369,14 @@ function MembersPendingProfileKey({
memberships,
ourConversationId,
setStagedMemberships,
}: {
}: Readonly<{
conversation: ConversationType;
i18n: LocalizerType;
members: Array<ConversationType>;
memberships: Array<GroupV2PendingMembership>;
memberships: ReadonlyArray<GroupV2PendingMembership>;
ourConversationId: string;
setStagedMemberships: (stagedMembership: Array<StagedMembershipType>) => void;
}) {
}>) {
const groupedPendingMemberships = _.groupBy(
memberships,
membership => membership.metadata.addedByUserId

View File

@ -9,8 +9,8 @@ import { Avatar, AvatarSize } from '../Avatar';
import { Timestamp } from '../conversation/Timestamp';
import { isConversationUnread } from '../../util/isConversationUnread';
import { cleanId } from '../_util';
import { ColorType } from '../../types/Colors';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
const BASE_CLASS_NAME =
'module-conversation-list__item--contact-or-conversation';
@ -23,33 +23,40 @@ export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
type PropsType = {
avatarPath?: string;
checked?: boolean;
color?: ColorType;
conversationType: 'group' | 'direct';
disabled?: boolean;
headerDate?: number;
headerName: ReactNode;
i18n: LocalizerType;
id?: string;
isMe?: boolean;
i18n: LocalizerType;
isNoteToSelf?: boolean;
isSelected: boolean;
markedUnread?: boolean;
messageId?: string;
messageStatusIcon?: ReactNode;
messageText?: ReactNode;
name?: string;
onClick?: () => void;
phoneNumber?: string;
profileName?: string;
style: CSSProperties;
title: string;
unreadCount?: number;
};
} & Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'markedUnread'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
>;
export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo(
({
acceptedMessageRequest,
avatarPath,
checked,
color,
@ -69,8 +76,10 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
onClick,
phoneNumber,
profileName,
sharedGroupNames,
style,
title,
unblurredAvatarPath,
unreadCount,
}) => {
const isUnread = isConversationUnread({ markedUnread, unreadCount });
@ -112,16 +121,20 @@ export const BaseConversationListItem: FunctionComponent<PropsType> = React.memo
<>
<div className={`${BASE_CLASS_NAME}__avatar-container`}>
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
noteToSelf={isAvatarNoteToSelf}
conversationType={conversationType}
noteToSelf={isAvatarNoteToSelf}
i18n={i18n}
isMe={isMe}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
sharedGroupNames={sharedGroupNames}
size={AvatarSize.FIFTY_TWO}
unblurredAvatarPath={unblurredAvatarPath}
/>
{isUnread && (
<div className={`${BASE_CLASS_NAME}__unread-count`}>

View File

@ -4,7 +4,7 @@
import React, { CSSProperties, FunctionComponent, ReactNode } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
import { ConversationType } from '../../state/ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { ContactName } from '../conversation/ContactName';
import { About } from '../conversation/About';
@ -17,18 +17,24 @@ export enum ContactCheckboxDisabledReason {
}
export type PropsDataType = {
about?: string;
avatarPath?: string;
color?: ColorType;
disabledReason?: ContactCheckboxDisabledReason;
id: string;
isMe?: boolean;
isChecked: boolean;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
} & Pick<
ConversationType,
| 'about'
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarPath'
>;
type PropsHousekeepingType = {
i18n: LocalizerType;
@ -44,6 +50,7 @@ type PropsType = PropsDataType & PropsHousekeepingType;
export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
({
about,
acceptedMessageRequest,
avatarPath,
color,
disabledReason,
@ -55,8 +62,11 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
onClick,
phoneNumber,
profileName,
sharedGroupNames,
style,
title,
type,
unblurredAvatarPath,
}) => {
const disabled = Boolean(disabledReason);
@ -87,10 +97,11 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
checked={isChecked}
color={color}
conversationType="direct"
conversationType={type}
disabled={disabled}
headerName={headerName}
i18n={i18n}
@ -102,8 +113,10 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
onClick={onClickItem}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
style={style}
title={title}
unblurredAvatarPath={unblurredAvatarPath}
/>
);
}

View File

@ -4,23 +4,27 @@
import React, { CSSProperties, FunctionComponent } from 'react';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ColorType } from '../../types/Colors';
import { ConversationType } from '../../state/ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { ContactName } from '../conversation/ContactName';
import { About } from '../conversation/About';
export type PropsDataType = {
about?: string;
avatarPath?: string;
color?: ColorType;
id: string;
isMe?: boolean;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
type: 'group' | 'direct';
};
export type PropsDataType = Pick<
ConversationType,
| 'about'
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'id'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarPath'
>;
type PropsHousekeepingType = {
i18n: LocalizerType;
@ -33,6 +37,7 @@ type PropsType = PropsDataType & PropsHousekeepingType;
export const ContactListItem: FunctionComponent<PropsType> = React.memo(
({
about,
acceptedMessageRequest,
avatarPath,
color,
i18n,
@ -42,9 +47,11 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
onClick,
phoneNumber,
profileName,
sharedGroupNames,
style,
title,
type,
unblurredAvatarPath,
}) => {
const headerName = isMe ? (
i18n('noteToSelf')
@ -63,6 +70,7 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={type}
@ -76,8 +84,10 @@ export const ContactListItem: FunctionComponent<PropsType> = React.memo(
onClick={onClick ? () => onClick(id) : undefined}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
style={style}
title={title}
unblurredAvatarPath={unblurredAvatarPath}
/>
);
}

View File

@ -18,7 +18,7 @@ import { ContactName } from '../conversation/ContactName';
import { TypingAnimation } from '../conversation/TypingAnimation';
import { LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { ConversationType } from '../../state/ducks/conversations';
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
@ -27,41 +27,38 @@ export const MessageStatuses = [
'sent',
'delivered',
'read',
'paused',
'error',
'partial-sent',
] as const;
export type MessageStatusType = typeof MessageStatuses[number];
export type PropsData = {
id: string;
phoneNumber?: string;
color?: ColorType;
profileName?: string;
title: string;
name?: string;
type: 'group' | 'direct';
avatarPath?: string;
isMe?: boolean;
muteExpiresAt?: number;
lastUpdated?: number;
unreadCount?: number;
markedUnread?: boolean;
isSelected?: boolean;
acceptedMessageRequest?: boolean;
draftPreview?: string;
shouldShowDraft?: boolean;
typingContact?: unknown;
lastMessage?: {
status: MessageStatusType;
text: string;
deletedForEveryone?: boolean;
};
isPinned?: boolean;
};
export type PropsData = Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'draftPreview'
| 'id'
| 'isMe'
| 'isPinned'
| 'isSelected'
| 'lastMessage'
| 'lastUpdated'
| 'markedUnread'
| 'muteExpiresAt'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'shouldShowDraft'
| 'title'
| 'type'
| 'typingContact'
| 'unblurredAvatarPath'
| 'unreadCount'
>;
type PropsHousekeeping = {
i18n: LocalizerType;
@ -89,11 +86,13 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
onClick,
phoneNumber,
profileName,
sharedGroupNames,
shouldShowDraft,
style,
title,
type,
typingContact,
unblurredAvatarPath,
unreadCount,
}) => {
const headerName = isMe ? (
@ -180,6 +179,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color}
conversationType={type}
@ -196,9 +196,11 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
onClick={onClickItem}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
style={style}
title={title}
unreadCount={unreadCount}
unblurredAvatarPath={unblurredAvatarPath}
/>
);
}

View File

@ -18,12 +18,15 @@ export const CreateNewGroupButton: FunctionComponent<PropsType> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={false}
color="grey"
conversationType="group"
headerName={title}
i18n={i18n}
isMe={false}
isSelected={false}
onClick={onClick}
sharedGroupNames={[]}
style={style}
title={title}
/>

View File

@ -9,6 +9,7 @@ import { boolean, text, withKnobs } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { MessageSearchResult, PropsType } from './MessageSearchResult';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/MessageSearchResult', module);
@ -17,22 +18,23 @@ const story = storiesOf('Components/MessageSearchResult', module);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
story.addDecorator((withKnobs as any)({ escapeHTML: false }));
const someone = {
const someone = getDefaultConversation({
title: 'Some Person',
name: 'Some Person',
phoneNumber: '(202) 555-0011',
};
});
const me = {
const me = getDefaultConversation({
title: 'Me',
name: 'Me',
isMe: true,
};
});
const group = {
const group = getDefaultConversation({
title: 'Group Chat',
name: 'Group Chat',
};
type: 'group',
});
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,

View File

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
@ -14,8 +14,8 @@ import { ContactName } from '../conversation/ContactName';
import { assert } from '../../util/assert';
import { BodyRangesType, LocalizerType } from '../../types/Util';
import { ColorType } from '../../types/Colors';
import { BaseConversationListItem } from './BaseConversationListItem';
import { ConversationType } from '../../state/ducks/conversations';
export type PropsDataType = {
isSelected?: boolean;
@ -29,15 +29,19 @@ export type PropsDataType = {
body: string;
bodyRanges: BodyRangesType;
from: {
phoneNumber?: string;
title: string;
isMe?: boolean;
name?: string;
color?: ColorType;
profileName?: string;
avatarPath?: string;
};
from: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'unblurredAvatarPath'
>;
to: {
groupName?: string;
@ -192,6 +196,7 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
return (
<BaseConversationListItem
acceptedMessageRequest={from.acceptedMessageRequest}
avatarPath={from.avatarPath}
color={from.color}
conversationType="direct"
@ -207,8 +212,10 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
onClick={onClickItem}
phoneNumber={from.phoneNumber}
profileName={from.profileName}
sharedGroupNames={from.sharedGroupNames}
style={style}
title={from.title}
unblurredAvatarPath={from.unblurredAvatarPath}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More