Avatar defaults and colors
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
1
images/avatars/avatar_abstract_01.svg
Normal 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 |
1
images/avatars/avatar_abstract_02.svg
Normal file
After Width: | Height: | Size: 18 KiB |
1
images/avatars/avatar_abstract_03.svg
Normal file
After Width: | Height: | Size: 7.9 KiB |
1
images/avatars/avatar_balloon.svg
Normal 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 |
1
images/avatars/avatar_book.svg
Normal file
After Width: | Height: | Size: 26 KiB |
1
images/avatars/avatar_briefcase.svg
Normal file
After Width: | Height: | Size: 13 KiB |
1
images/avatars/avatar_cat.svg
Normal file
After Width: | Height: | Size: 5.3 KiB |
1
images/avatars/avatar_celebration.svg
Normal file
After Width: | Height: | Size: 9.1 KiB |
1
images/avatars/avatar_dinosour.svg
Normal 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 |
1
images/avatars/avatar_dog.svg
Normal file
After Width: | Height: | Size: 11 KiB |
1
images/avatars/avatar_drink.svg
Normal file
After Width: | Height: | Size: 14 KiB |
1
images/avatars/avatar_football.svg
Normal file
After Width: | Height: | Size: 10 KiB |
1
images/avatars/avatar_fox.svg
Normal file
After Width: | Height: | Size: 10 KiB |
1
images/avatars/avatar_ghost.svg
Normal file
After Width: | Height: | Size: 9.1 KiB |
1
images/avatars/avatar_heart.svg
Normal 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 |
1
images/avatars/avatar_house.svg
Normal file
After Width: | Height: | Size: 19 KiB |
1
images/avatars/avatar_incognito.svg
Normal file
After Width: | Height: | Size: 13 KiB |
1
images/avatars/avatar_melon.svg
Normal file
After Width: | Height: | Size: 7 KiB |
1
images/avatars/avatar_pig.svg
Normal file
After Width: | Height: | Size: 7.1 KiB |
1
images/avatars/avatar_sloth.svg
Normal file
After Width: | Height: | Size: 9.8 KiB |
1
images/avatars/avatar_soccerball.svg
Normal file
After Width: | Height: | Size: 16 KiB |
1
images/avatars/avatar_sunset.svg
Normal 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 |
1
images/avatars/avatar_surfboard.svg
Normal 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 |
1
images/avatars/avatar_tucan.svg
Normal file
After Width: | Height: | Size: 8.1 KiB |
1
images/icons/v2/photo-album-outline-24.svg
Normal 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 |
1
images/icons/v2/text-24.svg
Normal 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 |
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: (
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
95
stylesheets/components/AvatarEditor.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
12
stylesheets/components/AvatarModalButtons.scss
Normal 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;
|
||||
}
|
||||
}
|
92
stylesheets/components/AvatarPreview.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
25
stylesheets/components/AvatarTextEditor.scss
Normal 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;
|
||||
}
|
||||
}
|
88
stylesheets/components/BetterAvatarBubble.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}`
|
||||
)}
|
||||
/>
|
||||
|
|
32
ts/components/AvatarColorPicker.stories.tsx
Normal 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],
|
||||
})}
|
||||
/>
|
||||
));
|
40
ts/components/AvatarColorPicker.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
98
ts/components/AvatarEditor.stories.tsx
Normal 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',
|
||||
})}
|
||||
/>
|
||||
));
|
298
ts/components/AvatarEditor.tsx
Normal 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>;
|
||||
};
|
46
ts/components/AvatarIconEditor.stories.tsx
Normal 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],
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
));
|
81
ts/components/AvatarIconEditor.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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} />;
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
));
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
59
ts/components/AvatarLightbox.stories.tsx
Normal 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',
|
||||
})}
|
||||
/>
|
||||
));
|
64
ts/components/AvatarLightbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
32
ts/components/AvatarModalButtons.stories.tsx
Normal 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()} />);
|
54
ts/components/AvatarModalButtons.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
94
ts/components/AvatarPreview.stories.tsx
Normal 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 },
|
||||
})}
|
||||
/>
|
||||
));
|
184
ts/components/AvatarPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
49
ts/components/AvatarTextEditor.stories.tsx
Normal 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: '‱௸𒈙',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
));
|
197
ts/components/AvatarTextEditor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
23
ts/components/AvatarUploadButton.stories.tsx
Normal 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()} />);
|
86
ts/components/AvatarUploadButton.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
62
ts/components/BetterAvatar.stories.tsx
Normal 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],
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
));
|
117
ts/components/BetterAvatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
56
ts/components/BetterAvatarBubble.stories.tsx
Normal 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],
|
||||
})}
|
||||
/>
|
||||
));
|
60
ts/components/BetterAvatarBubble.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
23
ts/components/ConfirmDiscardDialog.stories.tsx
Normal 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()} />);
|
31
ts/components/ConfirmDiscardDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
));
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
));
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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: '🔥',
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|