Avatar defaults and colors

This commit is contained in:
Josh Perez 2021-08-05 20:17:05 -04:00 committed by GitHub
parent a001882d58
commit 12d2b1bf7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
140 changed files with 4212 additions and 1084 deletions

View File

@ -949,6 +949,10 @@
"message": "Photo",
"description": "Shown in a quotation of a message containing a photo if no text was originally provided with that image"
},
"text": {
"message": "Text",
"description": "Label for the word 'text'"
},
"cannotUpdate": {
"message": "Cannot Update",
"description": "Shown as the title of our update error dialogs on windows"
@ -1001,6 +1005,10 @@
"accept": {
"message": "Accept"
},
"done": {
"message": "Done",
"description": "Label for done"
},
"on": {
"message": "On",
"description": "Label for when something is turned on"
@ -5792,7 +5800,7 @@
"message": "Last Name (Optional)",
"description": "Placeholder text for last name field"
},
"ProfileEditor--discard": {
"ConfirmDiscardDialog--discard": {
"message": "Would you like to discard these changes?",
"description": "ConfirmationDialog text for discarding changes"
},
@ -5863,5 +5871,17 @@
"AnnouncementsOnlyGroupBanner--admins": {
"message": "admins",
"description": "Clickable text describing administrators of a group, used in the message an admin label"
},
"AvatarEditor--choose": {
"message": "Select an avatar",
"description": "Label for the avatar selector"
},
"AvatarColorPicker--choose": {
"message": "Choose a color",
"description": "Label for when you need to choose your fighter, err color"
},
"LeftPaneSetGroupMetadataHelper__avatar-modal-title": {
"message": "Group Avatar",
"description": "Title for the avatar picker in the group creation flow"
}
}

View File

@ -34,6 +34,7 @@ try {
}
const PATH = 'attachments.noindex';
const AVATAR_PATH = 'avatars.noindex';
const STICKER_PATH = 'stickers.noindex';
const TEMP_PATH = 'temp';
const DRAFT_PATH = 'drafts.noindex';
@ -87,6 +88,13 @@ export const getPath = (userDataPath: string): string => {
return join(userDataPath, PATH);
};
export const getAvatarsPath = (userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");
}
return join(userDataPath, AVATAR_PATH);
};
export const getStickersPath = (userDataPath: string): string => {
if (!isString(userDataPath)) {
throw new TypeError("'userDataPath' must be a string");

View File

@ -0,0 +1 @@
<svg fill="none" height="1024" viewBox="0 0 56 56" width="1024" xmlns="http://www.w3.org/2000/svg"><path d="m23.5145 55.65c1.448.2303 2.933.35 4.446.35 15.4858 0 28.0395-12.536 28.0395-28s-12.5537-28-28.0395-28c-2.1706 0-4.2836.2463-6.3126.712438 1.092 1.340602 4.2071 5.459042 5.1014 8.629822 1.0603 3.76044 2.7705 12.15494-2.9416 18.65124s-6.6358 7.7689-6.6358 7.7689l14.0582-1.2403s-7.7986 4.8534-8.2433 15.3869c.1359 1.1476.3647 3.8272.5277 5.741z" fill="#5aa87d"/><g fill="#043419"><path d="m20.9593.892662c.3962-.102631.7959-.196802 1.1987-.28231.9304.870948 1.7265 1.950248 2.4535 3.022328.7312 1.07818 1.3826 2.21849 1.9443 3.41362 1.1034 2.35007 1.8512 4.9231 2.0506 7.5655.2027 2.7119-.2227 5.4055-1.1699 7.9128-.9804 2.6022-2.4527 4.9449-4.1344 7.0574-1.6537 2.0813-3.5101 3.9491-5.3827 5.7849.6421-.0541 1.2841-.1063 1.9262-.1574 3.7091-.297 7.4247-.5406 11.1438-.7275.2326-.01.5483.0767.6846.277.1363.2002.0133.3704-.1562.4905l-.0002.0002c-.1728.1234-.3455.2468-.5149.3769-.0057.0044-.0114.0087-.017.013-.0065.005-.013.01-.0194.0149l-.0276.0211-.0018.0014-.0267.0204c-.0029.002-.0502.038-.0638.0493-.0382.0301-.0764.0609-.1146.0918l-.0004.0003c-.0381.0308-.0762.0615-.1143.0915-.3157.2569-.6248.5239-.9239.8009-.5949.5472-1.1533 1.1279-1.6751 1.7452-.9372 1.1046-1.6916 2.2225-2.373 3.5306-.7012 1.3449-1.2463 2.7698-1.6152 4.2414-.0198.0787-.0392.1576-.058.2366.0349.0081.0697.016.1045.0237.0316.0067.064.0133.0964.02l.066.0137c.0273.0029.0525.008.0792.0135l.0144.0029c.2492.0467.5018.0867.7544.1168.0414.0042.0832.0088.1251.0134l.0002.0001c.0891.0098.1787.0197.2669.0265.0327.0033.0647.0058.0965.0082l.0011.0001h.0004l.0004.0001h.0004c.0322.0025.0643.005.0972.0083.0123.0012.0215.0021.0281.0028l.0052.0002.003.0001c.0035.0001.0069.0002.0103.0002.2625.0134.5251.02.7877.0167.113 0 .2228-.0031.3357-.0064h.0001l.0098-.0003c.0335-.0011.0725-.006.1133-.0111h.0005c.0806-.0101.1679-.021.2318-.0056-.0299-.0066-.1562.0101-.0498.0034.0332 0 .0665-.0034.0997-.0067.0285-.0032.0562-.0056.0836-.008h.0002c.0176-.0015.0351-.0031.0525-.0048.0122-.0012.0244-.0024.0365-.0039h.0003c.1063-.01.2125-.02.3188-.0333.2293-.0267.4586-.0601.6846-.1002.0665-.01.1296-.02.1961-.0333.0036-.0006.0112-.002.021-.0039.0075-.0014.0162-.0032.0255-.0051.0134-.0027.0279-.0057.0413-.0084l.0012-.0003.0269-.0055c.0332-.0074.0779-.0171.0935-.0202.0565-.01.113-.0234.1695-.0367.452-.1035.8973-.2303 1.336-.3804.0095-.0035.0189-.0069.0283-.0103.0055-.0019.0109-.0039.0164-.0058.0167-.0058.0333-.0116.0499-.0173h.0001c.0316-.0109.0632-.0217.0948-.0334l.0079-.0034.006-.0025.0037-.0016c.0034-.0014.0067-.0028.0101-.0042.0066-.0024.0139-.005.0203-.0073l-.0182.0064c.0119-.0047.0237-.0092.0356-.0128.1078-.0395.2156-.0821.3201-.1248.216-.0867.4287-.1801.6415-.2769.4254-.1969.8408-.4138 1.2429-.6541.0152-.0089.0306-.0179.046-.0269.034-.0197.0685-.0398.1021-.0608-.0034.0027.0854-.0519.1012-.0625.0931-.06.1861-.1201.2759-.1802.2027-.1368.4021-.2769.5982-.4238.7445-.5572 1.479-1.2313 2.0672-1.9088.2227-.2569.6016-.2869.9206-.2502.1396.0167.3324.0667.4454.1635.0631.0534.1529.1869.0764.2736-.7644.8777-1.6185 1.6652-2.569 2.3326-1.7183 1.2047-3.6659 2.0123-5.7198 2.416-1.752.345-3.5743.3514-5.3301.0192-.547 2.7943-.4675 5.7448.234 8.4976-.5252-.0743-1.0458-.1631-1.5613-.2661-.3729-1.6232-.5493-3.2872-.5114-4.9536.0731-3.2303.9339-6.4372 2.5125-9.2537 1.2455-2.2193 2.8981-4.1819 4.86-5.7855-.4878.0267-.9756.0543-1.4633.0825-1.8313.1068-3.6625.2269-5.4905.3604-.9087.0657-1.8207.1378-2.7295.21.0523-.0041-.0071.0011-.0587.0056l-.0053.0004c-.0164.0015-.0316.0028-.0416.0037l-.0065.0006c-.0332.0033-.0665.0058-.0997.0083s-.0665.005-.0997.0083c-.0548.005-.1097.0092-.1645.0134s-.1097.0083-.1645.0133c-.1065.0096-.2123.0184-.3177.0272-.1148.0096-.2293.0191-.3437.0296l-1.3859.1201c-.2892.0234-.7777.0133-.9206-.3137-.0403-.092-.0312-.1766.0081-.2505-.0273-.0088-.0444-.0175-.038-.0245.2525-.2448.5018-.4897.7511-.7346 1.9941-1.9663 3.9583-3.9801 5.6632-6.2571 1.602-2.1381 2.9713-4.5247 3.8221-7.1452.8242-2.5328 1.0901-5.2264.7644-7.8944-.3224-2.6315-1.1699-5.17165-2.3564-7.47785-.5982-1.16224-1.2829-2.26966-2.0406-3.31129-.5122-.7059-1.0535-1.4262-1.6681-2.045598z"/><path d="m32.9454 44.8459c.0083-.0052.0166-.0104.0248-.0157-.0085.0053-.0146.0091-.0186.0117l-.0011.0007-.0034.0022c-.0009.0006-.0014.0009-.0017.0011z"/><path d="m30.5572 46.0022-.0021.0009c-.0091.0032-.0171.0061-.0205.0072-.0047.0017-.0004.0001.0226-.0081z"/><path d="m30.9102 35.6974-.0007.0005c-.0109.0083-.0219.0168-.0331.0255.0266-.0204.0351-.027.0338-.026z"/><path d="m35.6179 21.7339c0 1.3622-.6843 2.4665-1.5285 2.4665-.8441 0-1.5284-1.1043-1.5284-2.4665s.6843-2.4665 1.5284-2.4665c.8442 0 1.5285 1.1043 1.5285 2.4665z"/><path d="m20.5515 21.8983c0 1.3925-.7088 2.5213-1.5831 2.5213s-1.583-1.1288-1.583-2.5213.7087-2.5213 1.583-2.5213 1.5831 1.1288 1.5831 2.5213z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="1024" viewBox="0 0 56 56" width="1024" xmlns="http://www.w3.org/2000/svg"><path d="m25.7169 40.6825c-.4039-.2426-5.6409-5.8106-5.7755-6.1745-.1347-.3639-3.6215-8.2246-3.7561-8.5885s-.5385-5.3254-.6731-5.6893c-.1347-.3639 1.077-5.8106 1.077-5.8106l3.3589-3.6331 4.9677-2.54138 5.7755.24261 5.5063 3.99707 2.1473 6.1745.2692 6.1745-3.0897 8.5946-4.9677 4.8341-2.9551 2.42.1347 1.2131-1.3463 1.0918-1.6088-.7279.1346-1.2131z" fill="#e75b14"/><path clip-rule="evenodd" d="m41.0156 52.7974c-.4172-.5866-.9449-1.1063-1.5405-1.5503-1.5596-1.163-3.4755-1.6739-5.3713-1.9966-.4998-.0854-.9996-.1589-1.4993-.2324h-.0002c-.5623-.0827-1.1247-.1654-1.687-.265l-.0143-.0027c-.8428-.1538-1.731-.3159-2.4663-.7772-1.6047-1.0122-1.3792-3.1407-1.0661-4.7987.6238-.1626 1.205-.5158 1.5188-1.0777.3107-.5583.1657-1.2007-.2402-1.6209 4.3478-2.9791 7.6654-7.1377 9.4109-11.8111.9546-2.5593 1.4655-5.2703 1.3983-7.9691-.0739-2.905-.8403-5.901-2.7092-8.3088-1.7075-2.2015-4.1881-3.86931-7.1057-4.48792-2.9647-.6368-6.1512.0182-8.6788 1.51014-2.5882 1.52828-4.4168 3.85118-5.3579 6.49538-1.0017 2.8201-1.0487 5.8343-.4572 8.7272.632 3.0385 1.8756 5.9557 3.3344 8.7394 1.237 2.3592 2.7764 4.7427 5.0823 6.3923.4168.2982.8472.5718 1.2986.8141-.3995.3555-.5719.9301-.4191 1.4722.1662.5795.6867.9339 1.2564 1.1052-.2866 1.555-.4401 3.2582.5228 4.61 1.0906 1.5333 3.2467 1.8605 5.0015 2.1267.0839.0127.1669.0253.2488.0379.2839.0431.5706.0837.8588.1245l.0002.0001c1.6429.2325 3.3325.4717 4.8081 1.2266 1.0003.5145 1.9231 1.2416 2.517 2.1838.4589-.2105.9111-.433 1.3562-.6671zm-19.9568-17.9707c1.331 2.1772 3.0587 4.2332 5.5528 5.3976.0569.0262.1027.0574.1381.092.4538-.2887.8976-.5934 1.3274-.9107 4.3025-3.1901 7.415-7.6053 8.8603-12.445 1.4857-4.9974 1.3714-11.2198-2.6352-15.2772-1.7344-1.75877-4.2016-3.08696-6.7965-3.13548-2.642-.04852-5.2436 1.10379-7.1326 2.79588-4.4907 4.0331-4.5781 10.395-2.9445 15.5562.8672 2.7352 2.1109 5.434 3.6302 7.9267zm5.4164 6.2978c-.0666.0633-.1495.1125-.2412.1387-.0397.0122-.0627.0188-.0704.0193-.0307.0223-.0264.0215.0099.0008-.0577.1226-.0438.0789-.0341.0483.0084-.0264.0136-.0431-.0331.066-.0471.1076-.0605.242-.0404.363.0269.1546.1009.2488.2219.3362.0739.047.2084.0672.2622.0672.0806 0 .2353-.0202.3764-.0874.1479-.074.2891-.1815.3765-.3294.0672-.1143.1008-.2756.0739-.4101-.0056-.0283-.016-.0648-.0292-.1017-.2955.0551-.645.0093-.8724-.1109z" fill="#4b064b" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="1024" viewBox="0 0 56 56" width="1024" xmlns="http://www.w3.org/2000/svg"><path d="m24.6686 55.804 1.8624-12.4916 3.0721-10.1906 3.7927-2.6159 7.9422-1.5194 4.0802-1.8162 1.3854-1.4546.0766-2.8397-1.3061-3.5713-2.1853-1.1671-6.0407-.953-3.132-.2229s-1.0178-.5853-1.2369-.5856c-.219-.0003-.5084-2.114-.5084-2.114l-1.5254-3.4256-2.2579-1.45923-2.7671-.07649-3.7173.65232-2.9149 1.3067-1.2408 2.5461s.2878 3.4241.2875 3.6431.2153 2.986.2153 2.986l-1.3146 3.1301s-3.3592 6.2592-3.3596 6.5512c-.0003.2443-2.0989 6.196-2.8268 8.2603-.142.4025-.2318.6572-.2437.6929-.0733.2189-1.39383 8.0831-1.39383 8.0831l-.00225 1.7856c4.16328 3.6992 9.43748 6.1756 15.25918 6.8658z" fill="#8cc63f"/><g fill="#000"><path d="m25.1992 55.8616c.0384-.6215.0821-1.2427.1318-1.8636.4229-5.3176 1.2838-10.6382 2.9987-15.7023.7146-2.1161 1.5534-4.3087 3.096-5.9675.7787-.8385 1.7396-1.4542 2.8207-1.8215 1.0275-.3508 2.117-.4909 3.1912-.629.1172-.0151.2341-.0301.3508-.0454 2.5596-.3326 5.0647-.9609 7.3152-2.2721.9353-.5463 1.8234-1.2934 2.1314-2.3698.3447-1.1968-.0004-2.5952-.3238-3.7636-.3017-1.0844-.8225-2.0633-1.7928-2.685-.9521-.6107-2.0798-.864-3.1858-1.0406-.9285-.1494-1.8644-.2711-2.7999-.3926h-.0001l-.0005-.0001c-.3771-.049-.7541-.098-1.1306-.1488l-.0008-.0001c-.7115-.0958-1.423-.1915-2.1345-.291-.1679-.0221-.3358-.0451-.5037-.0681s-.3358-.0461-.5037-.0682c-.0729-.01-.1477-.0182-.2234-.0265h-.0002c-.2313-.0254-.4699-.0516-.6816-.1316-.5086-.1914-.6575-.8058-.7767-1.2979-.0108-.0445-.0214-.0881-.0319-.1302-.0709-.2867-.1344-.5742-.1979-.8617s-.1271-.575-.1979-.8617c-.287-1.1282-.7311-2.1653-1.6172-2.9512-1.8598-1.64485-4.605-1.68481-6.9165-1.23512-1.2781.25024-2.5745.60631-3.7944 1.06472-1.0483.3928-2.0019 1.0231-2.4961 2.0591-.7298 1.5308-.4791 3.1573-.2297 4.7755.1209.7841.2414 1.5663.25 2.3347.0165 1.3542-.4486 2.5326-1.1072 3.6925-.2092.3659-.4223.7293-.6354 1.0928-.4093.6979-.8187 1.3961-1.2013 2.1132-1.1417 2.1448-2.1338 4.37-2.9691 6.6503-1.6704 4.5567-2.73513 9.3296-3.1574 14.1617-.03458.3944-.06491.7893-.09096 1.1844.35335.3335.71539.6579 1.08573.9729.20363-4.2425.90093-8.4614 2.07393-12.545.66-2.2987 1.4733-4.5498 2.4362-6.7386.9702-2.2106 2.1192-4.3153 3.3448-6.3906.6293-1.065 1.2075-2.1885 1.3332-3.4403.1044-1.0412-.0611-2.0743-.2257-3.1022l-.0001-.0003c-.0511-.3194-.1022-.6383-.1451-.9567-.141-1.0587-.1834-2.1866.1537-3.2119.3518-1.0726 1.1267-1.7944 2.1604-2.2201 1.1652-.4803 2.447-.80723 3.6776-1.05753 1.1612-.23579 2.3624-.32188 3.512-.00288 1.0108.27867 1.963.86021 2.5715 1.72601.6084.8662.8366 1.9047 1.0606 2.9241v.0003c.0282.1285.0564.2566.0852.384.0175.077.0345.1558.0518.2355.1014.4683.2103.9716.45 1.3711.3097.5151.8936.6874 1.452.783.9115.1539 1.8315.2695 2.7507.3851h.0002c.4425.0557.885.1113 1.3262.1712.4042.0553.8091.107 1.214.1587l.0007.0001c.8894.1137 1.779.2274 2.6617.3789 1.0074.1729 2.0402.4625 2.8132 1.1643.8825.8005 1.1693 2.0272 1.3906 3.1481.2395 1.2011.4094 2.4898-.3695 3.54-.6765.9117-1.7834 1.4541-2.7952 1.9091-1.1689.5241-2.3887.9095-3.6448 1.1561-.681.1328-1.3738.2243-2.0666.3158-1.711.226-3.4227.452-4.9594 1.2996-1.9216 1.0598-3.1434 2.9708-4.0001 4.9298-.9849 2.2545-1.6816 4.659-2.2614 7.0453-.6165 2.5397-1.0578 5.1233-1.3714 7.7181-.214 1.7653-.3695 3.536-.4815 5.3098.3744.0527.7511.0978 1.1301.1355z"/><path d="m30.1882 19.7988c-.0008.6036-.4909 1.0924-1.0946 1.0916-.6038-.0007-1.0926-.4907-1.0918-1.0944.0007-.6036.4908-1.0924 1.0945-1.0916.6038.0008 1.0926.4907 1.0919 1.0944z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="1024" viewBox="0 0 56 56" width="1024" xmlns="http://www.w3.org/2000/svg"><path d="m28.3918 45.6503c-1.7212.4495-3.3014-2.7437-8.3306-9.0306-2.7729-3.4672-4.0148-4.6538-5.5476-7.5914-1.1877-2.2848-1.7186-4.0232-1.8465-5.876-.0792-1.1573-.1767-2.9481.7748-4.8834.4051-.8343 1.3024-2.6653 3.3291-3.4378 1.6982-.6488 3.2511-.1909 4.2669.1054.5773.1718 2.6215.7983 4.187 2.6895 1.5656 1.8913 1.4843 3.6359 2.2348 3.6599.7619.0237.8363-1.7607 2.7084-3.7226.5278-.5522 1.5507-1.5985 3.2119-2.1434 1.5763-.5139 2.9157-.3126 3.7264-.1818 1.0639.175 2.9729.4797 4.3217 2.0002.9053 1.025 1.1315 2.1209 1.3932 3.4557.5847 3.0049.0172 5.5518-.3368 6.773-.7699 2.6727-2.0536 4.4006-3.5467 6.4087-.8374 1.1266-.4284.4237-4.351 4.9124-1.5569 1.7815-3.2546 3.4413-4.7175 5.3058-.9552 1.21-1.0628 1.4473-1.4775 1.5564z" fill="#df579a"/><g fill="#730173"><path d="m28.8814 45.3619c-2.656-2.391-5.0787-5.0287-7.4508-7.6964-2.2596-2.5395-4.521-5.1419-6.074-8.1817-1.498-2.9214-2.3704-6.3807-1.5887-9.6364.3574-1.4955 1.1121-2.911 2.3084-3.8995 1.1238-.9235 2.4873-1.2714 3.91-.9583 3.1566.6972 5.6827 3.349 6.6229 6.378.0823.2718.677.2546.8878.22.2335-.041.5792-.1596.485-.4482-.8837-2.8534-3.1277-5.2506-5.9123-6.347-1.6963-.6708-3.6661-.8995-5.4039-.2325-1.5056.5805-2.7326 1.6955-3.5048 3.1001-1.6964 3.0936-1.253 6.9537-.0398 10.1233 1.2816 3.356 3.5219 6.216 5.86 8.8962 2.4388 2.7971 4.934 5.5698 7.6404 8.1193.3412.3215.688.6371 1.0348.9526.2352.216.698.1912.9815.0974.2212-.0692.4844-.271.2435-.4869z"/><path d="m28.0482 21.446c1.0979-2.6136 3.1871-4.9874 5.9794-5.8048 1.2031-.3546 2.4289-.3215 3.6299.0437 1.2181.3647 2.3491 1.0804 3.1284 2.1032.9875 1.2968 1.306 3.0127 1.3749 4.6044.0728 1.7287-.2097 3.4448-.746 5.0883-1.0776 3.3156-3.1184 6.1792-5.4132 8.7646-2.4001 2.7083-5.1061 5.1284-7.2831 8.0302-.2811.3737-.5506.7528-.8144 1.1317-.4174.6061.9455.8295 1.2476.3924 1.1043-1.597 2.3374-3.095 3.6704-4.5044 1.2996-1.3741 2.6521-2.6984 3.9223-4.1003 2.4409-2.681 4.6901-5.6591 5.9128-9.1046.6031-1.7082.9627-3.5294.9446-5.3454-.0151-1.7132-.3214-3.6008-1.3073-5.0404-1.7149-2.5039-5.0178-3.511-7.9342-3.027-3.1386.519-5.734 2.8046-7.2001 5.5488-.179.3422-.3464.6897-.4965 1.0425-.2411.5668 1.176.6858 1.3845.1771z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="1024" viewBox="0 0 56 56" width="1024" xmlns="http://www.w3.org/2000/svg"><path d="m55.9223 30.1023h-17.5777l-38.140829 1.293c-.1345326-1.113-.203771-2.246-.203771-3.3953 0-15.464 12.536-28 28-28s28 12.536 28 28c0 .7072-.0262 1.4083-.0777 2.1023z" fill="#fa775c"/><path d="m11.7497 28.3088s-.4794-1.9729 1.9112-5.9238c2.3906-3.951 5.0987-5.2185 5.7398-5.6428.6351-.4242 5.2605-2.116 6.0574-1.9728s4.1461-.4243 6.2191-.1432c2.0731.2811 5.8956 2.9645 7.016 3.8078 1.1144.8485 3.6668 4.6563 3.8226 5.5048.1557.8486.7968 5.9185.7968 5.9185l-31.7187.9864z" fill="#fae15c"/><g fill="#21457c"><path d="m2.56768 39.7293 19.71492-.8003c1.8322-.0745 3.6643-.1528 5.4965-.2312l.0049-.0002c4.8139-.2057 9.6278-.4115 14.4418-.5496 1.8448-.0553 3.6897-.0983 5.5346-.0983 1.3651 0 2.7621.05 4.0696.4675-3.4738 1.4819-7.1847 2.2896-10.8956 2.9332-1.1205.1955-2.2438.3774-3.3672.5593-2.9397.4761-5.8801.9522-8.7722 1.673-2.2261.5535-4.4154 1.2607-6.5124 2.2015-.1476.0677-.2706.1845-.1968.3506.2952.6826 1.0516.9962 1.7342 1.1438.7007.1505 1.4302.1823 2.1499.2137.2215.0096.442.0193.6605.0323 1.0333.0602 2.0666.1221 3.0991.1839l.0041.0002c.9693.058 1.9378.116 2.9049.1726 2.0003.1196 3.999.2392 5.9978.3589l.026.0016c1.9991.1196 3.9981.2393 5.9987.359l.3631.0214c.565.0334 1.1303.0669 1.6957.1004.2466-.2218.4893-.448.7279-.6784-2.5227-.1504-5.0454-.3012-7.568-.452-3.8835-.2321-7.7669-.4643-11.6504-.6949-.3465-.0209-.6938-.041-1.0412-.0612h-.0005l-.0012-.0001c-.6756-.0392-1.352-.0785-2.0258-.1232-.542-.0379-1.1461-.0902-1.4755-.5568 3.57-1.5519 7.3992-2.3776 11.2226-3.0407 1.0638-.184 2.1301-.3566 3.1965-.5292 2.9962-.4849 5.9933-.97 8.9367-1.7092 2.22-.5596 4.4093-1.273 6.5001-2.2262.2706-.123.2891-.3874 0-.5104-1.482-.6396-3.0809-.8425-4.686-.8978-1.3731-.0476-2.7554-.0135-4.1311.0205-.2262.0056-.4521.0111-.6779.0164-2.8883.0632-5.7766.1807-8.6649.2981h-.0006c-.4815.0196-.9629.0392-1.4444.0585-3.373.1353-6.7476.2721-10.1222.409-3.3746.1368-6.7492.2736-10.1223.4089-1.9033.0769-3.80504.1538-5.7068.2306-1.90176.0769-3.80353.1538-5.70683.2306-.00877.0003-.01803.0006-.02773.001.10259.2394.20842.4772.31744.7132z"/><path clip-rule="evenodd" d="m.201536 31.3769c2.615104-.0509 5.229924-.1021 7.844744-.1533h.00044c4.63208-.0907 9.26408-.1814 13.89768-.2706h.0003.0021c9.2174-.1784 18.4349-.3567 27.6462-.5411 2.1041-.0399 4.2093-.0807 6.3147-.1218.0231-.2859.042-.5731.0565-.8614-1.5581.0304-3.116.061-4.674.0915-2.3395.0458-4.6789.0916-7.0186.1372-.5925-3.1791-1.2604-6.4637-3.0236-9.2202-1.8633-2.9088-4.9074-4.8766-8.2097-5.7929-3.327-.9286-6.9429-.8487-10.233.2091-3.1793 1.0208-5.9528 2.9641-8.0006 5.6022-1.9433 2.5029-3.167 5.5777-3.4438 8.7448-.0345.3688-.0475.7321-.0541 1.1003-.5211.0104-1.0422.0208-1.56323.0312-2.59148.0491-5.18449.0997-7.7775.1503h-.0008l-.01723.0003c-.61212.012-1.224244.0239-1.836346.0358.025625.2875.055594.5737.089842.8586zm37.190664-1.5862c-8.184.1584-16.368.3167-24.5476.4793.1189-6.0889 3.8723-11.8751 9.4995-14.2728 5.6392-2.3984 13.0925-1.1869 16.9053 3.9111 2.1162 2.8316 2.8517 6.3734 3.49 9.7789-1.7823.0347-3.5646.0692-5.3472.1035z" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1 @@
<svg fill="none" height="1024" viewBox="0 0 56 56" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><clipPath id="a"><path d="m0 0h56v56h-56z"/></clipPath><g clip-path="url(#a)"><path d="m56 28.0001c0 15.4639-12.536 28-28 28s-28-12.5361-28-28c0-.8435.037293-1.6782.11032-2.5027l55.64778-1.1942c.1596 1.2097.2419 2.4437.2419 3.6969z" fill="#fcf6c4"/><path d="m19.6205 54.7246-2.4975-11.0677 1.1275-12.6242 2.2037-8.8076 3.1065-6.2922 4.3637-4.9594 2.6502 1.9157 4.6134 8.7895 2.0159 12.5045-.3267 14.9197-1.2153 5.8359c-2.4351.6912-5.0052 1.0612-7.6618 1.0612-2.9191 0-5.7338-.4467-8.3796-1.2754z" fill="#c3dfc3"/><path d="m27.9964 56 .7121-20.2208.8947-23.2383-1.7864-1.782-1.339 1.4739-.578 27.2497s-.3021 8.3422-.5932 16.3897c.8853.0844 1.7825.1277 2.6898.1278z" fill="#e75b14"/><path d="m28.7975 55.9888c-.2649.0075-.5308.0112-.7975.0112-.1868 0-.3732-.0018-.5591-.0055l1.4571-43.6369c.0021-.0706.0229-.1321.0579-.1849-.23-.2209-.4699-.4321-.7206-.6322-.1809.1592-.3591.3205-.5331.4848.0108.0402.0159.0835.0141.1297-.0714 2.1696-.1441 4.3378-.2168 6.5053l-.0007.019c-.0725 2.1616-.145 4.3225-.2161 6.4835l-1.0281 30.7837c-.4539-.0279-.9048-.0667-1.3526-.116l1.4144-42.3567c-1.9559 2.2512-3.4325 4.8913-4.5932 7.6332-1.5548 3.6639-2.6114 7.5376-3.198 11.4738-.6051 4.0116-.7241 8.0916-.3918 12.1305.2752 3.4141.8793 6.7933 1.761 10.0975-.5061-.1528-1.0061-.3195-1.4996-.4997-.9316-3.6496-1.5234-7.3855-1.7238-11.148-.2166-4.1007.0286-8.2312.7701-12.2713.3355-1.8187.7658-3.6288 1.3006-5.4077-.28-.0063-.5598-.0128-.8395-.0193h-.0019c-.9996-.0231-1.9983-.0463-2.9996-.0646-4.9326-.0933-9.87695-.1068-14.8012127.2242.0225107-.2677.0487887-.5343.0787797-.7999 1.038783-.0675 2.079583-.1193 3.122513-.1585 5.22703-.2013 10.45522-.089 15.68322.0366.5675-1.775 1.2417-3.5152 2.0322-5.1977 1.5552-3.306 3.6309-6.4233 6.4489-8.776.2558-.2149.6154-.2749.9261-.1411.0918.0389.19.1179.2549.2118.0496.0192.0961.0426.1381.0708.3723.2495.7222.5254 1.0606.8227 1.3427 1.1888 2.3622 2.6773 3.1892 4.2578.8887 1.6962 1.5898 3.5008 2.204 5.3137.3588 1.0657.6783 2.144.9607 3.2321.1125-.037.234-.0538.335-.0594v-.0005l.0141-.0001c.0814-.003.1639-.0071.2465-.0112.0855-.0043.1711-.0086.2553-.0116.0528-.0028.1119.0025.1724.0155 1.261-.014 2.5194-.0291 3.7819-.052 4.9057-.0843 9.8041-.2396 14.6995-.4656.0448.3065.0847.6147.1196.9244-1.1804.0542-2.3607.1049-3.5409.1522-5.2555.21-10.514.3332-15.7701.3806-.0306-.0003-.0633-.0028-.0971-.0075.6146 2.556 1.029 5.1615 1.2694 7.7802.3829 4.1651.3147 8.3605-.0669 12.5196-.2806 3.0473-.7314 6.0762-1.2986 9.0842-.474.1473-.9534.2823-1.4377.4047.6947-3.613 1.2239-7.2576 1.4985-10.9296.3099-4.1178.29-8.2669-.1759-12.3748-.4527-3.999-1.3187-7.9614-2.7161-11.7385-.8245-2.2259-1.8067-4.5792-3.2745-6.5121-.0587 1.7722-.1181 3.5435-.1775 5.3143l-.0004.0104c-.0725 2.1628-.1451 4.3249-.2162 6.487z" fill="#0b5f65"/></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m5 17.5a3 3 0 0 1 -3-3v-9.5a3 3 0 0 1 3-3h9.5a3 3 0 0 1 3 3h-1.5a1.5 1.5 0 0 0 -1.5-1.5h-9.5a1.5 1.5 0 0 0 -1.5 1.5v9.5a1.5 1.5 0 0 0 1.5 1.5zm17-8v9.5a3 3 0 0 1 -3 3h-9.5a3 3 0 0 1 -3-3v-9.5a3 3 0 0 1 3-3h9.5a3 3 0 0 1 3 3zm-14 0v4.75l.62-.93 3.14-3.15 3.5 3.51 2.5-2.51 2.06 2.06.68 1v-4.73a1.5 1.5 0 0 0 -1.5-1.5h-9.5a1.5 1.5 0 0 0 -1.5 1.5zm12.5 9.5v-3l-2.74-2.7-1.44 1.44 1.22 1.26-1.06 1-4.72-4.7-3.76 3.76v2.94a1.5 1.5 0 0 0 1.5 1.5h9.5a1.5 1.5 0 0 0 1.5-1.5z"/></svg>

After

Width:  |  Height:  |  Size: 567 B

View File

@ -0,0 +1 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m.5 18.5 5.325-14.5h1.813l5.326 14.5h-1.841l-1.459-4.1h-5.864l-1.459 4.1zm8.612-5.666-2.323-6.534h-.114l-2.323 6.538zm5.188 2.582c0-2.492 2.2-2.939 4.22-3.2 1.984-.255 2.8-.184 2.8-.991v-.057a2.02 2.02 0 0 0 -2.294-2.21 2.94 2.94 0 0 0 -2.826 1.642l-1.586-.567a4.418 4.418 0 0 1 4.363-2.549c1.387 0 4.022.4 4.022 3.852v7.164h-1.671v-1.469h-.084a3.419 3.419 0 0 1 -3.23 1.728c-2.068 0-3.714-1.218-3.714-3.343zm7.024-.85v-1.529c-.282.34-2.18.538-2.888.623-1.3.17-2.465.567-2.465 1.841 0 1.162.962 1.756 2.294 1.756a2.791 2.791 0 0 0 3.063-2.691z"/></svg>

After

Width:  |  Height:  |  Size: 644 B

View File

@ -188,6 +188,7 @@ function initializeMigrations({
createWriterForExisting,
createWriterForNew,
createDoesExist,
getAvatarsPath,
getDraftPath,
getPath,
getStickersPath,
@ -238,11 +239,17 @@ function initializeMigrations({
const deleteDraftFile = Attachments.createDeleter(draftPath);
const readDraftData = createReader(draftPath);
const avatarsPath = getAvatarsPath(userDataPath);
const getAbsoluteAvatarPath = createAbsolutePathGetter(avatarsPath);
const writeNewAvatarData = createWriterForNew(avatarsPath);
const deleteAvatar = Attachments.createDeleter(avatarsPath);
return {
attachmentsPath,
copyIntoAttachmentsDirectory,
copyIntoTempDirectory,
deleteAttachmentData: deleteOnDisk,
deleteAvatar,
deleteDraftFile,
deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({
deleteAttachmentData: Type.deleteData(deleteOnDisk),
@ -252,6 +259,7 @@ function initializeMigrations({
deleteTempFile,
doesAttachmentExist,
getAbsoluteAttachmentPath,
getAbsoluteAvatarPath,
getAbsoluteDraftPath,
getAbsoluteStickerPath,
getAbsoluteTempPath,
@ -312,6 +320,7 @@ function initializeMigrations({
logger,
}),
writeNewAttachmentData: createWriterForNew(attachmentsPath),
writeNewAvatarData,
writeNewDraftData,
};
}

View File

@ -540,3 +540,20 @@
}
}
}
@mixin avatar-colors {
@each $color, $value in $avatar-colors {
&--#{$color} {
background-color: map-get($value, 'bg');
color: map-get($value, 'fg');
&--icon {
background-color: map-get($value, 'fg');
@include dark-theme {
// For specificity
background-color: map-get($value, 'fg');
}
}
}
}
}

View File

@ -9797,11 +9797,7 @@ $contact-modal-padding: 18px;
background-color: $color-black-alpha-40;
}
@each $color, $value in $avatar-colors {
&__#{$color} {
background-color: $value;
}
}
@include avatar-colors();
}
.module-tooltip {

View File

@ -117,36 +117,70 @@ $color-tangerine: (
// Avatars
$avatar-color-crimson: #d00b2c;
$avatar-color-vermilion: #c72a0a;
$avatar-color-burlap: #866118;
$avatar-color-forest: #067919;
$avatar-color-wintergreen: #067953;
$avatar-color-teal: #077288;
$avatar-color-blue: #0a69c7;
$avatar-color-indigo: #5151f6;
$avatar-color-violet: #a20ced;
$avatar-color-plum: #c70a88;
$avatar-color-taupe: #cb0b6b;
$avatar-color-steel: $color-gray-60;
$avatar-color-ultramarine: #0d59f2;
$avatar-color-A100: (
bg: #e3e3fe,
fg: #3838f5,
);
$avatar-color-A110: (
bg: #dde7fc,
fg: #1251d3,
);
$avatar-color-A120: (
bg: #d8e8f0,
fg: #086da0,
);
$avatar-color-A130: (
bg: #cde4cd,
fg: #067906,
);
$avatar-color-A140: (
bg: #eae0fd,
fg: #661aff,
);
$avatar-color-A150: (
bg: #f5e3fe,
fg: #9f00f0,
);
$avatar-color-A160: (
bg: #f6d8ec,
fg: #b8057c,
);
$avatar-color-A170: (
bg: #f5d7d7,
fg: #be0404,
);
$avatar-color-A180: (
bg: #fef5d0,
fg: #836b01,
);
$avatar-color-A190: (
bg: #eae6d5,
fg: #7d6f40,
);
$avatar-color-A200: (
bg: #d2d2dc,
fg: #4f4f6d,
);
$avatar-color-A210: (
bg: #d7d7d9,
fg: #5c5c5c,
);
// Maps for easy manipulation
$avatar-colors: (
blue: $avatar-color-blue,
burlap: $avatar-color-burlap,
crimson: $avatar-color-crimson,
forest: $avatar-color-forest,
indigo: $avatar-color-indigo,
plum: $avatar-color-plum,
steel: $avatar-color-steel,
taupe: $avatar-color-taupe,
teal: $avatar-color-teal,
ultramarine: $avatar-color-ultramarine,
vermilion: $avatar-color-vermilion,
violet: $avatar-color-violet,
wintergreen: $avatar-color-wintergreen,
A100: $avatar-color-A100,
A110: $avatar-color-A110,
A120: $avatar-color-A120,
A130: $avatar-color-A130,
A140: $avatar-color-A140,
A150: $avatar-color-A150,
A160: $avatar-color-A160,
A170: $avatar-color-A170,
A180: $avatar-color-A180,
A190: $avatar-color-A190,
A200: $avatar-color-A200,
A210: $avatar-color-A210,
);
$conversation-colors: (

View File

@ -76,23 +76,12 @@
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);
}
-webkit-mask: url($icon) no-repeat center;
-webkit-mask-size: 100%;
}
&--direct {
@ -118,24 +107,5 @@
padding: 4px;
}
&--no-image {
background-color: $avatar-color-steel;
}
&--signal-blue {
background-color: $avatar-color-ultramarine;
@include dark-theme {
background-color: $avatar-color-ultramarine;
}
}
@each $color, $value in $avatar-colors {
&--#{$color} {
background-color: $value;
@include dark-theme {
background-color: $value;
}
}
}
@include avatar-colors();
}

View File

@ -0,0 +1,95 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.AvatarEditor {
&__top-buttons {
display: flex;
}
&__button {
@include button-reset;
align-items: center;
border-radius: 8px;
display: flex;
flex-direction: column;
font-size: 9px;
justify-content: center;
line-height: 14px;
margin: 0 8px;
min-height: 44px;
min-width: 60px;
padding: 0 8px;
@include light-theme {
background-color: $color-gray-05;
color: $color-black;
}
@include dark-theme {
background-color: $color-gray-65;
color: $color-gray-05;
}
&::before {
content: '';
display: block;
height: 18px;
width: 18px;
}
@mixin button-icon($icon) {
@include light-theme {
@include color-svg($icon, $color-black);
}
@include dark-theme {
@include color-svg($icon, $color-gray-05);
}
}
&--photo::before {
@include button-icon('../images/icons/v2/photo-album-outline-24.svg');
}
&--text::before {
@include button-icon('../images/icons/v2/text-24.svg');
}
&:focus {
box-shadow: 0 0 0 2px $color-ultramarine;
}
}
&__avatars {
display: flex;
flex-wrap: wrap;
gap: 9px;
}
&__divider {
border: none;
border-bottom: 1px solid $color-gray-15;
margin-bottom: 24px;
margin-top: 20px;
@include light-theme {
border-color: $color-gray-15;
}
@include dark-theme {
border-color: $color-gray-75;
}
}
&__preview {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}
&__avatar-selector-title {
@include font-body-1-bold;
margin-bottom: 14px;
}
}

View File

@ -1,92 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-AvatarInput {
@include button-reset;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
background: none;
$dark-selector: '#{&}--dark';
&__avatar {
@include button-reset;
margin-top: 4px;
display: flex;
border-radius: 100%;
height: 80px;
width: 80px;
transition: background-color 100ms ease-out;
&--nothing {
align-items: stretch;
background: $color-white;
@at-root '#{$dark-selector} #{&}' {
background: $color-ultramarine;
}
&::before {
flex-grow: 1;
content: '';
display: block;
@include color-svg(
'../images/icons/v2/camera-outline-24.svg',
$color-ultramarine,
false
);
-webkit-mask-size: 24px 24px;
@at-root '#{$dark-selector} #{&}' {
@include color-svg(
'../images/icons/v2/camera-outline-24.svg',
$color-white,
false
);
}
}
}
&--loading {
align-items: center;
background: $color-black;
}
&--has-image {
background-size: cover;
background-position: center center;
}
}
&__label {
@include button-reset;
@include font-body-1;
padding-bottom: 4px;
padding-top: 4px;
@include light-theme {
color: $color-ultramarine;
}
@include dark-theme {
color: $color-ultramarine-light;
}
}
@include keyboard-mode {
&:focus {
.module-AvatarInput__avatar {
box-shadow: inset 0 0 0 2px $color-ultramarine;
}
.module-AvatarInput__label {
@include font-body-1-bold;
}
}
}
}

View File

@ -0,0 +1,12 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.AvatarModalButtons {
bottom: 0;
position: absolute;
right: 0;
.module-Button {
margin-left: 12px;
}
}

View File

@ -0,0 +1,92 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.AvatarPreview {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
&__avatar {
@include button-reset;
align-items: center;
border-radius: 100%;
cursor: auto;
display: flex;
font-size: 32px;
height: 80px;
justify-content: center;
margin-bottom: 16px;
margin-top: 4px;
position: relative;
transition: background-color 100ms ease-out;
user-select: none;
width: 80px;
&--loading {
background: $color-black;
}
&--has-image {
background-size: cover;
background-position: center center;
}
}
&__group {
-webkit-mask: url('../images/icons/v2/group-outline-40.svg') no-repeat
center;
-webkit-mask-size: 70%;
height: 100%;
width: 100%;
}
&__upload {
align-items: center;
background: $color-gray-02;
border-radius: 100%;
bottom: 4px;
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.12), 0px 2px 4px rgba(0, 0, 0, 0.2);
display: flex;
height: 28px;
justify-content: center;
position: absolute;
right: -7px;
width: 28px;
&::after {
@include color-svg(
'../images/icons/v2/camera-outline-24.svg',
$color-black
);
content: '';
display: block;
height: 16px;
width: 16px;
}
}
&__clear {
@include button-reset;
align-items: center;
background-color: $color-white;
border-radius: 100%;
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.12), 0px 2px 4px rgba(0, 0, 0, 0.2);
display: flex;
height: 24px;
justify-content: center;
position: absolute;
right: 0;
top: 0;
width: 24px;
&:after {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
content: '';
height: 14px;
width: 14px;
}
}
}

View File

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.AvatarTextEditor {
&__input {
background: transparent;
border: none;
color: inherit;
outline: none;
padding: 0;
text-align: center;
text-transform: uppercase;
transition: font-size 30ms linear;
width: 100%;
}
&__measure {
left: -9999;
position: fixed;
text-transform: uppercase;
top: -9999;
touch-action: none;
visibility: hidden;
}
}

View File

@ -0,0 +1,88 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.BetterAvatarBubble {
align-items: center;
background-clip: content-box;
background-position: center;
border-color: transparent;
border-radius: 100%;
border-style: solid;
border-width: 2px;
cursor: pointer;
display: flex;
font-size: 32px;
height: 56px;
justify-content: center;
padding: 2px;
position: relative;
width: 56px;
@include avatar-colors();
&--selected {
@include light-theme {
border-color: $color-black;
}
@include dark-theme {
border-color: $color-white;
}
}
@include keyboard-mode {
&:focus {
border-color: $color-ultramarine;
outline: none;
}
}
&--editable {
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
background: $color-black-alpha-20;
height: 100%;
width: 100%;
&::after {
content: '';
display: block;
height: 24px;
width: 24px;
@include color-svg(
'../images/icons/v2/compose-outline-24.svg',
$color-white
);
}
}
&__delete {
@include button-reset;
align-items: center;
background-color: $color-white;
border-radius: 100%;
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.12), 0px 2px 4px rgba(0, 0, 0, 0.2);
display: none;
height: 20px;
justify-content: center;
position: absolute;
top: 0;
right: 0;
width: 20px;
&:after {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
content: '';
height: 10px;
width: 10px;
}
}
&:hover {
.BetterAvatarBubble__delete {
display: flex;
}
}
}

View File

@ -7,7 +7,9 @@
margin: 0 auto;
max-width: 360px;
width: 95%;
max-height: 90vh;
// We need this to be a number not divisible by 5 so that if we have sticky
// buttons the bottom doesn't bleed through by 1px.
max-height: 89vh;
display: flex;
flex-direction: column;
@ -85,7 +87,8 @@
padding: 0 16px 16px 16px;
border-top: 1px solid transparent;
// If there's a header, just the body scrolls
overflow: auto;
overflow-y: scroll; // scroll so that the padding is always there
overflow-x: auto;
&--scrolled {
@include light-theme {
@ -102,7 +105,8 @@
&--no-header {
padding: 16px;
// If there's no header, the whole thing scrolls
overflow: auto;
overflow-y: scroll; // scroll so that the padding is always there
overflow-x: auto;
}
&__button-footer {
@ -120,6 +124,44 @@
flex-direction: column;
align-items: flex-end;
}
.module-Modal--sticky-buttons & {
bottom: 0;
display: flex;
justify-content: flex-end;
padding: 16px 0;
position: sticky;
right: 0;
width: 100%;
z-index: 10;
@include light-theme() {
background: $color-white;
}
@include dark-theme() {
background: $color-gray-95;
}
}
}
&--sticky-buttons {
.module-Modal__body {
padding-bottom: 0;
}
position: relative;
.module-Modal__body--overflow {
.module-Modal__button-footer {
@include light-theme {
border-top: 1px solid $color-gray-05;
}
@include dark-theme {
border-top: 1px solid $color-gray-80;
}
}
}
}
// Overrides for a modal with important message

View File

@ -2,19 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
.ProfileEditor {
padding-bottom: 48px;
position: relative;
&__buttons {
bottom: 0;
position: absolute;
right: 0;
button {
margin-left: 12px;
}
}
&__icon {
&--container {
align-items: center;
@ -79,6 +66,6 @@
&__info {
@include font-body-2;
color: $color-gray-60;
margin-top: 16px;
margin: 16px 0;
}
}

View File

@ -31,7 +31,11 @@
@import './components/App.scss';
@import './components/AnnouncementsOnlyGroupBanner.scss';
@import './components/Avatar.scss';
@import './components/AvatarInput.scss';
@import './components/AvatarEditor.scss';
@import './components/AvatarModalButtons.scss';
@import './components/AvatarPreview.scss';
@import './components/AvatarTextEditor.scss';
@import './components/BetterAvatarBubble.scss';
@import './components/Button.scss';
@import './components/CallingScreenSharingController.scss';
@import './components/CallingSelectPresentingSourcesModal.scss';

View File

@ -36,7 +36,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
: true,
avatarPath: text('avatarPath', overrideProps.avatarPath || ''),
blur: overrideProps.blur,
color: select('color', colorMap, overrideProps.color || 'blue'),
color: select('color', colorMap, overrideProps.color || AvatarColors[0]),
conversationType: select(
'conversationType',
conversationTypeMap,

View File

@ -14,7 +14,7 @@ import { Spinner } from './Spinner';
import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util';
import { AvatarColorType } from '../types/Colors';
import { AvatarColors, AvatarColorType } from '../types/Colors';
import * as log from '../logging/log';
import { assert } from '../util/assert';
import { shouldBlurAvatar } from '../util/shouldBlurAvatar';
@ -70,7 +70,7 @@ export const Avatar: FunctionComponent<Props> = ({
acceptedMessageRequest,
avatarPath,
className,
color,
color = AvatarColors[0],
conversationType,
i18n,
isMe,
@ -160,6 +160,7 @@ export const Avatar: FunctionComponent<Props> = ({
<div
className={classNames(
'module-Avatar__icon',
`module-Avatar--${color}--icon`,
'module-Avatar__icon--note-to-self'
)}
/>
@ -179,6 +180,7 @@ export const Avatar: FunctionComponent<Props> = ({
<div
className={classNames(
'module-Avatar__icon',
`module-Avatar--${color}--icon`,
`module-Avatar__icon--${conversationType}`
)}
/>

View File

@ -0,0 +1,32 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarColorPicker, PropsType } from './AvatarColorPicker';
import { AvatarColors } from '../types/Colors';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
onColorSelected: action('onColorSelected'),
selectedColor: overrideProps.selectedColor,
});
const story = storiesOf('Components/AvatarColorPicker', module);
story.add('Default', () => <AvatarColorPicker {...createProps()} />);
story.add('Selected', () => (
<AvatarColorPicker
{...createProps({
selectedColor: AvatarColors[7],
})}
/>
));

View File

@ -0,0 +1,40 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { AvatarColors, AvatarColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { BetterAvatarBubble } from './BetterAvatarBubble';
export type PropsType = {
i18n: LocalizerType;
onColorSelected: (color: AvatarColorType) => unknown;
selectedColor?: AvatarColorType;
};
export const AvatarColorPicker = ({
i18n,
onColorSelected,
selectedColor,
}: PropsType): JSX.Element => {
return (
<>
<div className="AvatarEditor__avatar-selector-title">
{i18n('AvatarColorPicker--choose')}
</div>
<div className="AvatarEditor__avatars">
{AvatarColors.map(color => (
<BetterAvatarBubble
color={color}
i18n={i18n}
isSelected={selectedColor === color}
key={color}
onSelect={() => {
onColorSelected(color);
}}
/>
))}
</div>
</>
);
};

View File

@ -0,0 +1,98 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarColors } from '../types/Colors';
import { AvatarEditor, PropsType } from './AvatarEditor';
import { getDefaultAvatars } from '../types/Avatar';
import { createAvatarData } from '../util/createAvatarData';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarColor: overrideProps.avatarColor || AvatarColors[9],
avatarPath: overrideProps.avatarPath,
conversationId: '123',
conversationTitle: overrideProps.conversationTitle || 'Default Title',
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
i18n,
isGroup: Boolean(overrideProps.isGroup),
onCancel: action('onCancel'),
onSave: action('onSave'),
replaceAvatar: action('replaceAvatar'),
saveAvatarToDisk: action('saveAvatarToDisk'),
userAvatarData: overrideProps.userAvatarData || [
createAvatarData({
imagePath: '/fixtures/kitten-3-64-64.jpg',
}),
createAvatarData({
color: 'A110',
text: 'YA',
}),
createAvatarData({
color: 'A120',
text: 'OK',
}),
createAvatarData({
color: 'A130',
text: 'F',
}),
createAvatarData({
color: 'A140',
text: '🏄💣',
}),
createAvatarData({
color: 'A150',
text: '😇🙃😆',
}),
createAvatarData({
color: 'A160',
text: '🦊F💦',
}),
createAvatarData({
color: 'A170',
text: 'J',
}),
createAvatarData({
color: 'A180',
text: 'ZAP',
}),
createAvatarData({
color: 'A190',
text: '🍍P',
}),
createAvatarData({
color: 'A200',
text: '🌵',
}),
createAvatarData({
color: 'A210',
text: 'NAP',
}),
],
});
const story = storiesOf('Components/AvatarEditor', module);
story.add('No Avatar (group)', () => (
<AvatarEditor
{...createProps({ isGroup: true, userAvatarData: getDefaultAvatars(true) })}
/>
));
story.add('No Avatar (me)', () => (
<AvatarEditor {...createProps({ userAvatarData: getDefaultAvatars() })} />
));
story.add('Has Avatar', () => (
<AvatarEditor
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
})}
/>
));

View File

@ -0,0 +1,298 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useState } from 'react';
import { AvatarColorType } from '../types/Colors';
import {
AvatarDataType,
DeleteAvatarFromDiskActionType,
ReplaceAvatarActionType,
SaveAvatarToDiskActionType,
} from '../types/Avatar';
import { AvatarIconEditor } from './AvatarIconEditor';
import { AvatarModalButtons } from './AvatarModalButtons';
import { AvatarPreview } from './AvatarPreview';
import { AvatarTextEditor } from './AvatarTextEditor';
import { AvatarUploadButton } from './AvatarUploadButton';
import { BetterAvatar } from './BetterAvatar';
import { LocalizerType } from '../types/Util';
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
import { createAvatarData } from '../util/createAvatarData';
import { isSameAvatarData } from '../util/isSameAvatarData';
import { missingCaseError } from '../util/missingCaseError';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
avatarValue?: ArrayBuffer;
conversationId?: string;
conversationTitle?: string;
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
i18n: LocalizerType;
isGroup?: boolean;
onCancel: () => unknown;
onSave: (buffer: ArrayBuffer | undefined) => unknown;
userAvatarData: ReadonlyArray<AvatarDataType>;
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
};
enum EditMode {
Main = 'Main',
Custom = 'Custom',
Text = 'Text',
}
export const AvatarEditor = ({
avatarColor,
avatarPath,
avatarValue,
conversationId,
conversationTitle,
deleteAvatarFromDisk,
i18n,
isGroup,
onCancel,
onSave,
userAvatarData,
replaceAvatar,
saveAvatarToDisk,
}: PropsType): JSX.Element => {
const [provisionalSelectedAvatar, setProvisionalSelectedAvatar] = useState<
AvatarDataType | undefined
>();
const [avatarPreview, setAvatarPreview] = useState<ArrayBuffer | undefined>(
avatarValue
);
const [initialAvatar, setInitialAvatar] = useState<ArrayBuffer | undefined>(
avatarValue
);
const [localAvatarData, setLocalAvatarData] = useState<Array<AvatarDataType>>(
userAvatarData.slice()
);
const [editMode, setEditMode] = useState<EditMode>(EditMode.Main);
const getSelectedAvatar = useCallback(
avatarToFind =>
localAvatarData.find(avatarData =>
isSameAvatarData(avatarData, avatarToFind)
),
[localAvatarData]
);
const selectedAvatar = getSelectedAvatar(provisionalSelectedAvatar);
// Caching the ArrayBuffer produced into avatarData as buffer because
// that function is a little expensive to run and so we don't flicker the UI.
useEffect(() => {
let shouldCancel = false;
async function cacheAvatars() {
const newAvatarData = await Promise.all(
userAvatarData.map(async avatarData => {
if (avatarData.buffer) {
return avatarData;
}
const buffer = await avatarDataToArrayBuffer(avatarData);
return {
...avatarData,
buffer,
};
})
);
if (!shouldCancel) {
setLocalAvatarData(newAvatarData);
}
}
cacheAvatars();
return () => {
shouldCancel = true;
};
}, [setLocalAvatarData, userAvatarData]);
// This function optimistcally updates userAvatarData so we don't have to
// wait for saveAvatarToDisk to finish before displaying something to the
// user. As a bonus the component fully works in storybook!
const updateAvatarDataList = useCallback(
(newAvatarData?: AvatarDataType, staleAvatarData?: AvatarDataType) => {
const existingAvatarData = staleAvatarData
? localAvatarData.filter(avatarData => avatarData !== staleAvatarData)
: localAvatarData;
if (newAvatarData) {
setAvatarPreview(newAvatarData.buffer);
setLocalAvatarData([newAvatarData, ...existingAvatarData]);
setProvisionalSelectedAvatar(newAvatarData);
} else {
setLocalAvatarData(existingAvatarData);
if (isSameAvatarData(selectedAvatar, staleAvatarData)) {
setAvatarPreview(undefined);
setProvisionalSelectedAvatar(undefined);
}
}
},
[
localAvatarData,
selectedAvatar,
setAvatarPreview,
setLocalAvatarData,
setProvisionalSelectedAvatar,
]
);
const handleAvatarLoaded = useCallback(avatarBuffer => {
setAvatarPreview(avatarBuffer);
setInitialAvatar(avatarBuffer);
}, []);
const hasChanges = initialAvatar !== avatarPreview;
let content: JSX.Element | undefined;
if (editMode === EditMode.Main) {
content = (
<>
<div className="AvatarEditor__preview">
<AvatarPreview
avatarColor={avatarColor}
avatarPath={avatarPath}
avatarValue={avatarPreview}
conversationTitle={conversationTitle}
i18n={i18n}
isGroup={isGroup}
onAvatarLoaded={handleAvatarLoaded}
onClear={() => {
setAvatarPreview(undefined);
setProvisionalSelectedAvatar(undefined);
}}
/>
<div className="AvatarEditor__top-buttons">
<AvatarUploadButton
className="AvatarEditor__button AvatarEditor__button--photo"
i18n={i18n}
onChange={newAvatar => {
const avatarData = createAvatarData({
buffer: newAvatar,
// This is so that the newly created avatar gets an X
imagePath: 'TMP',
});
saveAvatarToDisk(avatarData, conversationId);
updateAvatarDataList(avatarData);
}}
/>
<button
className="AvatarEditor__button AvatarEditor__button--text"
onClick={() => {
setProvisionalSelectedAvatar(undefined);
setEditMode(EditMode.Text);
}}
type="button"
>
{i18n('text')}
</button>
</div>
</div>
<hr className="AvatarEditor__divider" />
<div className="AvatarEditor__avatar-selector-title">
{i18n('AvatarEditor--choose')}
</div>
<div className="AvatarEditor__avatars">
{localAvatarData.map(avatarData => (
<BetterAvatar
avatarData={avatarData}
key={avatarData.id}
i18n={i18n}
isSelected={isSameAvatarData(avatarData, selectedAvatar)}
onClick={avatarBuffer => {
if (isSameAvatarData(avatarData, selectedAvatar)) {
if (avatarData.text) {
setEditMode(EditMode.Text);
} else if (avatarData.icon) {
setEditMode(EditMode.Custom);
}
} else {
setAvatarPreview(avatarBuffer);
setProvisionalSelectedAvatar(avatarData);
}
}}
onDelete={() => {
updateAvatarDataList(undefined, avatarData);
deleteAvatarFromDisk(avatarData, conversationId);
}}
/>
))}
</div>
<AvatarModalButtons
hasChanges={hasChanges}
i18n={i18n}
onCancel={onCancel}
onSave={() => {
if (selectedAvatar) {
replaceAvatar(selectedAvatar, selectedAvatar, conversationId);
}
onSave(avatarPreview);
}}
/>
</>
);
} else if (editMode === EditMode.Text) {
content = (
<AvatarTextEditor
avatarData={selectedAvatar}
i18n={i18n}
onCancel={() => {
setEditMode(EditMode.Main);
if (selectedAvatar) {
return;
}
// The selected avatar was cleared when we entered text mode so we
// need to find if one is actually selected if it matches the current
// preview.
const actualAvatarSelected = localAvatarData.find(
avatarData => avatarData.buffer === avatarPreview
);
if (actualAvatarSelected) {
setProvisionalSelectedAvatar(actualAvatarSelected);
}
}}
onDone={(avatarBuffer, avatarData) => {
const newAvatarData = {
...avatarData,
buffer: avatarBuffer,
};
updateAvatarDataList(newAvatarData, selectedAvatar);
setEditMode(EditMode.Main);
replaceAvatar(newAvatarData, selectedAvatar, conversationId);
}}
/>
);
} else if (editMode === EditMode.Custom) {
if (!selectedAvatar) {
throw new Error('No selected avatar and editMode is custom');
}
content = (
<AvatarIconEditor
avatarData={selectedAvatar}
i18n={i18n}
onClose={avatarData => {
if (avatarData) {
updateAvatarDataList(avatarData, selectedAvatar);
replaceAvatar(avatarData, selectedAvatar, conversationId);
}
setEditMode(EditMode.Main);
}}
/>
);
} else {
throw missingCaseError(editMode);
}
return <div className="AvatarEditor">{content}</div>;
};

View File

@ -0,0 +1,46 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarIconEditor, PropsType } from './AvatarIconEditor';
import { GroupAvatarIcons, PersonalAvatarIcons } from '../types/Avatar';
import { AvatarColors } from '../types/Colors';
import { createAvatarData } from '../util/createAvatarData';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarData: overrideProps.avatarData || createAvatarData({}),
i18n,
onClose: action('onClose'),
});
const story = storiesOf('Components/AvatarIconEditor', module);
story.add('Personal Icon', () => (
<AvatarIconEditor
{...createProps({
avatarData: createAvatarData({
color: AvatarColors[3],
icon: PersonalAvatarIcons[0],
}),
})}
/>
));
story.add('Group Icon', () => (
<AvatarIconEditor
{...createProps({
avatarData: createAvatarData({
color: AvatarColors[3],
icon: GroupAvatarIcons[0],
}),
})}
/>
));

View File

@ -0,0 +1,81 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useState } from 'react';
import { AvatarColorPicker } from './AvatarColorPicker';
import { AvatarColorType } from '../types/Colors';
import { AvatarDataType } from '../types/Avatar';
import { AvatarModalButtons } from './AvatarModalButtons';
import { AvatarPreview } from './AvatarPreview';
import { LocalizerType } from '../types/Util';
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
export type PropsType = {
avatarData: AvatarDataType;
i18n: LocalizerType;
onClose: (avatarData?: AvatarDataType) => unknown;
};
export const AvatarIconEditor = ({
avatarData: initialAvatarData,
i18n,
onClose,
}: PropsType): JSX.Element => {
const [avatarBuffer, setAvatarBuffer] = useState<ArrayBuffer | undefined>();
const [avatarData, setAvatarData] = useState<AvatarDataType>(
initialAvatarData
);
const onColorSelected = useCallback(
(color: AvatarColorType) => {
setAvatarData({
...avatarData,
color,
});
},
[avatarData]
);
useEffect(() => {
let shouldCancel = false;
async function loadAvatar() {
const buffer = await avatarDataToArrayBuffer(avatarData);
if (!shouldCancel) {
setAvatarBuffer(buffer);
}
}
loadAvatar();
return () => {
shouldCancel = true;
};
}, [avatarData, setAvatarBuffer]);
const hasChanges = avatarData !== initialAvatarData;
return (
<>
<AvatarPreview
avatarColor={avatarData.color}
avatarValue={avatarBuffer}
conversationTitle={avatarData.text}
i18n={i18n}
/>
<hr className="AvatarEditor__divider" />
<AvatarColorPicker i18n={i18n} onColorSelected={onColorSelected} />
<AvatarModalButtons
hasChanges={hasChanges}
i18n={i18n}
onCancel={onClose}
onSave={() =>
onClose({
...avatarData,
buffer: avatarBuffer,
})
}
/>
</>
);
};

View File

@ -1,74 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useEffect } from 'react';
import { v4 as uuid } from 'uuid';
import { chunk, noop } from 'lodash';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarInput, AvatarInputVariant } from './AvatarInput';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/AvatarInput', module);
const TEST_IMAGE = new Uint8Array(
chunk(
'89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082',
2
).map(bytePair => parseInt(bytePair.join(''), 16))
).buffer;
const Wrapper = ({
startValue,
variant,
}: {
startValue: undefined | ArrayBuffer;
variant?: AvatarInputVariant;
}) => {
const [value, setValue] = useState<undefined | ArrayBuffer>(startValue);
const [objectUrl, setObjectUrl] = useState<undefined | string>();
useEffect(() => {
if (!value) {
setObjectUrl(undefined);
return noop;
}
const url = URL.createObjectURL(new Blob([value]));
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [value]);
return (
<>
<AvatarInput
contextMenuId={uuid()}
i18n={i18n}
value={value}
onChange={setValue}
variant={variant}
/>
<figure>
<figcaption>Processed image (if it exists)</figcaption>
{objectUrl && <img src={objectUrl} alt="" />}
</figure>
</>
);
};
story.add('No start state', () => {
return <Wrapper startValue={undefined} />;
});
story.add('Starting with a value', () => {
return <Wrapper startValue={TEST_IMAGE} />;
});
story.add('Dark variant', () => {
return <Wrapper startValue={undefined} variant={AvatarInputVariant.Dark} />;
});

View File

@ -1,225 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useRef,
useState,
useEffect,
ChangeEventHandler,
MouseEventHandler,
FunctionComponent,
} from 'react';
import classNames from 'classnames';
import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu';
import loadImage, { LoadImageOptions } from 'blueimp-load-image';
import { noop } from 'lodash';
import { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
import { canvasToArrayBuffer } from '../util/canvasToArrayBuffer';
export type PropsType = {
// This ID needs to be globally unique across the app.
contextMenuId: string;
disabled?: boolean;
i18n: LocalizerType;
onChange: (value: undefined | ArrayBuffer) => unknown;
type?: AvatarInputType;
value: undefined | ArrayBuffer;
variant?: AvatarInputVariant;
};
enum ImageStatus {
Nothing = 'nothing',
Loading = 'loading',
HasImage = 'has-image',
}
export enum AvatarInputType {
Profile = 'Profile',
Group = 'Group',
}
export enum AvatarInputVariant {
Light = 'light',
Dark = 'dark',
}
export const AvatarInput: FunctionComponent<PropsType> = ({
contextMenuId,
disabled,
i18n,
onChange,
type,
value,
variant = AvatarInputVariant.Light,
}) => {
const fileInputRef = useRef<null | HTMLInputElement>(null);
// Comes from a third-party dependency
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const menuTriggerRef = useRef<null | any>(null);
const [objectUrl, setObjectUrl] = useState<undefined | string>();
useEffect(() => {
if (!value) {
setObjectUrl(undefined);
return noop;
}
const url = URL.createObjectURL(new Blob([value]));
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [value]);
const [processingFile, setProcessingFile] = useState<undefined | File>(
undefined
);
useEffect(() => {
if (!processingFile) {
return noop;
}
let shouldCancel = false;
(async () => {
let newValue: ArrayBuffer;
try {
newValue = await processFile(processingFile);
} catch (err) {
// Processing errors should be rare; if they do, we silently fail. In an ideal
// world, we may want to show a toast instead.
return;
}
if (shouldCancel) {
return;
}
setProcessingFile(undefined);
onChange(newValue);
})();
return () => {
shouldCancel = true;
};
}, [processingFile, onChange]);
let buttonLabel = i18n('AvatarInput--change-photo-label');
if (!value) {
if (type === AvatarInputType.Profile) {
buttonLabel = i18n('AvatarInput--no-photo-label--profile');
} else {
buttonLabel = i18n('AvatarInput--no-photo-label--group');
}
}
const startUpload = () => {
const fileInput = fileInputRef.current;
if (fileInput) {
fileInput.click();
}
};
const clear = () => {
onChange(undefined);
};
const onClick: MouseEventHandler<unknown> = value
? event => {
const menuTrigger = menuTriggerRef.current;
if (!menuTrigger) {
return;
}
menuTrigger.handleContextClick(event);
}
: startUpload;
const onInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const file = event.target.files && event.target.files[0];
if (file) {
setProcessingFile(file);
}
};
let imageStatus: ImageStatus;
if (processingFile || (value && !objectUrl)) {
imageStatus = ImageStatus.Loading;
} else if (objectUrl) {
imageStatus = ImageStatus.HasImage;
} else {
imageStatus = ImageStatus.Nothing;
}
const isLoading = imageStatus === ImageStatus.Loading;
return (
<>
<ContextMenuTrigger id={contextMenuId} ref={menuTriggerRef}>
<button
type="button"
disabled={disabled || isLoading}
className={classNames(
'module-AvatarInput',
`module-AvatarInput--${variant}`
)}
onClick={onClick}
>
<div
className={`module-AvatarInput__avatar module-AvatarInput__avatar--${imageStatus}`}
style={
imageStatus === ImageStatus.HasImage
? {
backgroundImage: `url(${objectUrl})`,
}
: undefined
}
>
{isLoading && (
<Spinner size="70px" svgSize="normal" direction="on-avatar" />
)}
</div>
<span className="module-AvatarInput__label">{buttonLabel}</span>
</button>
</ContextMenuTrigger>
<ContextMenu id={contextMenuId}>
<MenuItem onClick={startUpload}>
{i18n('AvatarInput--upload-photo-choice')}
</MenuItem>
<MenuItem onClick={clear}>
{i18n('AvatarInput--remove-photo-choice')}
</MenuItem>
</ContextMenu>
<input
accept=".gif,.jpg,.jpeg,.png,.webp,image/gif,image/jpeg/image/png,image/webp"
hidden
onChange={onInputChange}
ref={fileInputRef}
type="file"
/>
</>
);
};
async function processFile(file: File): Promise<ArrayBuffer> {
const { image } = await loadImage(file, {
canvas: true,
cover: true,
crop: true,
imageSmoothingQuality: 'medium',
maxHeight: 512,
maxWidth: 512,
minHeight: 2,
minWidth: 2,
// `imageSmoothingQuality` is not present in `loadImage`'s types, but it is
// documented and supported. Updating DefinitelyTyped is the long-term solution
// here.
} as LoadImageOptions);
// NOTE: The types for `loadImage` say this can never be a canvas, but it will be if
// `canvas: true`, at least in our case. Again, updating DefinitelyTyped should
// address this.
if (!(image instanceof HTMLCanvasElement)) {
throw new Error('Loaded image was not a canvas');
}
return canvasToArrayBuffer(image);
}

View File

@ -1,43 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { v4 as uuid } from 'uuid';
import { noop } from 'lodash';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarInputContainer } from './AvatarInputContainer';
import { AvatarInputType } from './AvatarInput';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/AvatarInputContainer', module);
story.add('No photo (group)', () => (
<AvatarInputContainer
contextMenuId={uuid()}
i18n={i18n}
onAvatarChanged={noop}
/>
));
story.add('No photo (profile)', () => (
<AvatarInputContainer
contextMenuId={uuid()}
i18n={i18n}
onAvatarChanged={noop}
type={AvatarInputType.Profile}
/>
));
story.add('Has photo', () => (
<AvatarInputContainer
avatarPath="/fixtures/kitten-3-64-64.jpg"
contextMenuId={uuid()}
i18n={i18n}
onAvatarChanged={noop}
/>
));

View File

@ -1,86 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef, useState } from 'react';
import { noop } from 'lodash';
import * as log from '../logging/log';
import { AvatarInput, PropsType as AvatarInputPropsType } from './AvatarInput';
import { LocalizerType } from '../types/Util';
import { imagePathToArrayBuffer } from '../util/imagePathToArrayBuffer';
type PropsType = {
avatarPath?: string;
i18n: LocalizerType;
onAvatarChanged: (avatar: ArrayBuffer | undefined) => unknown;
onAvatarLoaded?: (avatar: ArrayBuffer | undefined) => unknown;
} & Pick<
AvatarInputPropsType,
'contextMenuId' | 'disabled' | 'type' | 'variant'
>;
const TEMPORARY_AVATAR_VALUE = new ArrayBuffer(0);
export const AvatarInputContainer = ({
avatarPath,
contextMenuId,
disabled,
i18n,
onAvatarChanged,
onAvatarLoaded,
type,
variant,
}: PropsType): JSX.Element => {
const startingAvatarPathRef = useRef<undefined | string>(avatarPath);
const [avatar, setAvatar] = useState<undefined | ArrayBuffer>(
avatarPath ? TEMPORARY_AVATAR_VALUE : undefined
);
useEffect(() => {
const startingAvatarPath = startingAvatarPathRef.current;
if (!startingAvatarPath) {
return noop;
}
let shouldCancel = false;
(async () => {
try {
const buffer = await imagePathToArrayBuffer(startingAvatarPath);
if (shouldCancel) {
return;
}
setAvatar(buffer);
if (onAvatarLoaded) {
onAvatarLoaded(buffer);
}
} catch (err) {
log.warn(
`Failed to convert image URL to array buffer. Error message: ${
err && err.message
}`
);
}
})();
return () => {
shouldCancel = true;
};
}, [onAvatarLoaded]);
return (
<AvatarInput
contextMenuId={contextMenuId}
disabled={disabled}
i18n={i18n}
onChange={newAvatar => {
setAvatar(newAvatar);
onAvatarChanged(newAvatar);
}}
type={type}
value={avatar}
variant={variant}
/>
);
};

View File

@ -0,0 +1,59 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { select } from '@storybook/addon-knobs';
import enMessages from '../../_locales/en/messages.json';
import { AvatarColors } from '../types/Colors';
import { AvatarLightbox, PropsType } from './AvatarLightbox';
import { setup as setupI18n } from '../../js/modules/i18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarColor: select(
'Color',
AvatarColors,
overrideProps.avatarColor || AvatarColors[0]
),
avatarPath: overrideProps.avatarPath,
conversationTitle: overrideProps.conversationTitle,
i18n,
isGroup: Boolean(overrideProps.isGroup),
onClose: action('onClose'),
});
const story = storiesOf('Components/AvatarLightbox', module);
story.add('Group', () => (
<AvatarLightbox
{...createProps({
isGroup: true,
})}
/>
));
story.add('Person', () => {
const conversation = getDefaultConversation();
return (
<AvatarLightbox
{...createProps({
avatarColor: conversation.color,
conversationTitle: conversation.title,
})}
/>
);
});
story.add('Photo', () => (
<AvatarLightbox
{...createProps({
avatarPath: '/fixtures/kitten-1-64-64.jpg',
})}
/>
));

View File

@ -0,0 +1,64 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { AvatarColorType } from '../types/Colors';
import { AvatarPreview } from './AvatarPreview';
import { IMAGE_JPEG } from '../types/MIME';
import { Lightbox } from './Lightbox';
import { LocalizerType } from '../types/Util';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
conversationTitle?: string;
i18n: LocalizerType;
isGroup?: boolean;
onClose: () => unknown;
};
export const AvatarLightbox = ({
avatarColor,
avatarPath,
conversationTitle,
i18n,
isGroup,
onClose,
}: PropsType): JSX.Element => {
if (avatarPath) {
return (
<Lightbox
// We don't know that the avatar is a JPEG, but any image `contentType` will cause
// it to be rendered as an image, which is what we want.
contentType={IMAGE_JPEG}
close={onClose}
i18n={i18n}
isViewOnce={false}
objectURL={avatarPath}
/>
);
}
return (
<Lightbox
contentType={undefined}
close={onClose}
i18n={i18n}
isViewOnce={false}
objectURL=""
>
<AvatarPreview
avatarColor={avatarColor}
conversationTitle={conversationTitle}
i18n={i18n}
isGroup={isGroup}
style={{
fontSize: '16em',
height: '2em',
width: '2em',
}}
/>
</Lightbox>
);
};

View File

@ -0,0 +1,32 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import { AvatarModalButtons, PropsType } from './AvatarModalButtons';
import { setup as setupI18n } from '../../js/modules/i18n';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
hasChanges: Boolean(overrideProps.hasChanges),
i18n,
onCancel: action('onCancel'),
onSave: action('onSave'),
});
const story = storiesOf('Components/AvatarModalButtons', module);
story.add('Has changes', () => (
<AvatarModalButtons
{...createProps({
hasChanges: true,
})}
/>
));
story.add('No changes', () => <AvatarModalButtons {...createProps()} />);

View File

@ -0,0 +1,54 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { Button, ButtonVariant } from './Button';
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
import { LocalizerType } from '../types/Util';
import { Modal } from './Modal';
export type PropsType = {
hasChanges: boolean;
i18n: LocalizerType;
onCancel: () => unknown;
onSave: () => unknown;
};
export const AvatarModalButtons = ({
hasChanges,
i18n,
onCancel,
onSave,
}: PropsType): JSX.Element => {
const [confirmDiscardAction, setConfirmDiscardAction] = useState<
(() => unknown) | undefined
>(undefined);
return (
<Modal.ButtonFooter>
<Button
onClick={() => {
if (hasChanges) {
setConfirmDiscardAction(() => onCancel);
} else {
onCancel();
}
}}
variant={ButtonVariant.Secondary}
>
{i18n('cancel')}
</Button>
<Button disabled={!hasChanges} onClick={onSave}>
{i18n('save')}
</Button>
{confirmDiscardAction && (
<ConfirmDiscardDialog
i18n={i18n}
onDiscard={confirmDiscardAction}
onClose={() => setConfirmDiscardAction(undefined)}
/>
)}
</Modal.ButtonFooter>
);
};

View File

@ -30,7 +30,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'),
color: select('color', colorMap, overrideProps.color || AvatarColors[0]),
conversationType: select(
'conversationType',
conversationTypeMap,

View File

@ -0,0 +1,94 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { chunk } from 'lodash';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { AvatarPreview, PropsType } from './AvatarPreview';
import { AvatarColors } from '../types/Colors';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const TEST_IMAGE = new Uint8Array(
chunk(
'89504e470d0a1a0a0000000d4948445200000008000000080103000000fec12cc800000006504c5445ff00ff00ff000c82e9800000001849444154085b633061a8638863a867f8c720c760c12000001a4302f4d81dd9870000000049454e44ae426082',
2
).map(bytePair => parseInt(bytePair.join(''), 16))
).buffer;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarColor: overrideProps.avatarColor,
avatarPath: overrideProps.avatarPath,
avatarValue: overrideProps.avatarValue,
conversationTitle: overrideProps.conversationTitle,
i18n,
isEditable: Boolean(overrideProps.isEditable),
isGroup: Boolean(overrideProps.isGroup),
onAvatarLoaded: action('onAvatarLoaded'),
onClear: action('onClear'),
onClick: action('onClick'),
style: overrideProps.style,
});
const story = storiesOf('Components/AvatarPreview', module);
story.add('No state (personal)', () => (
<AvatarPreview
{...createProps({
avatarColor: AvatarColors[0],
conversationTitle: 'Just Testing',
})}
/>
));
story.add('No state (group)', () => (
<AvatarPreview
{...createProps({
avatarColor: AvatarColors[1],
isGroup: true,
})}
/>
));
story.add('No state (group) + upload me', () => (
<AvatarPreview
{...createProps({
avatarColor: AvatarColors[1],
isEditable: true,
isGroup: true,
})}
/>
));
story.add('value', () => (
<AvatarPreview {...createProps({ avatarValue: TEST_IMAGE })} />
));
story.add('path', () => (
<AvatarPreview
{...createProps({ avatarPath: '/fixtures/kitten-3-64-64.jpg' })}
/>
));
story.add('value & path', () => (
<AvatarPreview
{...createProps({
avatarPath: '/fixtures/kitten-3-64-64.jpg',
avatarValue: TEST_IMAGE,
})}
/>
));
story.add('style', () => (
<AvatarPreview
{...createProps({
avatarValue: TEST_IMAGE,
style: { height: 100, width: 100 },
})}
/>
));

View File

@ -0,0 +1,184 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
import { noop } from 'lodash';
import * as log from '../logging/log';
import { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
import { AvatarColors, AvatarColorType } from '../types/Colors';
import { getInitials } from '../util/getInitials';
import { imagePathToArrayBuffer } from '../util/imagePathToArrayBuffer';
export type PropsType = {
avatarColor?: AvatarColorType;
avatarPath?: string;
avatarValue?: ArrayBuffer;
conversationTitle?: string;
i18n: LocalizerType;
isEditable?: boolean;
isGroup?: boolean;
onAvatarLoaded?: (avatarBuffer: ArrayBuffer) => unknown;
onClear?: () => unknown;
onClick?: () => unknown;
style?: CSSProperties;
};
enum ImageStatus {
Nothing = 'nothing',
Loading = 'loading',
HasImage = 'has-image',
}
export const AvatarPreview = ({
avatarColor = AvatarColors[0],
avatarPath,
avatarValue,
conversationTitle,
i18n,
isEditable,
isGroup,
onAvatarLoaded,
onClear,
onClick,
style = {},
}: PropsType): JSX.Element => {
const startingAvatarPathRef = useRef<undefined | string>(
avatarValue ? undefined : avatarPath
);
const [avatarPreview, setAvatarPreview] = useState<ArrayBuffer | undefined>();
// Loads the initial avatarPath if one is provided.
useEffect(() => {
const startingAvatarPath = startingAvatarPathRef.current;
if (!startingAvatarPath) {
return noop;
}
let shouldCancel = false;
(async () => {
try {
const buffer = await imagePathToArrayBuffer(startingAvatarPath);
if (shouldCancel) {
return;
}
setAvatarPreview(buffer);
if (onAvatarLoaded) {
onAvatarLoaded(buffer);
}
} catch (err) {
if (shouldCancel) {
return;
}
log.warn(
`Failed to convert image URL to array buffer. Error message: ${
err && err.message
}`
);
}
})();
return () => {
shouldCancel = true;
};
}, [onAvatarLoaded]);
// Ensures that when avatarValue changes we generate new URLs
useEffect(() => {
if (avatarValue) {
setAvatarPreview(avatarValue);
} else {
setAvatarPreview(undefined);
}
}, [avatarValue]);
// Creates the object URL to render the ArrayBuffer image
const [objectUrl, setObjectUrl] = useState<undefined | string>();
useEffect(() => {
if (!avatarPreview) {
setObjectUrl(undefined);
return noop;
}
const url = URL.createObjectURL(new Blob([avatarPreview]));
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [avatarPreview]);
let imageStatus: ImageStatus;
if (avatarValue && !objectUrl) {
imageStatus = ImageStatus.Loading;
} else if (objectUrl) {
imageStatus = ImageStatus.HasImage;
} else {
imageStatus = ImageStatus.Nothing;
}
const isLoading = imageStatus === ImageStatus.Loading;
const clickProps = onClick ? { role: 'button', onClick } : {};
const componentStyle = {
...style,
};
if (onClick) {
componentStyle.cursor = 'pointer';
}
if (!avatarPreview) {
return (
<div className="AvatarPreview">
<div
className={`AvatarPreview__avatar BetterAvatarBubble--${avatarColor}`}
{...clickProps}
style={componentStyle}
>
{isGroup ? (
<div
className={`BetterAvatarBubble--${avatarColor}--icon AvatarPreview__group`}
/>
) : (
getInitials(conversationTitle)
)}
{isEditable && <div className="AvatarPreview__upload" />}
</div>
</div>
);
}
return (
<div className="AvatarPreview">
<div
className={`AvatarPreview__avatar AvatarPreview__avatar--${imageStatus}`}
{...clickProps}
style={
imageStatus === ImageStatus.HasImage
? {
...componentStyle,
backgroundImage: `url(${objectUrl})`,
}
: componentStyle
}
>
{isLoading && (
<Spinner size="70px" svgSize="normal" direction="on-avatar" />
)}
{imageStatus === ImageStatus.HasImage && onClear && (
<button
aria-label={i18n('delete')}
className="AvatarPreview__clear"
onClick={onClear}
tabIndex={-1}
type="button"
/>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,49 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarTextEditor, PropsType } from './AvatarTextEditor';
import { AvatarColors } from '../types/Colors';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarData: overrideProps.avatarData,
i18n,
onCancel: action('onCancel'),
onDone: action('onDone'),
});
const story = storiesOf('Components/AvatarTextEditor', module);
story.add('Empty', () => <AvatarTextEditor {...createProps()} />);
story.add('with Data', () => (
<AvatarTextEditor
{...createProps({
avatarData: {
id: '123',
color: AvatarColors[6],
text: 'SUP',
},
})}
/>
));
story.add('with wide characters', () => (
<AvatarTextEditor
{...createProps({
avatarData: {
id: '123',
color: AvatarColors[6],
text: '‱௸𒈙',
},
})}
/>
));

View File

@ -0,0 +1,197 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
ChangeEvent,
ClipboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { noop } from 'lodash';
import * as grapheme from '../util/grapheme';
import { AvatarColorPicker } from './AvatarColorPicker';
import { AvatarColors } from '../types/Colors';
import { AvatarDataType } from '../types/Avatar';
import { AvatarModalButtons } from './AvatarModalButtons';
import { BetterAvatarBubble } from './BetterAvatarBubble';
import { LocalizerType } from '../types/Util';
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
import { createAvatarData } from '../util/createAvatarData';
import {
getFittedFontSize,
getFontSizes,
} from '../util/avatarTextSizeCalculator';
type DoneHandleType = (
avatarBuffer: ArrayBuffer,
avatarData: AvatarDataType
) => unknown;
export type PropsType = {
avatarData?: AvatarDataType;
i18n: LocalizerType;
onCancel: () => unknown;
onDone: DoneHandleType;
};
const BUBBLE_SIZE = 120;
const MAX_LENGTH = 3;
export const AvatarTextEditor = ({
avatarData,
i18n,
onCancel,
onDone,
}: PropsType): JSX.Element => {
const initialText = useMemo(() => avatarData?.text || '', [avatarData]);
const initialColor = useMemo(() => avatarData?.color || AvatarColors[0], [
avatarData,
]);
const [inputText, setInputText] = useState(initialText);
const [fontSize, setFontSize] = useState(getFontSizes(BUBBLE_SIZE).text);
const [selectedColor, setSelectedColor] = useState(initialColor);
const inputRef = useRef<HTMLInputElement | null>(null);
const focusInput = useCallback(() => {
const inputEl = inputRef?.current;
if (inputEl) {
inputEl.focus();
}
}, []);
const handleChange = useCallback(
(ev: ChangeEvent<HTMLInputElement>) => {
const { value } = ev.target;
if (grapheme.count(value) <= MAX_LENGTH) {
setInputText(ev.target.value);
}
},
[setInputText]
);
const handlePaste = useCallback(
(ev: ClipboardEvent<HTMLInputElement>) => {
const inputEl = ev.currentTarget;
const selectionStart = inputEl.selectionStart || 0;
const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0;
const textBeforeSelection = inputText.slice(0, selectionStart);
const textAfterSelection = inputText.slice(selectionEnd);
const pastedText = ev.clipboardData.getData('Text');
const newGraphemeCount =
grapheme.count(textBeforeSelection) +
grapheme.count(pastedText) +
grapheme.count(textAfterSelection);
if (newGraphemeCount > MAX_LENGTH) {
ev.preventDefault();
}
},
[inputText]
);
const onDoneRef = useRef<DoneHandleType>(onDone);
// Make sure we keep onDoneRef up to date
useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
const handleDone = useCallback(async () => {
const newAvatarData = createAvatarData({
color: selectedColor,
text: inputText,
});
const buffer = await avatarDataToArrayBuffer(newAvatarData);
onDoneRef.current(buffer, newAvatarData);
}, [inputText, selectedColor]);
// In case the component unmounts before we're able to create the avatar data
// we set the done handler to a no-op.
useEffect(() => {
return () => {
onDoneRef.current = noop;
};
}, []);
const measureElRef = useRef<null | HTMLDivElement>(null);
useEffect(() => {
const measureEl = measureElRef.current;
if (!measureEl) {
return;
}
const nextFontSize = getFittedFontSize(
BUBBLE_SIZE,
inputText,
candidateFontSize => {
measureEl.style.fontSize = `${candidateFontSize}px`;
const { width, height } = measureEl.getBoundingClientRect();
return { height, width };
}
);
setFontSize(nextFontSize);
}, [inputText]);
useEffect(() => {
focusInput();
}, [focusInput]);
const hasChanges =
initialText !== inputText || selectedColor !== initialColor;
return (
<>
<div className="AvatarEditor__preview">
<BetterAvatarBubble
color={selectedColor}
i18n={i18n}
onSelect={focusInput}
style={{
height: BUBBLE_SIZE,
width: BUBBLE_SIZE,
}}
>
<input
className="AvatarTextEditor__input"
onChange={handleChange}
onPaste={handlePaste}
ref={inputRef}
style={{ fontSize }}
type="text"
value={inputText}
/>
</BetterAvatarBubble>
</div>
<hr className="AvatarEditor__divider" />
<AvatarColorPicker
i18n={i18n}
onColorSelected={color => {
setSelectedColor(color);
focusInput();
}}
selectedColor={selectedColor}
/>
<AvatarModalButtons
hasChanges={hasChanges}
i18n={i18n}
onCancel={onCancel}
onSave={handleDone}
/>
<div className="AvatarTextEditor__measure" ref={measureElRef}>
{inputText}
</div>
</>
);
};

View File

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { AvatarUploadButton, PropsType } from './AvatarUploadButton';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
className: overrideProps.className || '',
i18n,
onChange: action('onChange'),
});
const story = storiesOf('Components/AvatarUploadButton', module);
story.add('Default', () => <AvatarUploadButton {...createProps()} />);

View File

@ -0,0 +1,86 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ChangeEventHandler, useEffect, useRef, useState } from 'react';
import { noop } from 'lodash';
import { LocalizerType } from '../types/Util';
import { processImageFile } from '../util/processImageFile';
export type PropsType = {
className: string;
i18n: LocalizerType;
onChange: (avatar: ArrayBuffer) => unknown;
};
export const AvatarUploadButton = ({
className,
i18n,
onChange,
}: PropsType): JSX.Element => {
const fileInputRef = useRef<null | HTMLInputElement>(null);
const [processingFile, setProcessingFile] = useState<File | undefined>();
useEffect(() => {
if (!processingFile) {
return noop;
}
let shouldCancel = false;
(async () => {
let newAvatar: ArrayBuffer;
try {
newAvatar = await processImageFile(processingFile);
} catch (err) {
// Processing errors should be rare; if they do, we silently fail. In an ideal
// world, we may want to show a toast instead.
return;
}
if (shouldCancel) {
return;
}
setProcessingFile(undefined);
onChange(newAvatar);
})();
return () => {
shouldCancel = true;
};
}, [onChange, processingFile]);
const onInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const file = event.target.files && event.target.files[0];
if (file) {
setProcessingFile(file);
}
};
return (
<>
<button
className={className}
onClick={() => {
const fileInput = fileInputRef.current;
if (fileInput) {
// Setting the value to empty so that onChange always fires in case
// you add multiple photos.
fileInput.value = '';
fileInput.click();
}
}}
type="button"
>
{i18n('photo')}
</button>
<input
accept=".gif,.jpg,.jpeg,.png,.webp,image/gif,image/jpeg,image/png,image/webp"
hidden
onChange={onInputChange}
ref={fileInputRef}
type="file"
/>
</>
);
};

View File

@ -0,0 +1,62 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import { AvatarColors } from '../types/Colors';
import { GroupAvatarIcons, PersonalAvatarIcons } from '../types/Avatar';
import { BetterAvatar, PropsType } from './BetterAvatar';
import { createAvatarData } from '../util/createAvatarData';
import { setup as setupI18n } from '../../js/modules/i18n';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
avatarData:
overrideProps.avatarData ||
createAvatarData({ color: AvatarColors[0], text: 'OOO' }),
i18n,
isSelected: Boolean(overrideProps.isSelected),
onClick: action('onClick'),
onDelete: action('onDelete'),
size: 80,
});
const story = storiesOf('Components/BetterAvatar', module);
story.add('Text', () => (
<BetterAvatar
{...createProps({
avatarData: createAvatarData({
color: AvatarColors[0],
text: 'AH',
}),
})}
/>
));
story.add('Personal Icon', () => (
<BetterAvatar
{...createProps({
avatarData: createAvatarData({
color: AvatarColors[1],
icon: PersonalAvatarIcons[1],
}),
})}
/>
));
story.add('Group Icon', () => (
<BetterAvatar
{...createProps({
avatarData: createAvatarData({
color: AvatarColors[1],
icon: GroupAvatarIcons[1],
}),
})}
/>
));

View File

@ -0,0 +1,117 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { MouseEvent, useEffect, useState } from 'react';
import { noop } from 'lodash';
import { AvatarDataType } from '../types/Avatar';
import { BetterAvatarBubble } from './BetterAvatarBubble';
import { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
import { avatarDataToArrayBuffer } from '../util/avatarDataToArrayBuffer';
type AvatarSize = 48 | 80;
export type PropsType = {
avatarData: AvatarDataType;
i18n: LocalizerType;
isSelected?: boolean;
onClick: (avatarBuffer: ArrayBuffer | undefined) => unknown;
onDelete: () => unknown;
size?: AvatarSize;
};
export const BetterAvatar = ({
avatarData,
i18n,
isSelected,
onClick,
onDelete,
size = 48,
}: PropsType): JSX.Element => {
const [avatarBuffer, setAvatarBuffer] = useState<ArrayBuffer | undefined>(
avatarData.buffer
);
const [avatarURL, setAvatarURL] = useState<string | undefined>(undefined);
useEffect(() => {
let shouldCancel = false;
async function makeAvatar() {
const buffer = await avatarDataToArrayBuffer(avatarData);
if (!shouldCancel) {
setAvatarBuffer(buffer);
}
}
// If we don't have this we'll get lots of flashing because avatarData
// changes too much. Once we have a buffer set we don't need to reload.
if (avatarBuffer) {
return noop;
}
makeAvatar();
return () => {
shouldCancel = true;
};
}, [avatarBuffer, avatarData]);
// Convert avatar's ArrayBuffer to a URL object
useEffect(() => {
if (avatarBuffer) {
const url = URL.createObjectURL(new Blob([avatarBuffer]));
setAvatarURL(url);
}
}, [avatarBuffer]);
// Clean up any remaining object URLs
useEffect(() => {
return () => {
if (avatarURL) {
URL.revokeObjectURL(avatarURL);
}
};
}, [avatarURL]);
const isEditable = Boolean(avatarData.color);
const handleDelete = !avatarData.icon
? (ev: MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
onDelete();
}
: undefined;
return (
<BetterAvatarBubble
i18n={i18n}
isSelected={isSelected}
onDelete={handleDelete}
onSelect={() => {
onClick(avatarBuffer);
}}
style={{
backgroundImage: avatarURL ? `url(${avatarURL})` : undefined,
backgroundSize: size,
// +8 so that the size is the acutal size we want, 8 is the invisible
// padding around the bubble to make room for the selection border
height: size + 8,
width: size + 8,
}}
>
{isEditable && isSelected && (
<div className="BetterAvatarBubble--editable" />
)}
{!avatarURL && (
<div className="module-Avatar__spinner-container">
<Spinner
size={`${size - 8}px`}
svgSize="small"
direction="on-avatar"
/>
</div>
)}
</BetterAvatarBubble>
);
};

View File

@ -0,0 +1,56 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import { AvatarColors } from '../types/Colors';
import { BetterAvatarBubble, PropsType } from './BetterAvatarBubble';
import { setup as setupI18n } from '../../js/modules/i18n';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
children: overrideProps.children,
color: overrideProps.color,
i18n,
isSelected: Boolean(overrideProps.isSelected),
onDelete: action('onDelete'),
onSelect: action('onSelect'),
style: overrideProps.style,
});
const story = storiesOf('Components/BetterAvatarBubble', module);
story.add('Children', () => (
<BetterAvatarBubble
{...createProps({
children: <div>HI</div>,
color: AvatarColors[8],
})}
/>
));
story.add('Selected', () => (
<BetterAvatarBubble
{...createProps({
color: AvatarColors[1],
isSelected: true,
})}
/>
));
story.add('Style', () => (
<BetterAvatarBubble
{...createProps({
style: {
height: 120,
width: 120,
},
color: AvatarColors[2],
})}
/>
));

View File

@ -0,0 +1,60 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, MouseEvent, ReactNode } from 'react';
import classNames from 'classnames';
import { AvatarColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
export type PropsType = {
children?: ReactNode;
color?: AvatarColorType;
i18n: LocalizerType;
isSelected?: boolean;
onDelete?: (ev: MouseEvent) => unknown;
onSelect: () => unknown;
style?: CSSProperties;
};
export const BetterAvatarBubble = ({
children,
color,
i18n,
isSelected,
onDelete,
onSelect,
style,
}: PropsType): JSX.Element => {
return (
<div
className={classNames(
{
BetterAvatarBubble: true,
'BetterAvatarBubble--selected': isSelected,
},
color && `BetterAvatarBubble--${color}`
)}
onKeyDown={ev => {
if (ev.key === 'Enter') {
onSelect();
}
}}
onClick={onSelect}
role="button"
style={style}
tabIndex={0}
>
{onDelete && (
<button
aria-label={i18n('delete')}
className="BetterAvatarBubble__delete"
onClick={onDelete}
tabIndex={-1}
type="button"
/>
)}
{children}
</div>
);
};

View File

@ -3,6 +3,7 @@
import React, { useRef, useEffect } from 'react';
import { LocalizerType } from '../types/Util';
import { AvatarColors } from '../types/Colors';
import { Avatar } from './Avatar';
import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName';
@ -46,7 +47,7 @@ export const CallNeedPermissionScreen: React.FC<Props> = ({
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPath={conversation.avatarPath}
color={conversation.color || 'ultramarine'}
color={conversation.color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}

View File

@ -24,7 +24,7 @@ import {
PresentedSource,
VideoFrameSource,
} from '../types/Calling';
import { AvatarColorType } from '../types/Colors';
import { AvatarColors, AvatarColorType } from '../types/Colors';
import { CallingToastManager } from './CallingToastManager';
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
@ -343,7 +343,7 @@ export const CallScreen: React.FC<PropsType> = ({
<Avatar
acceptedMessageRequest
avatarPath={me.avatarPath}
color={me.color || 'ultramarine'}
color={me.color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
@ -418,7 +418,7 @@ export const CallScreen: React.FC<PropsType> = ({
<Avatar
acceptedMessageRequest
avatarPath={me.avatarPath}
color={me.color || 'ultramarine'}
color={me.color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}

View File

@ -15,6 +15,7 @@ import {
GroupCallVideoRequest,
VideoFrameSource,
} from '../types/Calling';
import { AvatarColors } from '../types/Colors';
import { SetRendererCanvasType } from '../state/ducks/calling';
import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer';
import { usePageVisibility } from '../util/hooks';
@ -50,7 +51,7 @@ const NoVideo = ({
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}

View File

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { ConfirmDiscardDialog, PropsType } from './ConfirmDiscardDialog';
const i18n = setupI18n('en', enMessages);
const createProps = (): PropsType => ({
i18n,
onClose: action('onClose'),
onDiscard: action('onDiscard'),
});
const story = storiesOf('Components/ConfirmDiscardDialog', module);
story.add('Default', () => <ConfirmDiscardDialog {...createProps()} />);

View File

@ -0,0 +1,31 @@
import React from 'react';
import { ConfirmationDialog } from './ConfirmationDialog';
import { LocalizerType } from '../types/Util';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
onDiscard: () => unknown;
};
export const ConfirmDiscardDialog = ({
i18n,
onClose,
onDiscard,
}: PropsType): JSX.Element | null => {
return (
<ConfirmationDialog
actions={[
{
action: onDiscard,
text: i18n('discard'),
style: 'negative',
},
]}
i18n={i18n}
onClose={onClose}
>
{i18n('ConfirmDiscardDialog--discard')}
</ConfirmationDialog>
);
};

View File

@ -10,6 +10,7 @@ import { gifUrl } from '../storybook/Fixtures';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { ContactListItem } from './ContactListItem';
import { getRandomColor } from '../test-both/helpers/getRandomColor';
const i18n = setupI18n('en', enMessages);
const onClick = action('onClick');
@ -126,7 +127,7 @@ storiesOf('Components/ContactListItem', module)
isMe={false}
title="Someone 🔥 Somewhere"
name="Someone 🔥 Somewhere"
color="teal"
color={getRandomColor()}
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
sharedGroupNames={[]}
@ -140,7 +141,7 @@ storiesOf('Components/ContactListItem', module)
<ContactListItem
type="direct"
acceptedMessageRequest
color="blue"
color={getRandomColor()}
i18n={i18n}
isMe={false}
phoneNumber="(202) 555-0011"

View File

@ -22,7 +22,6 @@ type ContactType = Omit<ContactPillPropsType, 'i18n' | 'onClickRemove'>;
const contacts: Array<ContactType> = times(50, index =>
getDefaultConversation({
color: 'crimson',
id: `contact-${index}`,
name: `Contact ${index}`,
phoneNumber: '(202) 555-0001',
@ -37,7 +36,6 @@ const contactPillProps = (
...(overrideProps ||
getDefaultConversation({
avatarPath: gifUrl,
color: 'crimson',
firstName: 'John',
id: 'abc123',
isMe: false,

View File

@ -5,6 +5,7 @@ import React, { useRef, useEffect } from 'react';
import { SetRendererCanvasType } from '../state/ducks/calling';
import { ConversationType } from '../state/ducks/conversations';
import { LocalizerType } from '../types/Util';
import { AvatarColors } from '../types/Colors';
import { Avatar } from './Avatar';
type PropsType = {
@ -69,7 +70,7 @@ function renderAvatar(
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}

View File

@ -16,6 +16,7 @@ import {
VideoFrameSource,
} from '../types/Calling';
import { LocalizerType } from '../types/Util';
import { AvatarColors } from '../types/Colors';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog';
@ -331,7 +332,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}

View File

@ -11,6 +11,7 @@ import { AvatarColors } 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';
import { getRandomColor } from '../test-both/helpers/getRandomColor';
const i18n = setupI18n('en', enMessages);
@ -25,7 +26,6 @@ const defaultProps = {
conversation: getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
color: AvatarColors[0],
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
@ -37,7 +37,7 @@ const defaultProps = {
storiesOf('Components/IncomingCallBar', module)
.add('Knobs Playground', () => {
const color = select('color', AvatarColors, 'ultramarine');
const color = select('color', AvatarColors, getRandomColor());
const isVideoCall = boolean('isVideoCall', false);
const name = text(
'name',

View File

@ -7,6 +7,7 @@ import { Tooltip } from './Tooltip';
import { Theme } from '../util/theme';
import { ContactName } from './conversation/ContactName';
import { LocalizerType } from '../types/Util';
import { AvatarColors } from '../types/Colors';
import { ConversationType } from '../state/ducks/conversations';
import { AcceptCallType, DeclineCallType } from '../state/ducks/calling';
@ -89,7 +90,7 @@ export const IncomingCallBar = ({
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || 'ultramarine'}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
i18n={i18n}

View File

@ -82,6 +82,9 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'),
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'),
composeReplaceAvatar: action('composeReplaceAvatar'),
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
createGroup: action('createGroup'),
i18n,
modeSpecificProps: defaultModeSpecificProps,
@ -135,6 +138,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
'startNewConversationFromPhoneNumber'
),
startSettingGroupMetadata: action('startSettingGroupMetadata'),
toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'),
toggleConversationInChooseMembers: action(
'toggleConversationInChooseMembers'
),
@ -528,7 +532,9 @@ story.add('Group Metadata: No Timer', () => (
groupExpireTimer: 0,
hasError: false,
isCreating: false,
isEditingAvatar: false,
selectedContacts: defaultConversations,
userAvatarData: [],
},
})}
/>
@ -544,7 +550,9 @@ story.add('Group Metadata: Regular Timer', () => (
groupExpireTimer: 24 * 3600,
hasError: false,
isCreating: false,
isEditingAvatar: false,
selectedContacts: defaultConversations,
userAvatarData: [],
},
})}
/>
@ -560,7 +568,9 @@ story.add('Group Metadata: Custom Timer', () => (
groupExpireTimer: 7 * 3600,
hasError: false,
isCreating: false,
isEditingAvatar: false,
selectedContacts: defaultConversations,
userAvatarData: [],
},
})}
/>

View File

@ -43,6 +43,12 @@ import { missingCaseError } from '../util/missingCaseError';
import { ConversationList } from './ConversationList';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import {
DeleteAvatarFromDiskActionType,
ReplaceAvatarActionType,
SaveAvatarToDiskActionType,
} from '../types/Avatar';
export enum LeftPaneMode {
Inbox,
Search,
@ -105,6 +111,10 @@ export type PropsType = {
showChooseGroupMembers: () => void;
startSettingGroupMetadata: () => void;
toggleConversationInChooseMembers: (conversationId: string) => void;
composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
toggleComposeEditingAvatar: () => unknown;
// Render Props
renderExpiredBuildDialog: () => JSX.Element;
@ -118,35 +128,39 @@ export type PropsType = {
export const LeftPane: React.FC<PropsType> = ({
cantAddContactToGroup,
challengeStatus,
clearGroupCreationError,
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
composeDeleteAvatarFromDisk,
composeReplaceAvatar,
composeSaveAvatarToDisk,
createGroup,
i18n,
modeSpecificProps,
challengeStatus,
setChallengeStatus,
openConversationInternal,
renderCaptchaDialog,
renderExpiredBuildDialog,
renderMainHeader,
renderMessageSearchResult,
renderNetworkStatus,
renderRelinkDialog,
renderUpdateDialog,
renderCaptchaDialog,
selectedConversationId,
selectedMessageId,
setComposeSearchTerm,
setChallengeStatus,
setComposeGroupAvatar,
setComposeGroupName,
setComposeGroupExpireTimer,
setComposeGroupName,
setComposeSearchTerm,
showArchivedConversations,
showChooseGroupMembers,
showInbox,
startComposing,
showChooseGroupMembers,
startNewConversationFromPhoneNumber,
startSettingGroupMetadata,
toggleComposeEditingAvatar,
toggleConversationInChooseMembers,
}) => {
const previousModeSpecificProps = usePrevious(
@ -340,11 +354,15 @@ export const LeftPane: React.FC<PropsType> = ({
closeCantAddContactToGroupModal,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
composeDeleteAvatarFromDisk,
composeReplaceAvatar,
composeSaveAvatarToDisk,
createGroup,
i18n,
setComposeGroupAvatar,
setComposeGroupName,
setComposeGroupExpireTimer,
toggleComposeEditingAvatar,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);
},

View File

@ -129,3 +129,18 @@ story.add('Including Next/Previous/Save Callbacks', () => {
return <Lightbox {...props} />;
});
story.add('Custom children', () => (
<Lightbox {...createProps({})} contentType={undefined}>
<div
style={{
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
I am middle child
</div>
</Lightbox>
));

View File

@ -1,7 +1,7 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import is from '@sindresorhus/is';
@ -25,6 +25,7 @@ const colorSVG = (url: string, color: string) => {
};
export type Props = {
children?: ReactNode;
close: () => void;
contentType: MIME.MIMEType | undefined;
i18n: LocalizerType;
@ -53,6 +54,7 @@ const styles = {
top: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
zIndex: 10,
} as React.CSSProperties,
buttonContainer: {
backgroundColor: 'transparent',
@ -298,6 +300,7 @@ export class Lightbox extends React.Component<Props, State> {
public render(): JSX.Element {
const {
caption,
children,
contentType,
i18n,
isViewOnce,
@ -329,7 +332,7 @@ export class Lightbox extends React.Component<Props, State> {
isViewOnce,
loop,
})
: null}
: children}
{caption ? <div style={styles.caption}>{caption}</div> : null}
</div>
<div style={styles.controls}>

View File

@ -106,3 +106,45 @@ story.add('Long body with long title and X button', () => (
<p>{LOREM_IPSUM}</p>
</Modal>
));
story.add('With sticky buttons long body', () => (
<Modal hasStickyButtons hasXButton i18n={i18n} onClose={onClose}>
<p>{LOREM_IPSUM}</p>
<p>{LOREM_IPSUM}</p>
<p>{LOREM_IPSUM}</p>
<p>{LOREM_IPSUM}</p>
<Modal.ButtonFooter>
<Button onClick={noop}>Okay</Button>
<Button onClick={noop}>Okay</Button>
</Modal.ButtonFooter>
</Modal>
));
story.add('With sticky buttons short body', () => (
<Modal hasStickyButtons hasXButton i18n={i18n} onClose={onClose}>
<p>{LOREM_IPSUM.slice(0, 140)}</p>
<Modal.ButtonFooter>
<Button onClick={noop}>Okay</Button>
<Button onClick={noop}>Okay</Button>
</Modal.ButtonFooter>
</Modal>
));
story.add('Sticky footer, Lots of buttons', () => (
<Modal hasStickyButtons i18n={i18n} onClose={onClose} title="OK">
<p>{LOREM_IPSUM}</p>
<Modal.ButtonFooter>
<Button onClick={noop}>Okay</Button>
<Button onClick={noop}>Okay</Button>
<Button onClick={noop}>Okay</Button>
<Button onClick={noop}>
This is a button with a fairly large amount of text
</Button>
<Button onClick={noop}>Okay</Button>
<Button onClick={noop}>
This is a button with a fairly large amount of text
</Button>
<Button onClick={noop}>Okay</Button>
</Modal.ButtonFooter>
</Modal>
));

View File

@ -1,7 +1,8 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, ReactElement, ReactNode } from 'react';
import React, { useRef, useState, ReactElement, ReactNode } from 'react';
import Measure, { ContentRect, MeasuredComponentProps } from 'react-measure';
import classNames from 'classnames';
import { noop } from 'lodash';
@ -13,6 +14,7 @@ import { useHasWrapped } from '../util/hooks';
type PropsType = {
children: ReactNode;
hasStickyButtons?: boolean;
hasXButton?: boolean;
i18n: LocalizerType;
moduleClassName?: string;
@ -26,6 +28,7 @@ const BASE_CLASS_NAME = 'module-Modal';
export function Modal({
children,
hasStickyButtons,
hasXButton,
i18n,
moduleClassName,
@ -34,18 +37,32 @@ export function Modal({
title,
theme,
}: Readonly<PropsType>): ReactElement {
const modalRef = useRef<HTMLDivElement | null>(null);
const [scrolled, setScrolled] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const hasHeader = Boolean(hasXButton || title);
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
function handleResize({ scroll }: ContentRect) {
const modalNode = modalRef?.current;
if (!modalNode) {
return;
}
if (scroll) {
setHasOverflow(scroll.height > modalNode.clientHeight);
}
}
return (
<ModalHost noMouseClose={noMouseClose} onClose={onClose} theme={theme}>
<div
className={classNames(
getClassName(''),
getClassName(hasHeader ? '--has-header' : '--no-header')
getClassName(hasHeader ? '--has-header' : '--no-header'),
hasStickyButtons && getClassName('--sticky-buttons')
)}
ref={modalRef}
>
{hasHeader && (
<div className={getClassName('__header')}>
@ -72,17 +89,25 @@ export function Modal({
)}
</div>
)}
<div
className={classNames(
getClassName('__body'),
scrolled ? getClassName('__body--scrolled') : null
<Measure scroll onResize={handleResize}>
{({ measureRef }: MeasuredComponentProps) => (
<div
className={classNames(
getClassName('__body'),
scrolled ? getClassName('__body--scrolled') : null,
hasOverflow || scrolled
? getClassName('__body--overflow')
: null
)}
onScroll={event => {
setScrolled((event.target as HTMLDivElement).scrollTop > 2);
}}
ref={measureRef}
>
{children}
</div>
)}
onScroll={event => {
setScrolled((event.target as HTMLDivElement).scrollTop > 2);
}}
>
{children}
</div>
</Measure>
</div>
</ModalHost>
);

View File

@ -14,6 +14,7 @@ import {
getFirstName,
getLastName,
} from '../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../test-both/helpers/getRandomColor';
const i18n = setupI18n('en', enMessages);
@ -23,6 +24,9 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
aboutEmoji: overrideProps.aboutEmoji,
aboutText: text('about', overrideProps.aboutText || ''),
avatarPath: overrideProps.avatarPath,
conversationId: '123',
color: overrideProps.color || getRandomColor(),
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
familyName: overrideProps.familyName,
firstName: text('firstName', overrideProps.firstName || getFirstName()),
i18n,
@ -30,7 +34,10 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
onProfileChanged: action('onProfileChanged'),
onSetSkinTone: overrideProps.onSetSkinTone || action('onSetSkinTone'),
recentEmojis: [],
replaceAvatar: action('replaceAvatar'),
saveAvatarToDisk: action('saveAvatarToDisk'),
skinTone: overrideProps.skinTone || 0,
userAvatarData: [],
});
stories.add('Full Set', () => {

View File

@ -3,16 +3,24 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { AvatarInputContainer } from './AvatarInputContainer';
import { AvatarInputType } from './AvatarInput';
import { AvatarColors, AvatarColorType } from '../types/Colors';
import {
AvatarDataType,
DeleteAvatarFromDiskActionType,
ReplaceAvatarActionType,
SaveAvatarToDiskActionType,
} from '../types/Avatar';
import { AvatarEditor } from './AvatarEditor';
import { AvatarPreview } from './AvatarPreview';
import { Button, ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
import { Emoji } from './emoji/Emoji';
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { Input } from './Input';
import { Intl } from './Intl';
import { LocalizerType } from '../types/Util';
import { Modal } from './Modal';
import { PanelRow } from './conversation/conversation-details/PanelRow';
import { ProfileDataType } from '../state/ducks/conversations';
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
@ -20,6 +28,7 @@ import { missingCaseError } from '../util/missingCaseError';
export enum EditState {
None = 'None',
BetterAvatar = 'BetterAvatar',
ProfileName = 'ProfileName',
Bio = 'Bio',
}
@ -28,7 +37,7 @@ type PropsExternalType = {
onEditStateChanged: (editState: EditState) => unknown;
onProfileChanged: (
profileData: ProfileDataType,
avatarData?: ArrayBuffer
avatarBuffer?: ArrayBuffer
) => unknown;
};
@ -36,13 +45,19 @@ export type PropsDataType = {
aboutEmoji?: string;
aboutText?: string;
avatarPath?: string;
color?: AvatarColorType;
conversationId: string;
familyName?: string;
firstName: string;
i18n: LocalizerType;
userAvatarData: Array<AvatarDataType>;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type PropsActionType = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
onSetSkinTone: (tone: number) => unknown;
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
};
export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
@ -79,6 +94,9 @@ export const ProfileEditor = ({
aboutEmoji,
aboutText,
avatarPath,
color,
conversationId,
deleteAvatarFromDisk,
familyName,
firstName,
i18n,
@ -86,7 +104,10 @@ export const ProfileEditor = ({
onProfileChanged,
onSetSkinTone,
recentEmojis,
replaceAvatar,
saveAvatarToDisk,
skinTone,
userAvatarData,
}: PropsType): JSX.Element => {
const focusInputRef = useRef<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(EditState.None);
@ -105,7 +126,7 @@ export const ProfileEditor = ({
aboutText,
});
const [avatarData, setAvatarData] = useState<ArrayBuffer | undefined>(
const [avatarBuffer, setAvatarBuffer] = useState<ArrayBuffer | undefined>(
undefined
);
const [stagedProfile, setStagedProfile] = useState<ProfileDataType>({
@ -115,8 +136,6 @@ export const ProfileEditor = ({
firstName,
});
let content: JSX.Element;
const handleBack = useCallback(() => {
setEditState(EditState.None);
onEditStateChanged(EditState.None);
@ -135,9 +154,11 @@ export const ProfileEditor = ({
const handleAvatarChanged = useCallback(
(avatar: ArrayBuffer | undefined) => {
setAvatarData(avatar);
setAvatarBuffer(avatar);
setEditState(EditState.None);
onProfileChanged(stagedProfile, avatar);
},
[setAvatarData]
[onProfileChanged, stagedProfile]
);
const getTextEncoder = useCallback(() => new TextEncoder(), []);
@ -154,6 +175,10 @@ export const ProfileEditor = ({
[countByteLength]
);
const getFullNameText = () => {
return [fullName.firstName, fullName.familyName].filter(Boolean).join(' ');
};
useEffect(() => {
const focusNode = focusInputRef.current;
if (!focusNode) {
@ -163,7 +188,34 @@ export const ProfileEditor = ({
focusNode.focus();
}, [editState]);
if (editState === EditState.ProfileName) {
useEffect(() => {
onEditStateChanged(editState);
}, [editState, onEditStateChanged]);
const handleAvatarLoaded = useCallback(avatar => {
setAvatarBuffer(avatar);
}, []);
let content: JSX.Element;
if (editState === EditState.BetterAvatar) {
content = (
<AvatarEditor
avatarColor={color || AvatarColors[0]}
avatarPath={avatarPath}
avatarValue={avatarBuffer}
conversationId={conversationId}
conversationTitle={getFullNameText()}
deleteAvatarFromDisk={deleteAvatarFromDisk}
i18n={i18n}
onCancel={handleBack}
onSave={handleAvatarChanged}
userAvatarData={userAvatarData}
replaceAvatar={replaceAvatar}
saveAvatarToDisk={saveAvatarToDisk}
/>
);
} else if (editState === EditState.ProfileName) {
const shouldDisableSave =
!stagedProfile.firstName ||
(stagedProfile.firstName === fullName.firstName &&
@ -200,7 +252,7 @@ export const ProfileEditor = ({
placeholder={i18n('ProfileEditor--last-name')}
value={stagedProfile.familyName}
/>
<div className="ProfileEditor__buttons">
<Modal.ButtonFooter>
<Button
onClick={() => {
const handleCancel = () => {
@ -236,13 +288,13 @@ export const ProfileEditor = ({
familyName: stagedProfile.familyName,
});
onProfileChanged(stagedProfile, avatarData);
onProfileChanged(stagedProfile, avatarBuffer);
handleBack();
}}
>
{i18n('save')}
</Button>
</div>
</Modal.ButtonFooter>
</>
);
} else if (editState === EditState.Bio) {
@ -314,7 +366,7 @@ export const ProfileEditor = ({
/>
))}
<div className="ProfileEditor__buttons">
<Modal.ButtonFooter>
<Button
onClick={() => {
const handleCancel = () => {
@ -346,32 +398,32 @@ export const ProfileEditor = ({
aboutText: stagedProfile.aboutText,
});
onProfileChanged(stagedProfile, avatarData);
onProfileChanged(stagedProfile, avatarBuffer);
handleBack();
}}
>
{i18n('save')}
</Button>
</div>
</Modal.ButtonFooter>
</>
);
} else if (editState === EditState.None) {
const fullNameText = [fullName.firstName, fullName.familyName]
.filter(Boolean)
.join(' ');
content = (
<>
<AvatarInputContainer
<AvatarPreview
avatarColor={color}
avatarPath={avatarPath}
contextMenuId="edit-self-profile-avatar"
avatarValue={avatarBuffer}
conversationTitle={getFullNameText()}
i18n={i18n}
onAvatarChanged={avatar => {
handleAvatarChanged(avatar);
onProfileChanged(stagedProfile, avatar);
onAvatarLoaded={handleAvatarLoaded}
onClick={() => {
setEditState(EditState.BetterAvatar);
}}
style={{
height: 96,
width: 96,
}}
onAvatarLoaded={handleAvatarChanged}
type={AvatarInputType.Profile}
/>
<hr className="ProfileEditor__divider" />
@ -381,10 +433,9 @@ export const ProfileEditor = ({
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--name" />
}
label={fullNameText}
label={getFullNameText()}
onClick={() => {
setEditState(EditState.ProfileName);
onEditStateChanged(EditState.ProfileName);
}}
/>
@ -402,7 +453,6 @@ export const ProfileEditor = ({
label={fullBio.aboutText || i18n('ProfileEditor--about')}
onClick={() => {
setEditState(EditState.Bio);
onEditStateChanged(EditState.Bio);
}}
/>
@ -434,19 +484,11 @@ export const ProfileEditor = ({
return (
<>
{confirmDiscardAction && (
<ConfirmationDialog
actions={[
{
action: confirmDiscardAction,
text: i18n('discard'),
style: 'negative',
},
]}
<ConfirmDiscardDialog
i18n={i18n}
onDiscard={confirmDiscardAction}
onClose={() => setConfirmDiscardAction(undefined)}
>
{i18n('ProfileEditor--discard')}
</ConfirmationDialog>
/>
)}
<div className="ProfileEditor">{content}</div>
</>

View File

@ -18,7 +18,7 @@ export type PropsDataType = {
type PropsType = {
myProfileChanged: (
profileData: ProfileDataType,
avatarData?: ArrayBuffer
avatarBuffer?: ArrayBuffer
) => unknown;
toggleProfileEditor: () => unknown;
toggleProfileEditorHasError: () => unknown;
@ -57,6 +57,7 @@ export const ProfileEditorModal = ({
return (
<>
<Modal
hasStickyButtons
hasXButton
i18n={i18n}
onClose={toggleProfileEditor}
@ -74,8 +75,8 @@ export const ProfileEditorModal = ({
setModalTitle(ModalTitles.Bio);
}
}}
onProfileChanged={(profileData, avatarData) => {
myProfileChanged(profileData, avatarData);
onProfileChanged={(profileData, avatarBuffer) => {
myProfileChanged(profileData, avatarBuffer);
}}
onSetSkinTone={onSetSkinTone}
/>

View File

@ -15,7 +15,6 @@ const i18n = setupI18n('en', enMessages);
const contactWithAllData = getDefaultConversation({
id: 'abc',
avatarPath: undefined,
color: 'ultramarine',
profileName: '-*Smartest Dude*-',
title: 'Rick Sanchez',
name: 'Rick Sanchez',
@ -25,7 +24,6 @@ const contactWithAllData = getDefaultConversation({
const contactWithJustProfile = getDefaultConversation({
id: 'def',
avatarPath: undefined,
color: 'ultramarine',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
@ -35,7 +33,6 @@ const contactWithJustProfile = getDefaultConversation({
const contactWithJustNumber = getDefaultConversation({
id: 'xyz',
avatarPath: undefined,
color: 'ultramarine',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
@ -45,7 +42,6 @@ const contactWithJustNumber = getDefaultConversation({
const contactWithNothing = getDefaultConversation({
id: 'some-guid',
avatarPath: undefined,
color: 'ultramarine',
profileName: undefined,
name: undefined,
phoneNumber: undefined,

View File

@ -7,46 +7,43 @@ import { boolean, text } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { PropsType, SafetyNumberViewer } from './SafetyNumberViewer';
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 contactWithAllData = {
const contactWithAllData = getDefaultConversation({
title: 'Summer Smith',
name: 'Summer Smith',
phoneNumber: '(305) 123-4567',
isVerified: true,
} as ConversationType;
});
const contactWithJustProfile = {
const contactWithJustProfile = getDefaultConversation({
avatarPath: undefined,
color: 'ultramarine',
title: '-*Smartest Dude*-',
profileName: '-*Smartest Dude*-',
name: undefined,
phoneNumber: '(305) 123-4567',
} as ConversationType;
});
const contactWithJustNumber = {
const contactWithJustNumber = getDefaultConversation({
avatarPath: undefined,
color: 'ultramarine',
profileName: undefined,
name: undefined,
title: '(305) 123-4567',
phoneNumber: '(305) 123-4567',
} as ConversationType;
});
const contactWithNothing = {
const contactWithNothing = getDefaultConversation({
id: 'some-guid',
avatarPath: undefined,
color: 'ultramarine',
profileName: undefined,
title: 'Unknown contact',
name: undefined,
phoneNumber: undefined,
} as ConversationType;
});
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
contact: overrideProps.contact || contactWithAllData,

View File

@ -1,14 +1,15 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactPortal } from 'react';
import React, { ReactPortal, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ConversationType } from '../../state/ducks/conversations';
import { About } from './About';
import { Avatar } from '../Avatar';
import { SharedGroupNames } from '../SharedGroupNames';
import { AvatarLightbox } from '../AvatarLightbox';
import { ConversationType } from '../../state/ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { SharedGroupNames } from '../SharedGroupNames';
export type PropsType = {
areWeAdmin: boolean;
@ -41,11 +42,13 @@ export const ContactModal = ({
throw new Error('Contact modal opened without a matching contact');
}
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const overlayRef = React.useRef<HTMLElement | null>(null);
const closeButtonRef = React.useRef<HTMLElement | null>(null);
const [root, setRoot] = useState<HTMLElement | null>(null);
const overlayRef = useRef<HTMLElement | null>(null);
const closeButtonRef = useRef<HTMLElement | null>(null);
React.useEffect(() => {
const [showingAvatar, setShowingAvatar] = useState(false);
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
@ -56,18 +59,18 @@ export const ContactModal = ({
};
}, []);
React.useEffect(() => {
useEffect(() => {
// Kick off the expensive hydration of the current sharedGroupNames
updateSharedGroups();
}, [updateSharedGroups]);
React.useEffect(() => {
useEffect(() => {
if (root !== null && closeButtonRef.current) {
closeButtonRef.current.focus();
}
}, [root]);
React.useEffect(() => {
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
@ -92,6 +95,115 @@ export const ContactModal = ({
}
};
let content: JSX.Element;
if (showingAvatar) {
content = (
<AvatarLightbox
avatarColor={contact.color}
avatarPath={contact.avatarPath}
conversationTitle={contact.title}
i18n={i18n}
onClose={() => setShowingAvatar(false)}
/>
);
} else {
content = (
<div className="module-contact-modal">
<button
ref={r => {
closeButtonRef.current = r;
}}
type="button"
className="module-contact-modal__close-button"
onClick={onClose}
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}
onClick={() => setShowingAvatar(true)}
/>
<div className="module-contact-modal__name">{contact.title}</div>
<div className="module-about__container">
<About text={contact.about} />
</div>
{contact.phoneNumber && (
<div className="module-contact-modal__info">
{contact.phoneNumber}
</div>
)}
<div className="module-contact-modal__info">
<SharedGroupNames
i18n={i18n}
sharedGroupNames={contact.sharedGroupNames || []}
/>
</div>
<div className="module-contact-modal__button-container">
<button
type="button"
className="module-contact-modal__button module-contact-modal__send-message"
onClick={() => openConversation(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__send-message__bubble-icon" />
</div>
<span>{i18n('ContactModal--message')}</span>
</button>
{!contact.isMe && (
<button
type="button"
className="module-contact-modal__button module-contact-modal__safety-number"
onClick={() => showSafetyNumber(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__safety-number__bubble-icon" />
</div>
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && areWeAdmin && isMember && (
<>
<button
type="button"
className="module-contact-modal__button module-contact-modal__make-admin"
onClick={() => toggleAdmin(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__make-admin__bubble-icon" />
</div>
{isAdmin ? (
<span>{i18n('ContactModal--rm-admin')}</span>
) : (
<span>{i18n('ContactModal--make-admin')}</span>
)}
</button>
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
</>
)}
</div>
</div>
);
}
return root
? createPortal(
<div
@ -102,98 +214,7 @@ export const ContactModal = ({
className="module-contact-modal__overlay"
onClick={onClickOverlay}
>
<div className="module-contact-modal">
<button
ref={r => {
closeButtonRef.current = r;
}}
type="button"
className="module-contact-modal__close-button"
onClick={onClose}
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">
<About text={contact.about} />
</div>
{contact.phoneNumber && (
<div className="module-contact-modal__info">
{contact.phoneNumber}
</div>
)}
<div className="module-contact-modal__info">
<SharedGroupNames
i18n={i18n}
sharedGroupNames={contact.sharedGroupNames || []}
/>
</div>
<div className="module-contact-modal__button-container">
<button
type="button"
className="module-contact-modal__button module-contact-modal__send-message"
onClick={() => openConversation(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__send-message__bubble-icon" />
</div>
<span>{i18n('ContactModal--message')}</span>
</button>
{!contact.isMe && (
<button
type="button"
className="module-contact-modal__button module-contact-modal__safety-number"
onClick={() => showSafetyNumber(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__safety-number__bubble-icon" />
</div>
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && areWeAdmin && isMember && (
<>
<button
type="button"
className="module-contact-modal__button module-contact-modal__make-admin"
onClick={() => toggleAdmin(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__make-admin__bubble-icon" />
</div>
{isAdmin ? (
<span>{i18n('ContactModal--rm-admin')}</span>
) : (
<span>{i18n('ContactModal--make-admin')}</span>
)}
</button>
<button
type="button"
className="module-contact-modal__button module-contact-modal__remove-from-group"
onClick={() => removeMember(contact.id)}
>
<div className="module-contact-modal__bubble-icon">
<div className="module-contact-modal__remove-from-group__bubble-icon" />
</div>
<span>{i18n('ContactModal--remove-from-group')}</span>
</button>
</>
)}
</div>
</div>
{content}
</div>,
root
)

View File

@ -7,6 +7,7 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import {
@ -71,7 +72,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'With name and profile, verified',
props: {
...commonProps,
color: 'crimson',
color: getRandomColor(),
isVerified: true,
avatarPath: gifUrl,
title: 'Someone 🔥 Somewhere',
@ -87,7 +88,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'With name, not verified, no avatar',
props: {
...commonProps,
color: 'blue',
color: getRandomColor(),
isVerified: false,
title: 'Someone 🔥 Somewhere',
name: 'Someone 🔥 Somewhere',
@ -101,7 +102,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'With name, not verified, descenders',
props: {
...commonProps,
color: 'blue',
color: getRandomColor(),
isVerified: false,
title: 'Joyrey 🔥 Leppey',
name: 'Joyrey 🔥 Leppey',
@ -115,7 +116,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Profile, no name',
props: {
...commonProps,
color: 'wintergreen',
color: getRandomColor(),
isVerified: false,
phoneNumber: '(202) 555-0003',
type: 'direct',
@ -141,7 +142,7 @@ const stories: Array<ConversationHeaderStory> = [
props: {
...commonProps,
showBackButton: true,
color: 'vermilion',
color: getRandomColor(),
phoneNumber: '(202) 555-0004',
title: '(202) 555-0004',
type: 'direct',
@ -153,7 +154,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Disappearing messages set',
props: {
...commonProps,
color: 'indigo',
color: getRandomColor(),
title: '(202) 555-0005',
phoneNumber: '(202) 555-0005',
type: 'direct',
@ -166,7 +167,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Disappearing messages + verified',
props: {
...commonProps,
color: 'indigo',
color: getRandomColor(),
title: '(202) 555-0005',
phoneNumber: '(202) 555-0005',
type: 'direct',
@ -181,7 +182,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Muting Conversation',
props: {
...commonProps,
color: 'ultramarine',
color: getRandomColor(),
title: '(202) 555-0006',
phoneNumber: '(202) 555-0006',
type: 'direct',
@ -194,7 +195,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'SMS-only conversation',
props: {
...commonProps,
color: 'ultramarine',
color: getRandomColor(),
title: '(202) 555-0006',
phoneNumber: '(202) 555-0006',
type: 'direct',
@ -214,7 +215,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Basic',
props: {
...commonProps,
color: 'ultramarine',
color: getRandomColor(),
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@ -229,7 +230,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'In a group you left - no disappearing messages',
props: {
...commonProps,
color: 'ultramarine',
color: getRandomColor(),
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@ -245,7 +246,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'In a group with an active group call',
props: {
...commonProps,
color: 'ultramarine',
color: getRandomColor(),
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
@ -260,7 +261,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'In a forever muted group',
props: {
...commonProps,
color: 'ultramarine',
color: getRandomColor(),
title: 'Way too many messages',
name: 'Way too many messages',
phoneNumber: '',
@ -282,7 +283,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'In chat with yourself',
props: {
...commonProps,
color: 'blue',
color: getRandomColor(),
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '15',
@ -302,7 +303,7 @@ const stories: Array<ConversationHeaderStory> = [
title: '1:1 conversation',
props: {
...commonProps,
color: 'blue',
color: getRandomColor(),
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '16',

View File

@ -44,7 +44,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
contacts: overrideProps.contacts || [
{
...getDefaultConversation({
color: 'indigo',
title: 'Just Max',
}),
isOutgoingKeyError: false,
@ -124,7 +123,6 @@ story.add('Message Statuses', () => {
contacts: [
{
...getDefaultConversation({
color: 'forest',
title: 'Max',
}),
isOutgoingKeyError: false,
@ -133,7 +131,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
color: 'blue',
title: 'Sally',
}),
isOutgoingKeyError: false,
@ -142,7 +139,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
color: 'burlap',
title: 'Terry',
}),
isOutgoingKeyError: false,
@ -151,7 +147,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
color: 'wintergreen',
title: 'Theo',
}),
isOutgoingKeyError: false,
@ -160,7 +155,6 @@ story.add('Message Statuses', () => {
},
{
...getDefaultConversation({
color: 'steel',
title: 'Nikki',
}),
isOutgoingKeyError: false,
@ -217,7 +211,6 @@ story.add('All Errors', () => {
contacts: [
{
...getDefaultConversation({
color: 'forest',
title: 'Max',
}),
isOutgoingKeyError: true,
@ -226,7 +219,6 @@ story.add('All Errors', () => {
},
{
...getDefaultConversation({
color: 'blue',
title: 'Sally',
}),
errors: [
@ -241,7 +233,6 @@ story.add('All Errors', () => {
},
{
...getDefaultConversation({
color: 'taupe',
title: 'Terry',
}),
isOutgoingKeyError: true,

View File

@ -15,6 +15,7 @@ import { PropsType, Timeline } from './Timeline';
import { TimelineItem, TimelineItemType } from './TimelineItem';
import { ConversationHero } from './ConversationHero';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { LastSeenIndicator } from './LastSeenIndicator';
import { TimelineLoadingRow } from './TimelineLoadingRow';
import { TypingBubble } from './TypingBubble';
@ -38,7 +39,6 @@ const items: Record<string, TimelineItemType> = {
data: {
author: getDefaultConversation({
phoneNumber: '(202) 555-2001',
color: 'forest',
}),
canDeleteForEveryone: false,
canDownload: true,
@ -58,7 +58,7 @@ const items: Record<string, TimelineItemType> = {
'id-2': {
type: 'message',
data: {
author: getDefaultConversation({ color: 'forest' }),
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@ -90,7 +90,7 @@ const items: Record<string, TimelineItemType> = {
'id-3': {
type: 'message',
data: {
author: getDefaultConversation({ color: 'crimson' }),
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@ -188,7 +188,7 @@ const items: Record<string, TimelineItemType> = {
'id-10': {
type: 'message',
data: {
author: getDefaultConversation({ color: 'plum' }),
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@ -208,7 +208,7 @@ const items: Record<string, TimelineItemType> = {
'id-11': {
type: 'message',
data: {
author: getDefaultConversation({ color: 'plum' }),
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@ -228,7 +228,7 @@ const items: Record<string, TimelineItemType> = {
'id-12': {
type: 'message',
data: {
author: getDefaultConversation({ color: 'crimson' }),
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@ -248,7 +248,7 @@ const items: Record<string, TimelineItemType> = {
'id-13': {
type: 'message',
data: {
author: getDefaultConversation({ color: 'blue' }),
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@ -269,7 +269,7 @@ const items: Record<string, TimelineItemType> = {
'id-14': {
type: 'message',
data: {
author: getDefaultConversation({ color: 'crimson' }),
author: getDefaultConversation({}),
canDeleteForEveryone: false,
canDownload: true,
canReply: true,
@ -418,7 +418,7 @@ const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
const renderTypingBubble = () => (
<TypingBubble
acceptedMessageRequest
color="crimson"
color={getRandomColor()}
conversationType="direct"
phoneNumber="+18005552222"
i18n={i18n}

View File

@ -12,6 +12,7 @@ import enMessages from '../../../_locales/en/messages.json';
import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem';
import { UniversalTimerNotification } from './UniversalTimerNotification';
import { CallMode } from '../../types/Calling';
import { AvatarColors } from '../../types/Colors';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
@ -97,7 +98,7 @@ storiesOf('Components/Conversation/TimelineItem', module)
timestamp: Date.now(),
author: {
phoneNumber: '(202) 555-2001',
color: 'forest',
color: AvatarColors[0],
},
text: '🔥',
},

View File

@ -8,6 +8,7 @@ import { Avatar, AvatarBlur } from '../Avatar';
import { Spinner } from '../Spinner';
import { LocalizerType } from '../../types/Util';
import { AvatarColors } from '../../types/Colors';
import { ContactType, getName } from '../../types/Contact';
// This file starts with _ to keep it from showing up in the StyleGuide.
@ -48,7 +49,7 @@ export function renderAvatar({
acceptedMessageRequest={false}
avatarPath={avatarPath}
blur={AvatarBlur.NoBlur}
color="steel"
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
isMe

View File

@ -75,6 +75,10 @@ const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
},
onBlock: action('onBlock'),
onLeave: action('onLeave'),
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
replaceAvatar: action('replaceAvatar'),
saveAvatarToDisk: action('saveAvatarToDisk'),
userAvatarData: [],
});
story.add('Basic', () => {

View File

@ -32,6 +32,12 @@ import { EditConversationAttributesModal } from './EditConversationAttributesMod
import { RequestState } from './util';
import { getCustomColorStyle } from '../../../util/getCustomColorStyle';
import { ConfirmationDialog } from '../../ConfirmationDialog';
import {
AvatarDataType,
DeleteAvatarFromDiskActionType,
ReplaceAvatarActionType,
SaveAvatarToDiskActionType,
} from '../../../types/Avatar';
enum ModalState {
NothingOpen,
@ -73,9 +79,16 @@ export type StateProps = {
) => Promise<void>;
onBlock: () => void;
onLeave: () => void;
userAvatarData: Array<AvatarDataType>;
};
export type Props = StateProps;
type ActionProps = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
};
export type Props = StateProps & ActionProps;
export const ConversationDetails: React.ComponentType<Props> = ({
addMembers,
@ -101,6 +114,10 @@ export const ConversationDetails: React.ComponentType<Props> = ({
updateGroupAttributes,
onBlock,
onLeave,
deleteAvatarFromDisk,
replaceAvatar,
saveAvatarToDisk,
userAvatarData,
}) => {
const [modalState, setModalState] = useState<ModalState>(
ModalState.NothingOpen
@ -141,7 +158,9 @@ export const ConversationDetails: React.ComponentType<Props> = ({
case ModalState.EditingGroupTitle:
modalNode = (
<EditConversationAttributesModal
avatarColor={conversation.color}
avatarPath={conversation.avatarPath}
conversationId={conversation.id}
groupDescription={conversation.groupDescription}
i18n={i18n}
initiallyFocusDescription={
@ -172,6 +191,10 @@ export const ConversationDetails: React.ComponentType<Props> = ({
}}
requestState={editGroupAttributesRequestState}
title={conversation.title}
deleteAvatarFromDisk={deleteAvatarFromDisk}
replaceAvatar={replaceAvatar}
saveAvatarToDisk={saveAvatarToDisk}
userAvatarData={userAvatarData}
/>
);
break;

View File

@ -1,14 +1,15 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactNode } from 'react';
import React, { ReactNode, useState } from 'react';
import { Avatar } from '../../Avatar';
import { Emojify } from '../Emojify';
import { LocalizerType } from '../../../types/Util';
import { AvatarLightbox } from '../../AvatarLightbox';
import { ConversationType } from '../../../state/ducks/conversations';
import { Emojify } from '../Emojify';
import { GroupDescription } from '../GroupDescription';
import { GroupV2Membership } from './ConversationDetailsMembershipList';
import { LocalizerType } from '../../../types/Util';
import { bemGenerator } from './util';
export type Props = {
@ -28,6 +29,8 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
memberships,
startEditing,
}) => {
const [showingAvatar, setShowingAvatar] = useState(false);
let subtitle: ReactNode;
if (conversation.groupDescription) {
subtitle = (
@ -45,26 +48,41 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
]);
}
const contents = (
<>
<Avatar
conversationType="group"
i18n={i18n}
size={80}
{...conversation}
sharedGroupNames={[]}
/>
<div>
<div className={bem('title')}>
<Emojify text={conversation.title} />
</div>
</div>
</>
const avatar = (
<Avatar
conversationType="group"
i18n={i18n}
size={80}
{...conversation}
onClick={() => setShowingAvatar(true)}
sharedGroupNames={[]}
/>
);
const contents = (
<div>
<div className={bem('title')}>
<Emojify text={conversation.title} />
</div>
</div>
);
const avatarLightbox = showingAvatar ? (
<AvatarLightbox
avatarColor={conversation.color}
avatarPath={conversation.avatarPath}
conversationTitle={conversation.title}
i18n={i18n}
isGroup
onClose={() => setShowingAvatar(false)}
/>
) : null;
if (canEdit) {
return (
<div className={bem('root')}>
{avatarLightbox}
{avatar}
<button
type="button"
onClick={ev => {
@ -95,5 +113,11 @@ export const ConversationDetailsHeader: React.ComponentType<Props> = ({
);
}
return <div className={bem('root')}>{contents}</div>;
return (
<div className={bem('root')}>
{avatarLightbox}
{avatar}
{contents}
</div>
);
};

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