Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35e0b8e85 | ||
|
|
248ebe2158 | ||
|
|
2adab4d52b | ||
|
|
b53a61fbde | ||
|
|
27b44aae50 | ||
|
|
a9149c870c | ||
|
|
450a5408f1 | ||
|
|
be9d5840be | ||
|
|
76e42c8a3c | ||
|
|
c38c8ea02b | ||
|
|
03d6824d3a | ||
|
|
6f5f229240 | ||
|
|
8553aeee71 | ||
|
|
862d20fc86 | ||
|
|
279e0dc5b8 | ||
|
|
857cf54f9b | ||
|
|
6005c08327 | ||
|
|
a17cb83d7e | ||
|
|
c305622f11 | ||
|
|
dbaa180099 | ||
|
|
940b972bc5 | ||
|
|
49ae89fb47 | ||
|
|
53b050ffd4 | ||
|
|
4486c40487 | ||
|
|
d23edf2c3b | ||
|
|
581552282e | ||
|
|
1db1d2156d | ||
|
|
5cdbc8be03 | ||
|
|
3cbdb4e353 | ||
|
|
c3814c30f9 | ||
|
|
5c83ded376 |
21
.github/workflows/benchmark.yml
vendored
21
.github/workflows/benchmark.yml
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
BIN
fixtures/wide.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
1
images/icons/v2/click-outline-24.svg
Normal file
1
images/icons/v2/click-outline-24.svg
Normal 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 |
@ -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);
|
||||
|
||||
@ -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
70
main.js
@ -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;
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
148
stylesheets/components/Avatar.scss
Normal file
148
stylesheets/components/Avatar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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')
|
||||
);
|
||||
|
||||
122
ts/background.ts
122
ts/background.ts
@ -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
485
ts/challenge.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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} />;
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 || ''),
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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>
|
||||
|
||||
33
ts/components/CaptchaDialog.stories.tsx
Normal file
33
ts/components/CaptchaDialog.stories.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
99
ts/components/CaptchaDialog.tsx
Normal file
99
ts/components/CaptchaDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'),
|
||||
});
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -19,6 +19,7 @@ const defaultProps = {
|
||||
socketStatus: 0,
|
||||
manualReconnect: action('manual-reconnect'),
|
||||
withinConnectingGracePeriod: false,
|
||||
challengeStatus: 'idle' as const,
|
||||
};
|
||||
|
||||
const permutations = [
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -11,6 +11,7 @@ export const SpinnerDirections = [
|
||||
'outgoing',
|
||||
'incoming',
|
||||
'on-background',
|
||||
'on-captcha',
|
||||
'on-progress-dialog',
|
||||
'on-avatar',
|
||||
] as const;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 */
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.',
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -32,6 +32,7 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
|
||||
i18n={i18n}
|
||||
size={80}
|
||||
{...conversation}
|
||||
sharedGroupNames={[]}
|
||||
/>
|
||||
<div>
|
||||
<div className={bem('title')}>{conversation.title}</div>
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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'),
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`}>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user