Username Link QR Code

This commit is contained in:
Fedor Indutny 2023-07-20 05:14:08 +02:00 committed by GitHub
parent 68dfc46185
commit e1d2dbd8ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 2187 additions and 120 deletions

View file

@ -91,6 +91,8 @@ jobs:
- run: yarn prepare-beta-build
- run: yarn test-node
- run: yarn test-electron
env:
ARTIFACTS_DIR: artifacts/macos
timeout-minutes: 5
- run: yarn build
env:
@ -102,6 +104,12 @@ jobs:
NODE_ENV: production
- run: yarn test-eslint
- name: Upload artifacts on failure
if: failure()
uses: actions/upload-artifact@v3
with:
path: artifacts
linux:
needs: lint
runs-on: ubuntu-latest
@ -150,12 +158,19 @@ jobs:
- run: xvfb-run --auto-servernum yarn test-electron
timeout-minutes: 5
env:
ARTIFACTS_DIR: artifacts/linux
LANG: en_US
LANGUAGE: en_US
- run: xvfb-run --auto-servernum yarn test-release
env:
NODE_ENV: production
- name: Upload artifacts on failure
if: failure()
uses: actions/upload-artifact@v3
with:
path: artifacts
windows:
needs: lint
runs-on: windows-latest
@ -205,11 +220,19 @@ jobs:
DISABLE_INSPECT_FUSE: on
- run: yarn test-electron
env:
ARTIFACTS_DIR: artifacts/windows
timeout-minutes: 5
- run: yarn test-release
env:
SIGNAL_ENV: production
- name: Upload artifacts on failure
if: failure()
uses: actions/upload-artifact@v3
with:
path: artifacts
mock-tests:
needs: lint
runs-on: ubuntu-latest

View file

@ -49,6 +49,9 @@
OS: {
hasCustomTitleBar: () => false,
},
usernames: {
hash: x => x,
},
config: {},
};

View file

@ -415,6 +415,10 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## changedpi
License: MIT
## cirbuf
MIT License

View file

@ -5255,6 +5255,18 @@
"messageformat": "Username",
"description": "Default text for username field"
},
"icu:ProfileEditor__username-link": {
"messageformat": "QR code or link",
"description": "Label of a profile editor row that navigates to username link and qr code modal"
},
"icu:ProfileEditor__username-link__tooltip__title": {
"messageformat": "Share your username",
"description": "Title of tooltip displayed under 'QR code or link' button for getting username link"
},
"icu:ProfileEditor__username-link__tooltip__body": {
"messageformat": "Let others start a chat with you by sharing your unique QR code or link.",
"description": "Body of tooltip displayed under 'QR code or link' button for getting username link"
},
"icu:ProfileEditor--username--title": {
"messageformat": "Choose your username",
"description": "Title text for username modal"
@ -5313,6 +5325,10 @@
},
"icu:ProfileEditor--username--confirm-delete-body": {
"messageformat": "This will remove your username, allowing other users to claim it. Are you sure?",
"description": "(deleted 07/10/2023) Shown in dialog body if user has saved an empty string to delete their username"
},
"icu:ProfileEditor--username--confirm-delete-body-2": {
"messageformat": "This will remove your username and disable your QR code and link. “{username}” will be available for others to claim. Are you sure?",
"description": "Shown in dialog body if user has saved an empty string to delete their username"
},
"icu:ProfileEditor--username--confirm-delete-button": {
@ -6491,6 +6507,42 @@
"messageformat": "These digits help keep your username private so you can avoid unwanted messages. Share your username with only the people and groups youd like to chat with. If you change usernames youll get a new set of digits.",
"description": "Body of the popup with information about discriminator in username"
},
"icu:EditUsernameModalBody__change-confirmation": {
"messageformat": "Changing your username will reset your existing QR code and link. Are you sure?",
"description": "Body of the confirmation dialog displayed when user is about to change their username"
},
"icu:EditUsernameModalBody__change-confirmation__continue": {
"messageformat": "Continue",
"description": "Text of the primary button on username change confirmation modal"
},
"icu:UsernameLinkModalBody__save": {
"messageformat": "Save",
"description": "Name of the button for saving username link QR code to disk in the username link modal"
},
"icu:UsernameLinkModalBody__color": {
"messageformat": "Color",
"description": "Name of the button for changing the username link QR code color in the username link modal"
},
"icu:UsernameLinkModalBody__copy": {
"messageformat": "Copy to Clipboard",
"description": "ARIA label of the button for copying the username link to clipboard in the username link modal"
},
"icu:UsernameLinkModalBody__help": {
"messageformat": "Only share your QR code and link with people you trust. When shared others will be able to view your username and start a chat with you.",
"description": "Text of disclaimer at the bottom of the username link modal"
},
"icu:UsernameLinkModalBody__reset": {
"messageformat": "Reset",
"description": "Text of button at the bottom of the username link modal"
},
"icu:UsernameLinkModalBody__color__radio": {
"messageformat": "Username link color, {index, number} of {total, number}",
"description": "ARIA label of button for selecting username link color"
},
"icu:UsernameLinkModalBody__reset__confirm": {
"messageformat": "If you reset your QR code, your existing QR code and link will no longer work.",
"description": "Text of confirmation modal when resetting the username link"
},
"icu:UsernameOnboardingModalBody__title": {
"messageformat": "Set up your Signal username",
"description": "Title of username onboarding modal"
@ -6500,7 +6552,7 @@
"description": "Content of the first row of username onboarding modal"
},
"icu:UsernameOnboardingModalBody__row__link": {
"messageformat": "Each username has a unique link you can share with your friends to start a chat with you",
"messageformat": "Each username has a unique QR code and link you can share with friends to start a chat with you",
"description": "Content of the second row of username onboarding modal"
},
"icu:UsernameOnboardingModalBody__row__lock": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#fff" fill-rule="evenodd" d="M20.648 18.834a.945.945 0 0 1 0-1.31l2.976-3.06c1.761-1.81 1.761-4.744 0-6.554a4.424 4.424 0 0 0-6.377 0l-2.976 3.059a.885.885 0 0 1-1.276 0 .945.945 0 0 1 0-1.311l2.976-3.06a6.194 6.194 0 0 1 8.929 0c2.465 2.535 2.465 6.643 0 9.178l-2.976 3.058a.885.885 0 0 1-1.276 0ZM10.02 14.028a.945.945 0 0 1 0 1.311l-2.976 3.059c-1.761 1.81-1.761 4.744 0 6.555a4.424 4.424 0 0 0 6.378 0l2.976-3.06a.885.885 0 0 1 1.275 0 .945.945 0 0 1 0 1.312l-2.976 3.059a6.193 6.193 0 0 1-8.928 0c-2.466-2.534-2.466-6.643 0-9.177l2.976-3.059a.885.885 0 0 1 1.275 0Zm8.504.437a.945.945 0 0 0 0-1.31.885.885 0 0 0-1.276 0l-5.102 5.243a.945.945 0 0 0 0 1.311.885.885 0 0 0 1.276 0l5.102-5.244Z" clip-rule="evenodd"/><path fill="#647392" fill-rule="evenodd" d="M19.573 19.88a2.445 2.445 0 0 1 0-3.403l2.976-3.059c1.195-1.227 1.195-3.234 0-4.462a2.924 2.924 0 0 0-4.227 0l-2.976 3.059a2.385 2.385 0 0 1-3.426 0 2.445 2.445 0 0 1 0-3.403l2.976-3.06L15.971 6.6l-2.976 3.059a.945.945 0 0 0 0 1.31.885.885 0 0 0 1.276 0l2.976-3.058a4.424 4.424 0 0 1 6.377 0c1.761 1.81 1.761 4.745 0 6.554l-2.976 3.06a.945.945 0 0 0 0 1.31.885.885 0 0 0 1.276 0l2.976-3.059c2.465-2.534 2.465-6.642 0-9.176a6.194 6.194 0 0 0-8.929 0l-1.075-1.046a7.693 7.693 0 0 1 11.079 0c3.032 3.116 3.032 8.152 0 11.269l-2.976 3.058a2.385 2.385 0 0 1-3.426 0Zm-1.765 1.882.94-.914a2.445 2.445 0 0 1 0 3.403l-2.977 3.058a7.693 7.693 0 0 1-11.078 0c-3.033-3.116-3.033-8.152 0-11.268l2.976-3.059a2.385 2.385 0 0 1 3.425 0 2.445 2.445 0 0 1 0 3.403l-2.976 3.059c-1.194 1.228-1.194 3.235 0 4.463a2.924 2.924 0 0 0 4.228 0l2.976-3.06a2.385 2.385 0 0 1 3.425 0l-.939.915Zm1.79-9.654a2.445 2.445 0 0 1 0 3.403l-5.102 5.244a2.385 2.385 0 0 1-3.426 0 2.445 2.445 0 0 1 0-3.403l5.102-5.244a2.385 2.385 0 0 1 3.426 0Zm-9.579 3.231a.945.945 0 0 0 0-1.31.885.885 0 0 0-1.275 0l-2.976 3.058c-2.466 2.534-2.466 6.643 0 9.177a6.193 6.193 0 0 0 8.928 0l2.976-3.06a.945.945 0 0 0 0-1.31.885.885 0 0 0-1.275 0l-2.976 3.059a4.424 4.424 0 0 1-6.378 0c-1.761-1.81-1.761-4.745 0-6.555l2.976-3.059Zm8.504-2.185a.945.945 0 0 1 0 1.311L13.42 19.71a.885.885 0 0 1-1.276 0 .945.945 0 0 1 0-1.31l5.102-5.245a.885.885 0 0 1 1.276 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,13 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.25 6C5.25 5.58579 5.58579 5.25 6 5.25H7C7.41422 5.25 7.75 5.58579 7.75 6V7C7.75 7.41422 7.41422 7.75 7 7.75H6C5.58579 7.75 5.25 7.41422 5.25 7V6Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.11738 1.875H7.88262C8.27475 1.87498 8.62034 1.87497 8.90645 1.89834C9.21069 1.9232 9.52389 1.97879 9.82823 2.13386C10.2751 2.36156 10.6384 2.72489 10.8661 3.17177C11.0212 3.47612 11.0768 3.78931 11.1017 4.09355C11.125 4.37965 11.125 4.72521 11.125 5.11731V7.88264C11.125 8.27474 11.125 8.62035 11.1017 8.90645C11.0768 9.21069 11.0212 9.52389 10.8661 9.82823C10.6384 10.2751 10.2751 10.6384 9.82823 10.8661C9.52389 11.0212 9.21069 11.0768 8.90645 11.1017C8.62035 11.125 8.2748 11.125 7.8827 11.125H5.11736C4.72526 11.125 4.37965 11.125 4.09355 11.1017C3.78931 11.0768 3.47612 11.0212 3.17177 10.8661C2.72489 10.6384 2.36156 10.2751 2.13386 9.82823C1.97879 9.52389 1.9232 9.21069 1.89834 8.90645C1.87497 8.62034 1.87498 8.27475 1.875 7.88262V5.11738C1.87498 4.72525 1.87497 4.37967 1.89834 4.09355C1.9232 3.78931 1.97879 3.47612 2.13386 3.17177C2.36156 2.72489 2.72489 2.36156 3.17177 2.13386C3.47612 1.97879 3.78931 1.9232 4.09355 1.89834C4.37967 1.87497 4.72525 1.87498 5.11738 1.875ZM4.23606 3.64253C4.04004 3.65855 3.98278 3.6847 3.96626 3.69312C3.84866 3.75304 3.75304 3.84866 3.69312 3.96626C3.6847 3.98278 3.65855 4.04004 3.64253 4.23606C3.62568 4.44229 3.625 4.71552 3.625 5.15V7.85C3.625 8.28448 3.62568 8.55771 3.64253 8.76395C3.65855 8.95996 3.6847 9.01722 3.69312 9.03375C3.75304 9.15135 3.84866 9.24696 3.96626 9.30688C3.98278 9.3153 4.04004 9.34146 4.23606 9.35747C4.44229 9.37432 4.71552 9.375 5.15 9.375H7.85C8.28448 9.375 8.55771 9.37432 8.76395 9.35747C8.95996 9.34146 9.01722 9.3153 9.03375 9.30688C9.15135 9.24696 9.24696 9.15135 9.30688 9.03375C9.3153 9.01722 9.34146 8.95996 9.35747 8.76395C9.37432 8.55771 9.375 8.28448 9.375 7.85V5.15C9.375 4.71552 9.37432 4.44229 9.35747 4.23606C9.34146 4.04004 9.3153 3.98278 9.30688 3.96626C9.24696 3.84866 9.15135 3.75304 9.03375 3.69312C9.01722 3.6847 8.95996 3.65855 8.76395 3.64253C8.55771 3.62568 8.28448 3.625 7.85 3.625H5.15C4.71552 3.625 4.44229 3.62568 4.23606 3.64253Z" fill="black"/>
<path d="M6 16.25C5.58579 16.25 5.25 16.5858 5.25 17V18C5.25 18.4142 5.58579 18.75 6 18.75H7C7.41422 18.75 7.75 18.4142 7.75 18V17C7.75 16.5858 7.41422 16.25 7 16.25H6Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.11738 12.875H7.88262C8.27475 12.875 8.62034 12.875 8.90645 12.8983C9.21069 12.9232 9.52389 12.9788 9.82823 13.1339C10.2751 13.3616 10.6384 13.7249 10.8661 14.1718C11.0212 14.4761 11.0768 14.7893 11.1017 15.0936C11.125 15.3797 11.125 15.7252 11.125 16.1173V18.8826C11.125 19.2747 11.125 19.6204 11.1017 19.9065C11.0768 20.2107 11.0212 20.5239 10.8661 20.8282C10.6384 21.2751 10.2751 21.6384 9.82823 21.8661C9.52389 22.0212 9.21069 22.0768 8.90645 22.1017C8.62034 22.125 8.27476 22.125 7.88264 22.125H5.11736C4.72524 22.125 4.37966 22.125 4.09355 22.1017C3.78931 22.0768 3.47612 22.0212 3.17177 21.8661C2.72489 21.6384 2.36156 21.2751 2.13386 20.8282C1.97879 20.5239 1.9232 20.2107 1.89834 19.9065C1.87497 19.6203 1.87498 19.2748 1.875 18.8826V16.1174C1.87498 15.7253 1.87497 15.3797 1.89834 15.0936C1.9232 14.7893 1.97879 14.4761 2.13386 14.1718C2.36156 13.7249 2.72489 13.3616 3.17177 13.1339C3.47612 12.9788 3.78931 12.9232 4.09355 12.8983C4.37967 12.875 4.72525 12.875 5.11738 12.875ZM4.23606 14.6425C4.04004 14.6585 3.98278 14.6847 3.96626 14.6931C3.84866 14.753 3.75304 14.8487 3.69312 14.9663C3.6847 14.9828 3.65855 15.04 3.64253 15.2361C3.62568 15.4423 3.625 15.7155 3.625 16.15V18.85C3.625 19.2845 3.62568 19.5577 3.64253 19.7639C3.65855 19.96 3.6847 20.0172 3.69312 20.0337C3.75304 20.1513 3.84866 20.247 3.96626 20.3069C3.98278 20.3153 4.04004 20.3415 4.23606 20.3575C4.44229 20.3743 4.71552 20.375 5.15 20.375H7.85C8.28448 20.375 8.55771 20.3743 8.76395 20.3575C8.95996 20.3415 9.01722 20.3153 9.03375 20.3069C9.15135 20.247 9.24696 20.1513 9.30688 20.0337C9.3153 20.0172 9.34146 19.96 9.35747 19.7639C9.37432 19.5577 9.375 19.2845 9.375 18.85V16.15C9.375 15.7155 9.37432 15.4423 9.35747 15.2361C9.34146 15.04 9.3153 14.9828 9.30688 14.9663C9.24696 14.8487 9.15135 14.753 9.03375 14.6931C9.01722 14.6847 8.95996 14.6585 8.76395 14.6425C8.55771 14.6257 8.28448 14.625 7.85 14.625H5.15C4.71552 14.625 4.44229 14.6257 4.23606 14.6425Z" fill="black"/>
<path d="M17 5.25C16.5858 5.25 16.25 5.58579 16.25 6V7C16.25 7.41422 16.5858 7.75 17 7.75H18C18.4142 7.75 18.75 7.41422 18.75 7V6C18.75 5.58579 18.4142 5.25 18 5.25H17Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.1174 1.875C15.7253 1.87498 15.3797 1.87497 15.0936 1.89834C14.7893 1.9232 14.4761 1.97879 14.1718 2.13386C13.7249 2.36156 13.3616 2.72489 13.1339 3.17177C12.9788 3.47612 12.9232 3.78931 12.8983 4.09355C12.875 4.37967 12.875 4.72525 12.875 5.11738V7.88262C12.875 8.27475 12.875 8.62034 12.8983 8.90645C12.9232 9.21069 12.9788 9.52389 13.1339 9.82823C13.3616 10.2751 13.7249 10.6384 14.1718 10.8661C14.4761 11.0212 14.7893 11.0768 15.0936 11.1017C15.3797 11.125 15.7252 11.125 16.1173 11.125H18.8826C19.2747 11.125 19.6204 11.125 19.9065 11.1017C20.2107 11.0768 20.5239 11.0212 20.8282 10.8661C21.2751 10.6384 21.6384 10.2751 21.8661 9.82823C22.0212 9.52389 22.0768 9.21069 22.1017 8.90645C22.125 8.62034 22.125 8.27476 22.125 7.88264V5.11736C22.125 4.72524 22.125 4.37966 22.1017 4.09355C22.0768 3.78931 22.0212 3.47612 21.8661 3.17177C21.6384 2.72489 21.2751 2.36156 20.8282 2.13386C20.5239 1.97879 20.2107 1.9232 19.9065 1.89834C19.6203 1.87497 19.2748 1.87498 18.8826 1.875H16.1174ZM14.9663 3.69312C14.9828 3.6847 15.04 3.65855 15.2361 3.64253C15.4423 3.62568 15.7155 3.625 16.15 3.625H18.85C19.2845 3.625 19.5577 3.62568 19.7639 3.64253C19.96 3.65855 20.0172 3.6847 20.0337 3.69312C20.1513 3.75304 20.247 3.84866 20.3069 3.96626C20.3153 3.98278 20.3415 4.04004 20.3575 4.23606C20.3743 4.44229 20.375 4.71552 20.375 5.15V7.85C20.375 8.28448 20.3743 8.55771 20.3575 8.76395C20.3415 8.95996 20.3153 9.01722 20.3069 9.03375C20.247 9.15135 20.1513 9.24696 20.0337 9.30688C20.0172 9.3153 19.96 9.34146 19.7639 9.35747C19.5577 9.37432 19.2845 9.375 18.85 9.375H16.15C15.7155 9.375 15.4423 9.37432 15.2361 9.35747C15.04 9.34146 14.9828 9.3153 14.9663 9.30688C14.8487 9.24696 14.753 9.15135 14.6931 9.03375C14.6847 9.01722 14.6585 8.95996 14.6425 8.76395C14.6257 8.55771 14.625 8.28448 14.625 7.85V5.15C14.625 4.71552 14.6257 4.44229 14.6425 4.23606C14.6585 4.04004 14.6847 3.98278 14.6931 3.96626C14.753 3.84866 14.8487 3.75304 14.9663 3.69312Z" fill="black"/>
<path d="M14.25 13.5C13.8358 13.5 13.5 13.8358 13.5 14.25V15.25C13.5 15.6642 13.8358 16 14.25 16H15.25C15.6642 16 16 15.6642 16 15.25V14.25C16 13.8358 15.6642 13.5 15.25 13.5H14.25Z" fill="black"/>
<path d="M16.25 17C16.25 16.5858 16.5858 16.25 17 16.25H18C18.4142 16.25 18.75 16.5858 18.75 17V18C18.75 18.4142 18.4142 18.75 18 18.75H17C16.5858 18.75 16.25 18.4142 16.25 18V17Z" fill="black"/>
<path d="M19 19.75C19 19.3358 19.3358 19 19.75 19H20.75C21.1642 19 21.5 19.3358 21.5 19.75V20.75C21.5 21.1642 21.1642 21.5 20.75 21.5H19.75C19.3358 21.5 19 21.1642 19 20.75V19.75Z" fill="black"/>
<path d="M19.75 13.5C19.3358 13.5 19 13.8358 19 14.25V15.25C19 15.6642 19.3358 16 19.75 16H20.75C21.1642 16 21.5 15.6642 21.5 15.25V14.25C21.5 13.8358 21.1642 13.5 20.75 13.5H19.75Z" fill="black"/>
<path d="M13.5 19.75C13.5 19.3358 13.8358 19 14.25 19H15.25C15.6642 19 16 19.3358 16 19.75V20.75C16 21.1642 15.6642 21.5 15.25 21.5H14.25C13.8358 21.5 13.5 21.1642 13.5 20.75V19.75Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6187 0.631282C12.277 0.289573 11.723 0.289573 11.3813 0.631282L7.88128 4.13128C7.53957 4.47299 7.53957 5.02701 7.88128 5.36872C8.22299 5.71043 8.77701 5.71043 9.11872 5.36872L11.251 3.23647L11.125 5V15.25C11.125 15.7332 11.5168 16.125 12 16.125C12.4832 16.125 12.875 15.7332 12.875 15.25V5L12.749 3.23647L14.8813 5.36872C15.223 5.71043 15.777 5.71043 16.1187 5.36872C16.4604 5.02701 16.4604 4.47299 16.1187 4.13128L12.6187 0.631282Z" fill="black"/>
<path d="M8.38134 7.375C7.74898 7.37499 7.23291 7.37499 6.81111 7.40206C6.37573 7.43 5.98108 7.48932 5.60186 7.63602C4.58422 8.02972 3.77972 8.83422 3.38602 9.85186C3.23932 10.2311 3.18 10.6257 3.15206 11.0611C3.12499 11.4829 3.12499 11.999 3.125 12.6313V17.8687C3.12499 18.501 3.12499 19.0171 3.15206 19.4389C3.18 19.8743 3.23932 20.2689 3.38602 20.6481C3.77972 21.6658 4.58422 22.4703 5.60186 22.864C5.98108 23.0107 6.37573 23.07 6.81111 23.0979C7.23292 23.125 7.74899 23.125 8.38137 23.125H15.6186C16.251 23.125 16.7671 23.125 17.1889 23.0979C17.6243 23.07 18.0189 23.0107 18.3981 22.864C19.4158 22.4703 20.2203 21.6658 20.614 20.6481C20.7607 20.2689 20.82 19.8743 20.8479 19.4389C20.875 19.0171 20.875 18.5011 20.875 17.8688V12.6313C20.875 11.999 20.875 11.4829 20.8479 11.0611C20.82 10.6257 20.7607 10.2311 20.614 9.85186C20.2203 8.83422 19.4158 8.02972 18.3981 7.63602C18.0189 7.48932 17.6243 7.43 17.1889 7.40206C16.7671 7.37499 16.251 7.37499 15.6187 7.375H15.5C15.0168 7.375 14.625 7.76675 14.625 8.25C14.625 8.73325 15.0168 9.125 15.5 9.125H15.59C16.2579 9.125 16.7177 9.12542 17.0768 9.14846C17.4287 9.17105 17.6236 9.21277 17.7667 9.26814C18.3248 9.48404 18.766 9.92522 18.9819 10.4833C19.0372 10.6264 19.079 10.8213 19.1015 11.1732C19.1246 11.5323 19.125 11.9921 19.125 12.66V17.84C19.125 18.5079 19.1246 18.9677 19.1015 19.3268C19.079 19.6787 19.0372 19.8736 18.9819 20.0167C18.766 20.5748 18.3248 21.016 17.7667 21.2319C17.6236 21.2872 17.4287 21.329 17.0768 21.3515C16.7177 21.3746 16.2579 21.375 15.59 21.375H8.41C7.74207 21.375 7.2823 21.3746 6.92318 21.3515C6.57129 21.329 6.37641 21.2872 6.23328 21.2319C5.67522 21.016 5.23404 20.5748 5.01814 20.0167C4.96277 19.8736 4.92105 19.6787 4.89846 19.3268C4.87542 18.9677 4.875 18.5079 4.875 17.84V12.66C4.875 11.9921 4.87542 11.5323 4.89846 11.1732C4.92105 10.8213 4.96277 10.6264 5.01814 10.4833C5.23404 9.92522 5.67522 9.48404 6.23328 9.26814C6.37641 9.21277 6.57129 9.17105 6.92318 9.14846C7.2823 9.12542 7.74207 9.125 8.41 9.125H8.5C8.98325 9.125 9.375 8.73325 9.375 8.25C9.375 7.76675 8.98325 7.375 8.5 7.375H8.38134Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

23
images/qr-and-link.svg Normal file
View file

@ -0,0 +1,23 @@
<svg width="32" height="34" viewBox="0 0 32 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5918 26.1585C24.9159 25.4686 24.9159 24.359 25.5918 23.6691L27.8097 21.405C28.7087 20.4872 28.7087 18.9901 27.8097 18.0723C26.9229 17.167 25.4945 17.167 24.6077 18.0723L22.3898 20.3365C21.7017 21.0389 20.5766 21.0389 19.8885 20.3365C19.2127 19.6465 19.2127 18.537 19.8885 17.8471L22.1065 15.5829C24.3694 13.2728 28.0479 13.2728 30.3109 15.5829M24.7831 22.1648L25.5585 22.9243L21.7563 26.8056C21.0683 27.5081 19.9432 27.5081 19.2551 26.8056C18.5793 26.1157 18.5793 25.0062 19.2551 24.3163L23.0573 20.4349C23.7453 19.7325 24.8704 19.7325 25.5585 20.4349C26.2343 21.1248 26.2343 22.2343 25.5585 22.9243L24.7831 22.1648ZM24.7831 22.1648L20.981 26.0461C20.7185 26.3141 20.2929 26.3141 20.0305 26.0461C19.768 25.7782 19.768 25.3437 20.0305 25.0758L23.8326 21.1944C24.0951 20.9265 24.5207 20.9265 24.7831 21.1944C25.0456 21.4624 25.0456 21.8968 24.7831 22.1648ZM30.3109 15.5829C32.5617 17.8806 32.5617 21.5967 30.3109 23.8944L28.093 26.1585C27.4049 26.8609 26.2799 26.8609 25.5918 26.1585M19.2217 21.0818C19.8975 21.7718 19.8975 22.8813 19.2217 23.5712L17.0037 25.8354C16.1047 26.7531 16.1047 28.2502 17.0037 29.168C17.8906 30.0733 19.3189 30.0733 20.2057 29.168L22.4237 26.9039C23.1118 26.2015 24.2368 26.2015 24.9249 26.9039C25.6008 27.5938 25.6007 28.7033 24.9249 29.3933L22.707 31.6574C20.444 33.9675 16.7655 33.9675 14.5025 31.6574C12.2518 29.3598 12.2518 25.6436 14.5025 23.346L16.7204 21.0818C17.4085 20.3794 18.5336 20.3794 19.2217 21.0818ZM26.3671 24.4286C26.1046 24.6966 26.1046 25.131 26.3671 25.399C26.6296 25.6669 27.0552 25.6669 27.3177 25.399L29.5356 23.1349C31.373 21.2592 31.373 18.2181 29.5356 16.3425C27.6982 14.4668 24.7192 14.4668 22.8818 16.3425L20.6639 18.6066C20.4014 18.8745 20.4014 19.309 20.6639 19.5769C20.9264 19.8449 21.3519 19.8449 21.6144 19.5769L23.8323 17.3128C25.1448 15.973 27.2726 15.973 28.585 17.3128C29.8975 18.6526 29.8975 20.8247 28.585 22.1645L26.3671 24.4286ZM18.4463 22.8117C18.7088 22.5438 18.7088 22.1093 18.4463 21.8414C18.1838 21.5734 17.7583 21.5734 17.4958 21.8414L15.2779 24.1055C13.4405 25.9812 13.4405 29.0222 15.2779 30.8979C17.1153 32.7735 20.0942 32.7735 21.9316 30.8979L24.1496 28.6338C24.412 28.3658 24.412 27.9314 24.1496 27.6634C23.8871 27.3955 23.4615 27.3955 23.199 27.6634L20.9811 29.9275C19.6687 31.2673 17.5408 31.2673 16.2284 29.9275C14.916 28.5878 14.916 26.4156 16.2284 25.0758L18.4463 22.8117Z" fill="#647392"/>
<path d="M2.56503 5.82115C2.56503 4.6814 2.56503 4.11153 2.78684 3.6762C2.98195 3.29328 3.29328 2.98195 3.6762 2.78684C4.11153 2.56503 4.6814 2.56503 5.82115 2.56503H6.30448C7.44422 2.56503 8.0141 2.56503 8.44942 2.78684C8.83234 2.98195 9.14367 3.29328 9.33878 3.6762C9.56059 4.11153 9.56059 4.6814 9.56059 5.82115V6.30448C9.56059 7.44422 9.56059 8.0141 9.33878 8.44942C9.14367 8.83234 8.83234 9.14367 8.44942 9.33878C8.0141 9.56059 7.44422 9.56059 6.30448 9.56059H5.82115C4.6814 9.56059 4.11153 9.56059 3.6762 9.33878C3.29328 9.14367 2.98195 8.83234 2.78684 8.44942C2.56503 8.0141 2.56503 7.44422 2.56503 6.30448V5.82115Z" fill="#C2E0F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.79619 1.97147H6.32943C6.87811 1.97146 7.32581 1.97145 7.68944 2.00116C8.0655 2.03189 8.4036 2.09732 8.71889 2.25797C9.2135 2.50999 9.61563 2.91212 9.86764 3.40673C10.0283 3.72201 10.0937 4.06012 10.1245 4.43618C10.1542 4.79981 10.1542 5.2475 10.1541 5.79617V6.32944C10.1542 6.87811 10.1542 7.32581 10.1245 7.68944C10.0937 8.0655 10.0283 8.4036 9.86764 8.71889C9.61563 9.2135 9.2135 9.61563 8.71889 9.86764C8.4036 10.0283 8.0655 10.0937 7.68944 10.1245C7.32581 10.1542 6.87811 10.1542 6.32944 10.1541H5.79617C5.2475 10.1542 4.79981 10.1542 4.43618 10.1245C4.06012 10.0937 3.72201 10.0283 3.40673 9.86764C2.91212 9.61563 2.50999 9.2135 2.25797 8.71889C2.09732 8.4036 2.03189 8.0655 2.00116 7.68944C1.97145 7.32581 1.97146 6.87811 1.97147 6.32943V5.79619C1.97146 5.24751 1.97145 4.79981 2.00116 4.43618C2.03189 4.06012 2.09732 3.72201 2.25797 3.40673C2.50999 2.91212 2.91212 2.50999 3.40673 2.25797C3.72201 2.09732 4.06012 2.03189 4.43618 2.00116C4.79981 1.97145 5.24751 1.97146 5.79619 1.97147ZM4.53285 3.18435C4.2302 3.20907 4.06571 3.25454 3.94567 3.31571C3.67443 3.45391 3.45391 3.67443 3.31571 3.94567C3.25454 4.06571 3.20907 4.2302 3.18435 4.53285C3.15905 4.84242 3.15859 5.24147 3.15859 5.82114V6.30447C3.15859 6.88414 3.15905 7.28319 3.18435 7.59277C3.20907 7.89541 3.25454 8.0599 3.31571 8.17995C3.45391 8.45118 3.67443 8.67171 3.94567 8.80991C4.06571 8.87107 4.2302 8.91654 4.53285 8.94127C4.84242 8.96656 5.24147 8.96702 5.82114 8.96702H6.30447C6.88414 8.96702 7.28319 8.96656 7.59277 8.94127C7.89541 8.91654 8.0599 8.87107 8.17995 8.80991C8.45118 8.67171 8.67171 8.45118 8.80991 8.17995C8.87107 8.0599 8.91654 7.89541 8.94127 7.59277C8.96656 7.28319 8.96702 6.88414 8.96702 6.30447V5.82114C8.96702 5.24147 8.96656 4.84242 8.94127 4.53285C8.91654 4.2302 8.87107 4.06571 8.80991 3.94567C8.67171 3.67443 8.45118 3.45391 8.17995 3.31571C8.0599 3.25454 7.89541 3.20907 7.59277 3.18435C7.28319 3.15905 6.88414 3.15859 6.30447 3.15859H5.82114C5.24147 3.15859 4.84242 3.15905 4.53285 3.18435Z" fill="#628192"/>
<path d="M2.56503 16.0813C2.56503 14.9416 2.56503 14.3717 2.78684 13.9364C2.98195 13.5534 3.29328 13.2421 3.6762 13.047C4.11153 12.8252 4.6814 12.8252 5.82115 12.8252H6.30448C7.44422 12.8252 8.0141 12.8252 8.44942 13.047C8.83234 13.2421 9.14367 13.5534 9.33878 13.9364C9.56059 14.3717 9.56059 14.9416 9.56059 16.0813V16.5646C9.56059 17.7044 9.56059 18.2743 9.33878 18.7096C9.14367 19.0925 8.83234 19.4038 8.44942 19.5989C8.0141 19.8208 7.44422 19.8208 6.30448 19.8208H5.82115C4.6814 19.8208 4.11153 19.8208 3.6762 19.5989C3.29328 19.4038 2.98195 19.0925 2.78684 18.7096C2.56503 18.2743 2.56503 17.7044 2.56503 16.5646V16.0813Z" fill="#C2E0F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.79619 12.2316H6.32943C6.87811 12.2316 7.32581 12.2316 7.68944 12.2613C8.0655 12.2921 8.4036 12.3575 8.71889 12.5181C9.2135 12.7701 9.61563 13.1723 9.86764 13.6669C10.0283 13.9822 10.0937 14.3203 10.1245 14.6963C10.1542 15.06 10.1542 15.5077 10.1541 16.0563V16.5896C10.1542 17.1383 10.1542 17.586 10.1245 17.9496C10.0937 18.3257 10.0283 18.6638 9.86764 18.9791C9.61563 19.4737 9.2135 19.8758 8.71889 20.1278C8.4036 20.2885 8.0655 20.3539 7.68944 20.3846C7.32581 20.4143 6.87811 20.4143 6.32944 20.4143H5.79617C5.2475 20.4143 4.79981 20.4143 4.43618 20.3846C4.06012 20.3539 3.72201 20.2885 3.40673 20.1278C2.91212 19.8758 2.50999 19.4737 2.25797 18.9791C2.09732 18.6638 2.03189 18.3257 2.00116 17.9496C1.97145 17.586 1.97146 17.1383 1.97147 16.5896V16.0564C1.97146 15.5077 1.97145 15.06 2.00116 14.6963C2.03189 14.3203 2.09732 13.9822 2.25797 13.6669C2.50999 13.1723 2.91212 12.7701 3.40673 12.5181C3.72201 12.3575 4.06012 12.2921 4.43618 12.2613C4.79981 12.2316 5.24751 12.2316 5.79619 12.2316ZM4.53285 13.4445C4.2302 13.4692 4.06571 13.5147 3.94567 13.5759C3.67443 13.7141 3.45391 13.9346 3.31571 14.2058C3.25454 14.3259 3.20907 14.4904 3.18435 14.793C3.15905 15.1026 3.15859 15.5016 3.15859 16.0813V16.5646C3.15859 17.1443 3.15905 17.5434 3.18435 17.8529C3.20907 18.1556 3.25454 18.3201 3.31571 18.4401C3.45391 18.7113 3.67443 18.9319 3.94567 19.0701C4.06571 19.1312 4.2302 19.1767 4.53285 19.2014C4.84242 19.2267 5.24147 19.2272 5.82114 19.2272H6.30447C6.88414 19.2272 7.28319 19.2267 7.59277 19.2014C7.89541 19.1767 8.0599 19.1312 8.17995 19.0701C8.45118 18.9319 8.67171 18.7113 8.80991 18.4401C8.87107 18.3201 8.91654 18.1556 8.94127 17.8529C8.96656 17.5434 8.96702 17.1443 8.96702 16.5646V16.0813C8.96702 15.5016 8.96656 15.1026 8.94127 14.793C8.91654 14.4904 8.87107 14.3259 8.80991 14.2058C8.67171 13.9346 8.45118 13.7141 8.17995 13.5759C8.0599 13.5147 7.89541 13.4692 7.59277 13.4445C7.28319 13.4192 6.88414 13.4188 6.30447 13.4188H5.82114C5.24147 13.4188 4.84242 13.4192 4.53285 13.4445Z" fill="#628192"/>
<path d="M12.8252 5.82115C12.8252 4.6814 12.8252 4.11153 13.047 3.6762C13.2421 3.29328 13.5534 2.98195 13.9364 2.78684C14.3717 2.56503 14.9416 2.56503 16.0813 2.56503H16.5646C17.7044 2.56503 18.2743 2.56503 18.7096 2.78684C19.0925 2.98195 19.4038 3.29328 19.5989 3.6762C19.8208 4.11153 19.8208 4.6814 19.8208 5.82115V6.30448C19.8208 7.44422 19.8208 8.0141 19.5989 8.44942C19.4038 8.83234 19.0925 9.14367 18.7096 9.33878C18.2743 9.56059 17.7044 9.56059 16.5646 9.56059H16.0813C14.9416 9.56059 14.3717 9.56059 13.9364 9.33878C13.5534 9.14367 13.2421 8.83234 13.047 8.44942C12.8252 8.0141 12.8252 7.44422 12.8252 6.30448V5.82115Z" fill="#C2E0F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0564 1.97147H16.5896C17.1383 1.97146 17.586 1.97145 17.9496 2.00116C18.3257 2.03189 18.6638 2.09732 18.9791 2.25797C19.4737 2.50999 19.8758 2.91212 20.1278 3.40673C20.2885 3.72201 20.3539 4.06012 20.3846 4.43618C20.4143 4.79981 20.4143 5.2475 20.4143 5.79617V6.32944C20.4143 6.87811 20.4143 7.32581 20.3846 7.68944C20.3539 8.0655 20.2885 8.4036 20.1278 8.71889C19.8758 9.2135 19.4737 9.61563 18.9791 9.86764C18.6638 10.0283 18.3257 10.0937 17.9496 10.1245C17.586 10.1542 17.1383 10.1542 16.5896 10.1541H16.0563C15.5077 10.1542 15.06 10.1542 14.6963 10.1245C14.3203 10.0937 13.9822 10.0283 13.6669 9.86764C13.1723 9.61563 12.7701 9.2135 12.5181 8.71889C12.3575 8.4036 12.2921 8.0655 12.2613 7.68944C12.2316 7.32581 12.2316 6.87811 12.2316 6.32943V5.79619C12.2316 5.24751 12.2316 4.79981 12.2613 4.43618C12.2921 4.06012 12.3575 3.72201 12.5181 3.40673C12.7701 2.91212 13.1723 2.50999 13.6669 2.25797C13.9822 2.09732 14.3203 2.03189 14.6963 2.00116C15.06 1.97145 15.5077 1.97146 16.0564 1.97147ZM14.793 3.18435C14.4904 3.20907 14.3259 3.25454 14.2058 3.31571C13.9346 3.45391 13.7141 3.67443 13.5759 3.94567C13.5147 4.06571 13.4692 4.2302 13.4445 4.53285C13.4192 4.84242 13.4188 5.24147 13.4188 5.82114V6.30447C13.4188 6.88414 13.4192 7.28319 13.4445 7.59277C13.4692 7.89541 13.5147 8.0599 13.5759 8.17995C13.7141 8.45118 13.9346 8.67171 14.2058 8.80991C14.3259 8.87107 14.4904 8.91654 14.793 8.94127C15.1026 8.96656 15.5016 8.96702 16.0813 8.96702H16.5646C17.1443 8.96702 17.5434 8.96656 17.8529 8.94127C18.1556 8.91654 18.3201 8.87107 18.4401 8.80991C18.7113 8.67171 18.9319 8.45118 19.0701 8.17995C19.1312 8.0599 19.1767 7.89541 19.2014 7.59277C19.2267 7.28319 19.2272 6.88414 19.2272 6.30447V5.82114C19.2272 5.24147 19.2267 4.84242 19.2014 4.53285C19.1767 4.2302 19.1312 4.06571 19.0701 3.94567C18.9319 3.67443 18.7113 3.45391 18.4401 3.31571C18.3201 3.25454 18.1556 3.20907 17.8529 3.18435C17.5434 3.15905 17.1443 3.15859 16.5646 3.15859H16.0813C15.5016 3.15859 15.1026 3.15905 14.793 3.18435Z" fill="#628192"/>
<path d="M13.0584 13.3976C13.0584 13.2102 13.2102 13.0584 13.3976 13.0584H14.1183C14.3056 13.0584 14.4575 13.2102 14.4575 13.3976V14.1183C14.4575 14.3056 14.3056 14.4575 14.1183 14.4575H13.3976C13.2102 14.4575 13.0584 14.3056 13.0584 14.1183V13.3976Z" fill="#628192"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.7192 13.3976C12.7192 13.0229 13.0229 12.7192 13.3976 12.7192H14.1183C14.493 12.7192 14.7967 13.0229 14.7967 13.3976V14.1183C14.7967 14.493 14.493 14.7967 14.1183 14.7967H13.3976C13.0229 14.7967 12.7192 14.493 12.7192 14.1183V13.3976ZM14.1183 13.3976H13.3976V14.1183H14.1183V13.3976Z" fill="#628192"/>
<path d="M15.6234 15.9626C15.6234 15.7753 15.7753 15.6234 15.9626 15.6234H16.6833C16.8707 15.6234 17.0225 15.7753 17.0225 15.9626V16.6833C17.0225 16.8707 16.8707 17.0225 16.6833 17.0225H15.9626C15.7753 17.0225 15.6234 16.8707 15.6234 16.6833V15.9626Z" fill="#628192"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.2842 15.9626C15.2842 15.588 15.588 15.2842 15.9626 15.2842H16.6834C17.058 15.2842 17.3617 15.588 17.3617 15.9626V16.6834C17.3617 17.058 17.058 17.3617 16.6834 17.3617H15.9626C15.588 17.3617 15.2842 17.058 15.2842 16.6834V15.9626ZM16.6834 15.9626H15.9626V16.6834H16.6834V15.9626Z" fill="#628192"/>
<path d="M5.36325 15.9626C5.36325 15.7753 5.51511 15.6234 5.70243 15.6234H6.42318C6.61051 15.6234 6.76236 15.7753 6.76236 15.9626V16.6833C6.76236 16.8707 6.61051 17.0225 6.42318 17.0225H5.70243C5.51511 17.0225 5.36325 16.8707 5.36325 16.6833V15.9626Z" fill="#628192"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.68491 15.9626C4.68491 15.4006 5.14047 14.9451 5.70244 14.9451H6.4232C6.98517 14.9451 7.44073 15.4006 7.44073 15.9626V16.6834C7.44073 17.2453 6.98517 17.7009 6.4232 17.7009H5.70244C5.14047 17.7009 4.68491 17.2453 4.68491 16.6834V15.9626ZM6.04162 16.3018V16.3442H6.08402V16.3018H6.04162Z" fill="#628192"/>
<path d="M5.36325 5.70243C5.36325 5.51511 5.51511 5.36325 5.70243 5.36325H6.42318C6.61051 5.36325 6.76236 5.51511 6.76236 5.70243V6.42318C6.76236 6.61051 6.61051 6.76236 6.42318 6.76236H5.70243C5.51511 6.76236 5.36325 6.61051 5.36325 6.42318V5.70243Z" fill="#628192"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.68491 5.70244C4.68491 5.14047 5.14047 4.68491 5.70244 4.68491H6.4232C6.98517 4.68491 7.44073 5.14047 7.44073 5.70244V6.4232C7.44073 6.98517 6.98517 7.44073 6.4232 7.44073H5.70244C5.14047 7.44073 4.68491 6.98517 4.68491 6.4232V5.70244ZM6.04162 6.04162V6.08402H6.08402V6.04162H6.04162Z" fill="#628192"/>
<path d="M15.6234 5.70243C15.6234 5.51511 15.7753 5.36325 15.9626 5.36325H16.6833C16.8707 5.36325 17.0225 5.51511 17.0225 5.70243V6.42318C17.0225 6.61051 16.8707 6.76236 16.6833 6.76236H15.9626C15.7753 6.76236 15.6234 6.61051 15.6234 6.42318V5.70243Z" fill="#628192"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.9451 5.70244C14.9451 5.14047 15.4006 4.68491 15.9626 4.68491H16.6834C17.2453 4.68491 17.7009 5.14047 17.7009 5.70244V6.4232C17.7009 6.98517 17.2453 7.44073 16.6834 7.44073H15.9626C15.4006 7.44073 14.9451 6.98517 14.9451 6.4232V5.70244ZM16.3018 6.04162V6.08402H16.3442V6.04162H16.3018Z" fill="#628192"/>
<path d="M18.1884 13.3976C18.1884 13.2102 18.3403 13.0584 18.5276 13.0584H19.2484C19.4357 13.0584 19.5876 13.2102 19.5876 13.3976V14.1183C19.5876 14.3056 19.4357 14.4575 19.2484 14.4575H18.5276C18.3403 14.4575 18.1884 14.3056 18.1884 14.1183V13.3976Z" fill="#628192"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.8493 13.3976C17.8493 13.0229 18.153 12.7192 18.5276 12.7192H19.2484C19.623 12.7192 19.9267 13.0229 19.9267 13.3976V14.1183C19.9267 14.493 19.623 14.7967 19.2484 14.7967H18.5276C18.153 14.7967 17.8493 14.493 17.8493 14.1183V13.3976ZM19.2484 13.3976H18.5276V14.1183H19.2484V13.3976Z" fill="#628192"/>
<path d="M13.0584 18.5276C13.0584 18.3403 13.2102 18.1884 13.3976 18.1884H14.1183C14.3056 18.1884 14.4575 18.3403 14.4575 18.5276V19.2484C14.4575 19.4357 14.3056 19.5876 14.1183 19.5876H13.3976C13.2102 19.5876 13.0584 19.4357 13.0584 19.2484V18.5276Z" fill="#628192"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.7192 18.5276C12.7192 18.153 13.0229 17.8493 13.3976 17.8493H14.1183C14.493 17.8493 14.7967 18.153 14.7967 18.5276V19.2484C14.7967 19.623 14.493 19.9267 14.1183 19.9267H13.3976C13.0229 19.9267 12.7192 19.623 12.7192 19.2484V18.5276ZM14.1183 18.5276H13.3976V19.2484H14.1183V18.5276Z" fill="#628192"/>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="none"><path fill="#000" d="M16.904 32.723V35a17.034 17.034 0 0 1-5.594-1.334l.595-2.22a14.763 14.763 0 0 0 5 1.277ZM9.119 33.064l.667-2.49-5.707 1.338 1.18-5.034-2.382.209-1.22 5.204A1.7 1.7 0 0 0 3.7 34.334l5.419-1.27ZM3.28 19.159c.15 1.91.671 3.77 1.53 5.477l-2.41.21a17.037 17.037 0 0 1-1.397-5.688H3.28ZM3.277 16.885H1c.146-2.223.727-4.4 1.712-6.403l1.972 1.139a14.765 14.765 0 0 0-1.407 5.264ZM5.821 9.652 3.85 8.513a17.035 17.035 0 0 1 4.69-4.68l1.138 1.972a14.763 14.763 0 0 0-3.856 3.847ZM11.648 4.672l-1.139-1.973c2-.978 4.172-1.556 6.395-1.699v2.277a14.762 14.762 0 0 0-5.256 1.395ZM19.177 3.283c1.816.145 3.593.625 5.24 1.42l1.137-1.973a17.034 17.034 0 0 0-6.377-1.725v2.278ZM29.795 9.118c.14.186.276.376.407.568l1.971-1.139a17.035 17.035 0 0 0-4.654-4.675l-1.138 1.973a14.763 14.763 0 0 1 3.414 3.273ZM32.52 15.322c.096.518.163 1.04.203 1.563H35a17.048 17.048 0 0 0-1.694-6.367l-1.973 1.14c.552 1.16.952 2.391 1.187 3.664ZM32.188 22.09a14.759 14.759 0 0 1-.871 2.287l1.972 1.139a17.032 17.032 0 0 0 1.708-6.357H32.72a14.768 14.768 0 0 1-.532 2.93ZM28.867 27.995a14.757 14.757 0 0 1-2.504 2.173l1.139 1.973a17.028 17.028 0 0 0 4.65-4.657l-1.972-1.139c-.396.58-.835 1.13-1.313 1.65ZM23.259 31.797c-1.314.5-2.69.809-4.082.92v2.278a17.033 17.033 0 0 0 6.358-1.716l-1.139-1.972c-.371.179-.75.342-1.137.49Z"/><path fill="#000" d="M11.66 7.265a12.463 12.463 0 0 1 11.9-.423 12.466 12.466 0 0 1 6.42 14.612 12.47 12.47 0 0 1-13.21 8.954 12.462 12.462 0 0 1-5.411-1.857L6.246 29.75l1.199-5.115a12.47 12.47 0 0 1 4.216-17.37Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -89,7 +89,7 @@
"@popperjs/core": "2.11.6",
"@react-spring/web": "9.5.5",
"@signalapp/better-sqlite3": "8.4.3",
"@signalapp/libsignal-client": "0.27.0",
"@signalapp/libsignal-client": "0.28.0",
"@signalapp/ringrtc": "2.29.1",
"@types/fabric": "4.5.3",
"backbone": "1.4.0",
@ -97,6 +97,7 @@
"blueimp-load-image": "5.14.0",
"blurhash": "1.1.3",
"buffer": "6.0.3",
"changedpi": "1.0.4",
"cirbuf": "1.0.1",
"classnames": "2.2.5",
"config": "1.28.1",
@ -189,7 +190,7 @@
"@electron/fuses": "1.5.0",
"@formatjs/intl": "2.6.7",
"@mixer/parallel-prettier": "2.0.3",
"@signalapp/mock-server": "3.1.0",
"@signalapp/mock-server": "3.2.0",
"@storybook/addon-a11y": "6.5.6",
"@storybook/addon-actions": "6.5.6",
"@storybook/addon-controls": "6.5.6",

View file

@ -147,6 +147,24 @@ message AccountRecord {
}
}
message UsernameLink {
enum Color {
UNKNOWN = 0;
BLUE = 1;
WHITE = 2;
GREY = 3;
OLIVE = 4;
GREEN = 5;
ORANGE = 6;
PINK = 7;
PURPLE = 8;
}
optional bytes entropy = 1; // 32 bytes of entropy used for encryption
optional bytes serverId = 2; // 16 bytes of encoded UUID provided by the server
optional Color color = 3; // color of the QR code itself
}
optional bytes profileKey = 1;
optional string givenName = 2;
optional string familyName = 3;
@ -179,6 +197,7 @@ message AccountRecord {
reserved 32; // hasSeenGroupStoryEducationSheet
optional string username = 33;
optional bool hasCompletedUsernameOnboarding = 34;
optional UsernameLink usernameLink = 35;
}
message StoryDistributionListRecord {

View file

@ -58,6 +58,24 @@
}
}
&--username-link {
&::after {
@include light-theme {
@include color-svg(
'../images/icons/v3/qr_code/qr_code.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/qr_code/qr_code.svg',
$color-gray-15
);
}
}
}
&--bio {
&::after {
@include light-theme {
@ -159,4 +177,61 @@
}
}
}
&__username-link {
&__tooltip {
padding: 12px;
&__container {
display: flex;
flex-direction: row;
}
&__icon {
width: 24px;
height: 24px;
margin-block-start: 4px;
margin-inline: 4px 12px;
@include dark-theme {
@include color-svg(
'../images/icons/v3/share/share.svg',
$color-white
);
}
@include light-theme {
@include color-svg(
'../images/icons/v3/share/share.svg',
$color-black
);
}
}
&__content {
text-align: start;
h3 {
@include font-body-2-bold;
margin: 0;
}
p {
max-width: 240px;
margin: 0;
}
}
&__close {
@include button-reset;
@include button-focus-outline;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
@include color-svg('../images/icons/v3/x/x.svg', $color-gray-45);
}
}
}
}

View file

@ -0,0 +1,299 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.UsernameLinkModalBody {
display: flex;
flex-direction: column;
align-items: center;
user-select: none;
max-width: 295px;
width: 100%;
&__container {
display: flex;
align-items: center;
justify-content: center;
}
&__card {
--bg-color: #506ecd;
--fg-color: #2449c0;
--text-color: #ffffff;
padding-block: 22px;
padding-inline: 28px;
background: var(--bg-color);
border-radius: 18px;
max-width: 204px;
&--shadow {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
}
&__qr {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background-color: $color-white;
border-radius: 8px;
width: 148px;
height: 148px;
.UsernameLinkModalBody__card--shadow & {
outline: 2px solid $color-gray-05;
}
&__spinner__arc {
background-color: var(--fg-color);
}
&__blotches {
width: 100%;
}
&__logo {
--size: 25px;
position: absolute;
top: calc(50% - var(--size) / 2);
inset-inline-start: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
@include color-svg('../images/signal-qr-logo.svg', var(--fg-color));
}
}
&__username {
display: flex;
flex-direction: row;
gap: 4px;
justify-content: center;
margin-block: 12px 2px;
&__text {
color: var(--text-color);
font-size: 16px;
font-weight: 600;
line-height: normal;
letter-spacing: -0.252px;
text-align: center;
}
&__copy {
@include button-reset;
@include button-focus-outline;
flex-shrink: 0;
margin-top: 2px;
display: inline-block;
width: 16px;
height: 16px;
@include color-svg(
'../images/icons/v3/copy/copy.svg',
var(--text-color)
);
}
}
}
&__actions {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
justify-content: center;
margin-block-start: 16px;
&__save,
&__color {
@include button-reset;
@include button-focus-outline;
@include font-caption;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
min-width: 68px;
border-radius: 8px;
padding: 5px;
@include light-theme() {
background-color: $color-gray-05;
color: $color-black;
}
@include dark-theme() {
background-color: $color-gray-75;
color: $color-gray-02;
}
i {
display: block;
width: 20px;
height: 20px;
margin-block-start: 2px;
}
}
&__save i {
@include light-theme() {
@include color-svg('../images/icons/v3/save/save.svg', $color-black);
}
@include dark-theme() {
@include color-svg('../images/icons/v3/save/save.svg', $color-gray-02);
}
}
&__color i {
@include light-theme() {
@include color-svg('../images/icons/v3/color/color.svg', $color-black);
}
@include dark-theme() {
@include color-svg(
'../images/icons/v3/color/color.svg',
$color-gray-02
);
}
}
}
&__link {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
padding-block: 12px;
padding-inline: 16px;
border-radius: 12px;
margin-block-start: 20px;
max-width: 296px;
width: 100%;
@include light-theme() {
border: 2px solid $color-gray-05;
}
@include dark-theme() {
border: 2px solid $color-gray-75;
}
&__icon {
@include button-reset;
@include button-focus-outline;
border-radius: 2px;
&:after {
content: '';
display: block;
width: 20px;
height: 20px;
flex-shrink: 0;
@include light-theme() {
@include color-svg('../images/icons/v3/copy/copy.svg', $color-black);
}
@include dark-theme() {
@include color-svg(
'../images/icons/v3/copy/copy.svg',
$color-gray-02
);
}
}
}
&__text {
word-break: break-all;
user-select: text;
}
}
&__help {
@include font-subtitle;
margin-block-start: 16px;
text-align: center;
@include light-theme() {
color: $color-gray-60;
}
@include dark-theme() {
color: $color-gray-25;
}
}
&__reset {
@include button-reset;
@include button-focus-outline;
@include font-body-1-bold;
margin-block: 12px 16px;
@include light-theme() {
color: $color-ultramarine;
}
@include dark-theme() {
color: $color-ultramarine-light;
}
}
&__colors {
&__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 1fr;
gap: 18px 20px;
margin-block: 24px 30px;
}
&__radio {
@include button-reset;
@include button-focus-outline;
display: flex;
width: 48px;
height: 48px;
border-radius: 24px;
&[aria-pressed='true'] {
padding: 3px;
@include light-theme() {
border: 2px solid $color-black;
}
@include dark-theme() {
border: 2px solid $color-ultramarine;
}
}
i {
width: 100%;
height: 100%;
border-radius: 24px;
border: 2px solid var(--fg-color);
background: var(--bg-color);
}
&--white-bg {
i {
@include light-theme() {
border-color: $color-gray-15;
}
@include dark-theme() {
border-color: $color-gray-60;
}
}
}
}
}
}

View file

@ -64,7 +64,9 @@
}
&--link {
background: url(../images/icons/v2/link_color_32.svg);
width: 32px;
height: 34px;
background: url(../images/qr-and-link.svg);
}
&--lock {

View file

@ -151,5 +151,6 @@
@import './components/Toast.scss';
@import './components/Waveform.scss';
@import './components/WaveformScrubber.scss';
@import './components/UsernameLinkModalBody.scss';
@import './components/UsernameOnboardingModalBody.scss';
@import './components/WhatsNew.scss';

View file

@ -5,6 +5,7 @@
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
const { usernames } = require('@signalapp/libsignal-client');
const { Crypto } = require('../ts/context/Crypto');
const { setEnvironment, Environment } = require('../ts/environment');
@ -21,6 +22,7 @@ global.window = {
performance,
SignalContext: {
crypto: new Crypto(),
usernames,
log: {
info: (...args) => console.log(...args),
warn: (...args) => console.warn(...args),

7
ts/changedpi.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
declare module 'changedpi' {
function changeDpiBlob(blob: Blob, dpi: number): Promise<Blob>;
function changeDpiDataUrl(url: string, dpi: number): string;
}

View file

@ -73,6 +73,7 @@ export function EditUsernameModalBody({
const [hasEverChanged, setHasEverChanged] = useState(false);
const [nickname, setNickname] = useState(currentNickname);
const [isLearnMoreVisible, setIsLearnMoreVisible] = useState(false);
const [isConfirmingSave, setIsConfirmingSave] = useState(false);
useEffect(() => {
if (state === UsernameReservationState.Closed) {
@ -144,6 +145,18 @@ export function EditUsernameModalBody({
}, []);
const onSave = useCallback(() => {
if (!currentUsername) {
confirmUsername();
} else {
setIsConfirmingSave(true);
}
}, [confirmUsername, currentUsername]);
const onCancelSave = useCallback(() => {
setIsConfirmingSave(false);
}, []);
const onConfirmUsername = useCallback(() => {
confirmUsername();
}, [confirmUsername]);
@ -285,6 +298,26 @@ export function EditUsernameModalBody({
})}
</ConfirmationDialog>
)}
{isConfirmingSave && (
<ConfirmationDialog
dialogName="EditUsernameModalBody.confirmChange"
cancelText={i18n('icu:cancel')}
actions={[
{
action: onConfirmUsername,
style: 'negative',
text: i18n(
'icu:EditUsernameModalBody__change-confirmation__continue'
),
},
]}
i18n={i18n}
onClose={onCancelSave}
>
{i18n('icu:EditUsernameModalBody__change-confirmation')}
</ConfirmationDialog>
)}
</>
);
}

View file

@ -12,6 +12,7 @@ import { ProfileEditor } from './ProfileEditor';
import { EditUsernameModalBody } from './EditUsernameModalBody';
import {
UsernameEditState,
UsernameLinkState,
UsernameReservationState,
} from '../state/ducks/usernameEnums';
import { UUID } from '../types/UUID';
@ -49,6 +50,12 @@ export default {
i18n: {
defaultValue: i18n,
},
usernameLink: {
defaultValue: 'https://signal.me/#eu/testtest',
},
usernameLinkFgColor: {
defaultValue: '',
},
isUsernameFlagEnabled: {
control: { type: 'checkbox' },
defaultValue: false,
@ -62,16 +69,25 @@ export default {
Deleting: UsernameEditState.Deleting,
},
},
usernameLinkState: {
control: { type: 'select' },
defaultValue: UsernameLinkState.Ready,
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
},
onEditStateChanged: { action: true },
onProfileChanged: { action: true },
onSetSkinTone: { action: true },
saveAttachment: { action: true },
setUsernameLinkColor: { action: true },
showToast: { action: true },
recentEmojis: {
defaultValue: [],
},
replaceAvatar: { action: true },
resetUsernameLink: { action: true },
saveAvatarToDisk: { action: true },
markCompletedUsernameOnboarding: { action: true },
markCompletedUsernameLinkOnboarding: { action: true },
openUsernameReservationModal: { action: true },
setUsernameEditState: { action: true },
deleteUsername: { action: true },

View file

@ -25,8 +25,12 @@ import { Intl } from './Intl';
import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal';
import { PanelRow } from './conversation/conversation-details/PanelRow';
import type { ProfileDataType } from '../state/ducks/conversations';
import type {
ProfileDataType,
SaveAttachmentActionCreatorType,
} from '../state/ducks/conversations';
import { UsernameEditState } from '../state/ducks/usernameEnums';
import type { UsernameLinkState } from '../state/ducks/usernameEnums';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
import { getEmojiData, unifiedToEmoji } from './emoji/lib';
@ -34,14 +38,15 @@ import { assertDev } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu';
import { UsernameLinkModalBody } from './UsernameLinkModalBody';
import { UsernameOnboardingModalBody } from './UsernameOnboardingModalBody';
import {
ConversationDetailsIcon,
IconType,
} from './conversation/conversation-details/ConversationDetailsIcon';
import { isWhitespace, trim } from '../util/whitespaceStringUtil';
import { generateUsernameLink } from '../util/sgnlHref';
import { UserText } from './UserText';
import { Tooltip, TooltipPlacement } from './Tooltip';
export enum EditState {
None = 'None',
@ -50,6 +55,7 @@ export enum EditState {
Bio = 'Bio',
Username = 'Username',
UsernameOnboarding = 'UsernameOnboarding',
UsernameLink = 'UsernameLink',
}
type PropsExternalType = {
@ -70,20 +76,28 @@ export type PropsDataType = {
familyName?: string;
firstName: string;
hasCompletedUsernameOnboarding: boolean;
hasCompletedUsernameLinkOnboarding: boolean;
i18n: LocalizerType;
isUsernameFlagEnabled: boolean;
userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string;
usernameEditState: UsernameEditState;
markCompletedUsernameOnboarding: () => void;
usernameLinkState: UsernameLinkState;
usernameLinkColor?: number;
usernameLink?: string;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type PropsActionType = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
markCompletedUsernameOnboarding: () => void;
markCompletedUsernameLinkOnboarding: () => void;
onSetSkinTone: (tone: number) => unknown;
replaceAvatar: ReplaceAvatarActionType;
saveAttachment: SaveAttachmentActionCreatorType;
saveAvatarToDisk: SaveAvatarToDiskActionType;
setUsernameEditState: (editState: UsernameEditState) => void;
setUsernameLinkColor: (color: number) => void;
resetUsernameLink: () => void;
deleteUsername: () => void;
showToast: ShowToastAction;
openUsernameReservationModal: () => void;
@ -131,9 +145,11 @@ export function ProfileEditor({
familyName,
firstName,
hasCompletedUsernameOnboarding,
hasCompletedUsernameLinkOnboarding,
i18n,
isUsernameFlagEnabled,
markCompletedUsernameOnboarding,
markCompletedUsernameLinkOnboarding,
onEditStateChanged,
onProfileChanged,
onSetSkinTone,
@ -142,13 +158,19 @@ export function ProfileEditor({
recentEmojis,
renderEditUsernameModalBody,
replaceAvatar,
resetUsernameLink,
saveAttachment,
saveAvatarToDisk,
setUsernameEditState,
setUsernameLinkColor,
showToast,
skinTone,
userAvatarData,
username,
usernameEditState,
usernameLinkState,
usernameLinkColor,
usernameLink,
}: PropsType): JSX.Element {
const focusInputRef = useRef<HTMLInputElement | null>(null);
const [editState, setEditState] = useState<EditState>(EditState.None);
@ -499,8 +521,22 @@ export function ProfileEditor({
}}
/>
);
} else if (editState === EditState.UsernameLink) {
content = (
<UsernameLinkModalBody
i18n={i18n}
link={usernameLink}
username={username ?? ''}
colorId={usernameLinkColor}
usernameLinkState={usernameLinkState}
setUsernameLinkColor={setUsernameLinkColor}
resetUsernameLink={resetUsernameLink}
saveAttachment={saveAttachment}
showToast={showToast}
/>
);
} else if (editState === EditState.None) {
let maybeUsernameRow: JSX.Element | undefined;
let maybeUsernameRows: JSX.Element | undefined;
if (isUsernameFlagEnabled) {
let actions: JSX.Element | undefined;
@ -528,21 +564,6 @@ export function ProfileEditor({
showToast({ toastType: ToastType.CopiedUsername });
},
},
{
group: 'copy',
icon: 'ProfileEditor__username-menu__copy-link-icon',
label: i18n('icu:ProfileEditor--username--copy-link'),
onClick: () => {
assertDev(
username !== undefined,
'Should not be visible without username'
);
void window.navigator.clipboard.writeText(
generateUsernameLink(username)
);
showToast({ toastType: ToastType.CopiedUsernameLink });
},
},
{
// Different group to display a divider above it
group: 'delete',
@ -568,24 +589,74 @@ export function ProfileEditor({
}
}
maybeUsernameRow = (
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
}
label={username || i18n('icu:ProfileEditor--username')}
info={username && generateUsernameLink(username, { short: true })}
onClick={() => {
openUsernameReservationModal();
if (username || hasCompletedUsernameOnboarding) {
setEditState(EditState.Username);
} else {
setEditState(EditState.UsernameOnboarding);
let maybeUsernameLinkRow: JSX.Element | undefined;
if (username) {
maybeUsernameLinkRow = (
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username-link" />
}
}}
actions={actions}
/>
label={i18n('icu:ProfileEditor__username-link')}
onClick={() => {
setEditState(EditState.UsernameLink);
}}
/>
);
if (!hasCompletedUsernameLinkOnboarding) {
const tooltip = (
<div className="ProfileEditor__username-link__tooltip__container">
<div className="ProfileEditor__username-link__tooltip__icon" />
<div className="ProfileEditor__username-link__tooltip__content">
<h3>
{i18n('icu:ProfileEditor__username-link__tooltip__title')}
</h3>
<p>{i18n('icu:ProfileEditor__username-link__tooltip__body')}</p>
</div>
<button
type="button"
className="ProfileEditor__username-link__tooltip__close"
onClick={markCompletedUsernameLinkOnboarding}
aria-label={i18n('icu:close')}
/>
</div>
);
maybeUsernameLinkRow = (
<Tooltip
className="ProfileEditor__username-link__tooltip"
direction={TooltipPlacement.Bottom}
sticky
content={tooltip}
>
{maybeUsernameLinkRow}
</Tooltip>
);
}
}
maybeUsernameRows = (
<>
<PanelRow
className="ProfileEditor__row"
icon={
<i className="ProfileEditor__icon--container ProfileEditor__icon ProfileEditor__icon--username" />
}
label={username || i18n('icu:ProfileEditor--username')}
onClick={() => {
openUsernameReservationModal();
if (username || hasCompletedUsernameOnboarding) {
setEditState(EditState.Username);
} else {
setEditState(EditState.UsernameOnboarding);
}
}}
actions={actions}
/>
{maybeUsernameLinkRow}
</>
);
}
@ -618,7 +689,7 @@ export function ProfileEditor({
setEditState(EditState.ProfileName);
}}
/>
{maybeUsernameRow}
{maybeUsernameRows}
<PanelRow
className="ProfileEditor__row"
icon={
@ -680,7 +751,9 @@ export function ProfileEditor({
},
]}
>
{i18n('icu:ProfileEditor--username--confirm-delete-body')}
{i18n('icu:ProfileEditor--username--confirm-delete-body-2', {
username,
})}
</ConfirmationDialog>
)}

View file

@ -39,6 +39,7 @@ export function ProfileEditorModal({
[EditState.ProfileName]: i18n('icu:ProfileEditorModal--name'),
[EditState.UsernameOnboarding]: undefined,
[EditState.Username]: i18n('icu:ProfileEditorModal--username'),
[EditState.UsernameLink]: undefined,
};
const [modalTitle, setModalTitle] = useState(

View file

@ -0,0 +1,104 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import type { Meta, Story } from '@storybook/react';
import enMessages from '../../_locales/en/messages.json';
import { UsernameLinkState } from '../state/ducks/usernameEnums';
import { setupI18n } from '../util/setupI18n';
import { SignalService as Proto } from '../protobuf';
import type { PropsType } from './UsernameLinkModalBody';
import { UsernameLinkModalBody } from './UsernameLinkModalBody';
import { Modal } from './Modal';
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
const i18n = setupI18n('en', enMessages);
export default {
component: UsernameLinkModalBody,
title: 'Components/UsernameLinkModalBody',
argTypes: {
i18n: {
defaultValue: i18n,
},
link: {
control: { type: 'text' },
defaultValue:
'https://signal.me#eu/n-AJkmmykrFB7j6UODGndSycxcMdp_v6ppRp9rFu5Ad39q_9Ngi_k9-TARWfT43t',
},
username: {
control: { type: 'text' },
defaultValue: 'alice.12',
},
usernameLinkState: {
control: { type: 'select' },
defaultValue: UsernameLinkState.Ready,
options: [UsernameLinkState.Ready, UsernameLinkState.Updating],
},
colorId: {
control: { type: 'select' },
defaultValue: ColorEnum.BLUE,
mapping: {
blue: ColorEnum.BLUE,
white: ColorEnum.WHITE,
grey: ColorEnum.GREY,
olive: ColorEnum.OLIVE,
green: ColorEnum.GREEN,
orange: ColorEnum.ORANGE,
pink: ColorEnum.PINK,
purple: ColorEnum.PURPLE,
},
},
showToast: { action: true },
resetUsernameLink: { action: true },
setUsernameLinkColor: { action: true },
},
} as Meta;
type ArgsType = PropsType;
// eslint-disable-next-line react/function-component-definition
const Template: Story<ArgsType> = args => {
const [attachment, setAttachment] = useState<string | undefined>();
const saveAttachment = useCallback(({ data }: { data?: Uint8Array }) => {
if (!data) {
setAttachment(undefined);
return;
}
const blob = new Blob([data], {
type: 'image/png',
});
setAttachment(oldURL => {
if (oldURL) {
URL.revokeObjectURL(oldURL);
}
return URL.createObjectURL(blob);
});
}, []);
return (
<>
<Modal modalName="story" i18n={i18n} hasXButton>
<UsernameLinkModalBody {...args} saveAttachment={saveAttachment} />
</Modal>
{attachment && <img src={attachment} alt="printable qr code" />}
</>
);
};
export const Normal = Template.bind({});
Normal.args = {};
Normal.story = {
name: 'normal',
};
export const NoLink = Template.bind({});
NoLink.args = { link: '' };
NoLink.story = {
name: 'normal',
};

View file

@ -0,0 +1,739 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState, useEffect } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import classnames from 'classnames';
import QR from 'qrcode-generator';
import { changeDpiBlob } from 'changedpi';
import { SignalService as Proto } from '../protobuf';
import type { SaveAttachmentActionCreatorType } from '../state/ducks/conversations';
import { UsernameLinkState } from '../state/ducks/usernameEnums';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
import type { LocalizerType } from '../types/Util';
import { IMAGE_PNG } from '../types/MIME';
import { strictAssert } from '../util/assert';
import { drop } from '../util/drop';
import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { ConfirmationDialog } from './ConfirmationDialog';
import { Spinner } from './Spinner';
export type PropsType = Readonly<{
i18n: LocalizerType;
link?: string;
username: string;
colorId?: number;
usernameLinkState: UsernameLinkState;
setUsernameLinkColor: (colorId: number) => void;
resetUsernameLink: () => void;
saveAttachment: SaveAttachmentActionCreatorType;
showToast: ShowToastAction;
}>;
export type ColorMapEntryType = Readonly<{
fg: string;
bg: string;
}>;
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
const DEFAULT_PRESET: ColorMapEntryType = { fg: '#2449c0', bg: '#506ecd' };
export const COLOR_MAP: ReadonlyMap<number, ColorMapEntryType> = new Map([
[ColorEnum.BLUE, DEFAULT_PRESET],
[ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff' }],
[ColorEnum.GREY, { fg: '#464852', bg: '#6a6c74' }],
[ColorEnum.OLIVE, { fg: '#73694f', bg: '#a89d7f' }],
[ColorEnum.GREEN, { fg: '#55733f', bg: '#829a6e' }],
[ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#de7134' }],
[ColorEnum.PINK, { fg: '#bb617b', bg: '#e67899' }],
[ColorEnum.PURPLE, { fg: '#7651c5', bg: '#9c84cf' }],
]);
const CLASS = 'UsernameLinkModalBody';
const AUTODETECT_TYPE_NUMBER = 0;
const ERROR_CORRECTION_LEVEL = 'H';
const CENTER_CUTAWAY_PERCENTAGE = 32 / 184;
const PRINT_WIDTH = 296;
const DEFAULT_PRINT_HEIGHT = 324;
const PRINT_SHADOW_BLUR = 4;
const PRINT_CARD_RADIUS = 24;
const PRINT_MAX_USERNAME_WIDTH = 222;
const PRINT_USERNAME_LINE_HEIGHT = 25;
const PRINT_USERNAME_Y = 269;
const PRINT_QR_SIZE = 184;
const PRINT_QR_Y = 48;
const PRINT_QR_PADDING = 16;
const PRINT_QR_PADDING_RADIUS = 12;
const PRINT_DPI = 224;
const PRINT_LOGO_SIZE = 36;
type BlotchesPropsType = Readonly<{
className?: string;
link: string;
color: string;
}>;
function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element {
const qr = QR(AUTODETECT_TYPE_NUMBER, ERROR_CORRECTION_LEVEL);
qr.addData(link);
qr.make();
const size = qr.getModuleCount();
const center = size / 2;
const radius = CENTER_CUTAWAY_PERCENTAGE * size;
function hasPixel(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= size || y >= size) {
return false;
}
const distanceFromCenter = Math.sqrt(
(x - center + 0.5) ** 2 + (y - center + 0.5) ** 2
);
// Center and 1 dot away should remain clear for the logo placement.
if (Math.ceil(distanceFromCenter) <= radius + 2) {
return false;
}
return qr.isDark(x, y);
}
const path = [];
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
if (!hasPixel(x, y)) {
continue;
}
const onTop = hasPixel(x, y - 1);
const onBottom = hasPixel(x, y + 1);
const onLeft = hasPixel(x - 1, y);
const onRight = hasPixel(x + 1, y);
const roundTL = !onLeft && !onTop;
const roundTR = !onTop && !onRight;
const roundBR = !onRight && !onBottom;
const roundBL = !onBottom && !onLeft;
path.push(
`M${2 * x} ${2 * y + 1}`,
roundTL ? 'a1 1 0 0 1 1 -1' : 'v-1h1',
roundTR ? 'a1 1 0 0 1 1 1' : 'h1v1',
roundBR ? 'a1 1 0 0 1 -1 1' : 'v1h-1',
roundBL ? 'a1 1 0 0 1 -1 -1' : 'h-1v-1',
'z'
);
}
}
return (
<svg
className={className}
viewBox={`0 0 ${2 * size} ${2 * size}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx={size}
cy={size}
r={radius * 2}
stroke={color}
strokeWidth={2}
/>
<path d={path.join('')} fill={color} />
</svg>
);
}
type CreateCanvasAndContextOptionsType = Readonly<{
width: number;
height: number;
devicePixelRatio?: number;
}>;
function createCanvasAndContext({
width,
height,
devicePixelRatio = window.devicePixelRatio,
}: CreateCanvasAndContextOptionsType): [
OffscreenCanvas,
OffscreenCanvasRenderingContext2D
] {
const canvas = new OffscreenCanvas(
devicePixelRatio * width,
devicePixelRatio * height
);
const context = canvas.getContext('2d');
strictAssert(context, 'Failed to get 2d context');
// Retina support
context.scale(devicePixelRatio, devicePixelRatio);
// Font config
context.font = `600 20px/${PRINT_USERNAME_LINE_HEIGHT}px Inter`;
context.textAlign = 'center';
context.textBaseline = 'top';
// Experimental Chrome APIs
(
context as unknown as {
letterSpacing: number;
}
).letterSpacing = -0.34;
(
context as unknown as {
textRendering: string;
}
).textRendering = 'optimizeLegibility';
context.imageSmoothingEnabled = false;
return [canvas, context];
}
type GetLogoCanvasOptionsType = Readonly<{
fgColor: string;
imageUrl?: string;
devicePixelRatio?: number;
}>;
async function getLogoCanvas({
fgColor,
imageUrl = 'images/signal-qr-logo.svg',
devicePixelRatio,
}: GetLogoCanvasOptionsType): Promise<OffscreenCanvas> {
const img = new Image();
await new Promise((resolve, reject) => {
img.addEventListener('load', resolve);
img.addEventListener('error', () =>
reject(new Error('Failed to load image'))
);
img.src = imageUrl;
});
const [canvas, context] = createCanvasAndContext({
width: PRINT_LOGO_SIZE,
height: PRINT_LOGO_SIZE,
devicePixelRatio,
});
context.fillStyle = fgColor;
context.fillRect(0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE);
context.globalCompositeOperation = 'destination-in';
context.drawImage(img, 0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE);
return canvas;
}
function splitUsername(username: string): Array<string> {
const result = new Array<string>();
const [, context] = createCanvasAndContext({ width: 1, height: 1 });
// Compute number of lines and height of username
for (let i = 0, last = 0; i < username.length; i += 1) {
const part = username.slice(last, i);
if (context.measureText(part).width > PRINT_MAX_USERNAME_WIDTH) {
result.push(username.slice(last, i - 1));
last = i - 1;
} else if (i === username.length - 1) {
result.push(username.slice(last));
}
}
return result;
}
type GenerateImageURLOptionsType = Readonly<{
link: string;
username: string;
colorId: number;
bgColor: string;
fgColor: string;
// For testing
logoUrl?: string;
devicePixelRatio?: number;
}>;
// Exported for testing
export async function _generateImageBlob({
link,
username,
colorId,
bgColor,
fgColor,
logoUrl,
devicePixelRatio,
}: GenerateImageURLOptionsType): Promise<Blob> {
const usernameLines = splitUsername(username);
const usernameHeight = PRINT_USERNAME_LINE_HEIGHT * usernameLines.length;
const isWhiteBackground = colorId === ColorEnum.WHITE;
const padding = isWhiteBackground ? PRINT_SHADOW_BLUR : 0;
const totalHeight =
DEFAULT_PRINT_HEIGHT - PRINT_USERNAME_LINE_HEIGHT + usernameHeight;
const [canvas, context] = createCanvasAndContext({
width: PRINT_WIDTH + 2 * padding,
height: totalHeight + 2 * padding,
devicePixelRatio,
});
// Draw card
context.save();
if (isWhiteBackground) {
context.shadowColor = 'rgba(0, 0, 0, 0.08)';
context.shadowBlur = PRINT_SHADOW_BLUR;
}
context.fillStyle = bgColor;
context.beginPath();
context.roundRect(
padding,
padding,
PRINT_WIDTH,
totalHeight,
PRINT_CARD_RADIUS
);
context.fill();
context.restore();
// Draw padding around QR code
context.save();
context.fillStyle = '#fff';
const sizeWithPadding = PRINT_QR_SIZE + 2 * PRINT_QR_PADDING;
context.beginPath();
context.roundRect(
padding + (PRINT_WIDTH - sizeWithPadding) / 2,
padding + PRINT_QR_Y - PRINT_QR_PADDING,
sizeWithPadding,
sizeWithPadding,
PRINT_QR_PADDING_RADIUS
);
context.fill();
if (isWhiteBackground) {
context.lineWidth = 2;
context.strokeStyle = '#e9e9e9';
context.stroke();
}
context.restore();
// Draw username
context.fillStyle = isWhiteBackground ? '#000' : '#fff';
for (const [i, line] of usernameLines.entries()) {
context.fillText(
line,
padding + PRINT_WIDTH / 2,
PRINT_USERNAME_Y + i * PRINT_USERNAME_LINE_HEIGHT
);
}
// Draw logo
context.drawImage(
await getLogoCanvas({ fgColor, imageUrl: logoUrl, devicePixelRatio }),
padding + (PRINT_WIDTH - PRINT_LOGO_SIZE) / 2,
padding + PRINT_QR_Y + (PRINT_QR_SIZE - PRINT_LOGO_SIZE) / 2,
PRINT_LOGO_SIZE,
PRINT_LOGO_SIZE
);
// Draw QR code
const svg = renderToStaticMarkup(Blotches({ link, color: fgColor }));
const svgURL = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
const img = new Image();
await new Promise((resolve, reject) => {
img.addEventListener('load', resolve);
img.addEventListener('error', () =>
reject(new Error('Failed to load image'))
);
img.src = svgURL;
});
context.drawImage(
img,
padding + (PRINT_WIDTH - PRINT_QR_SIZE) / 2,
PRINT_QR_Y + padding,
PRINT_QR_SIZE,
PRINT_QR_SIZE
);
const blob = await canvas.convertToBlob({ type: 'image/png' });
return changeDpiBlob(blob, PRINT_DPI);
}
type UsernameLinkColorRadioPropsType = Readonly<{
i18n: LocalizerType;
index: number;
colorId: number;
fgColor: string;
bgColor: string;
isSelected: boolean;
onSelect: (colorId: number) => void;
}>;
function UsernameLinkColorRadio({
i18n,
index,
colorId,
fgColor,
bgColor,
isSelected,
onSelect,
}: UsernameLinkColorRadioPropsType): JSX.Element {
const className = `${CLASS}__colors__radio`;
const onClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
onSelect(colorId);
},
[colorId, onSelect]
);
const onRef = useCallback(
(elem: HTMLButtonElement | null): void => {
if (elem) {
// Note that these cannot be set through html attributes
elem.style.setProperty('--bg-color', bgColor);
elem.style.setProperty('--fg-color', fgColor);
}
},
[fgColor, bgColor]
);
const isWhiteBackground = colorId === ColorEnum.WHITE;
return (
<button
ref={onRef}
className={classnames(className, {
[`${className}--white-bg`]: isWhiteBackground,
})}
type="button"
aria-label={i18n('icu:UsernameLinkModalBody__color__radio', {
index: index + 1,
total: COLOR_MAP.size,
})}
aria-pressed={isSelected}
onClick={onClick}
>
<i />
</button>
);
}
type UsernameLinkColorsPropsType = Readonly<{
i18n: LocalizerType;
value: number;
onChange: (colorId: number) => void;
onSave: () => void;
onCancel: () => void;
}>;
function UsernameLinkColors({
i18n,
value,
onChange,
onSave,
onCancel,
}: UsernameLinkColorsPropsType): JSX.Element {
const className = `${CLASS}__colors`;
const normalizedValue = value === ColorEnum.UNKNOWN ? ColorEnum.BLUE : value;
return (
<div className={className}>
<div className={`${className}__grid`}>
{[...COLOR_MAP.entries()].map(([colorId, { fg, bg }], index) => {
return (
<UsernameLinkColorRadio
key={colorId}
i18n={i18n}
colorId={colorId}
fgColor={fg}
bgColor={bg}
index={index}
isSelected={colorId === normalizedValue}
onSelect={onChange}
/>
);
})}
</div>
<Modal.ButtonFooter>
<Button variant={ButtonVariant.Secondary} onClick={onCancel}>
{i18n('icu:cancel')}
</Button>
<Button variant={ButtonVariant.Primary} onClick={onSave}>
{i18n('icu:save')}
</Button>
</Modal.ButtonFooter>
</div>
);
}
export function UsernameLinkModalBody({
i18n,
link,
username,
usernameLinkState,
colorId: initialColorId = ColorEnum.UNKNOWN,
setUsernameLinkColor,
resetUsernameLink,
saveAttachment,
showToast,
}: PropsType): JSX.Element {
const [pngData, setPngData] = useState<Uint8Array | undefined>();
const [showColors, setShowColors] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [colorId, setColorId] = useState(initialColorId);
const { fg: fgColor, bg: bgColor } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET;
const isWhiteBackground = colorId === ColorEnum.WHITE;
const onCardRef = useCallback(
(elem: HTMLDivElement | null): void => {
if (elem) {
// Note that these cannot be set through html attributes
elem.style.setProperty('--bg-color', bgColor);
elem.style.setProperty('--fg-color', fgColor);
elem.style.setProperty(
'--text-color',
isWhiteBackground ? '#000' : '#fff'
);
}
},
[bgColor, fgColor, isWhiteBackground]
);
useEffect(() => {
let isAborted = false;
async function run() {
if (!link) {
return;
}
const blob = await _generateImageBlob({
link,
username,
colorId,
bgColor,
fgColor,
});
const arrayBuffer = await blob.arrayBuffer();
if (isAborted) {
return;
}
setPngData(new Uint8Array(arrayBuffer));
}
drop(run());
return () => {
isAborted = true;
};
}, [link, username, colorId, bgColor, fgColor]);
const onSave = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
if (!pngData) {
return;
}
saveAttachment({
data: pngData,
fileName: 'signal-username-qr-code.png',
contentType: IMAGE_PNG,
size: pngData.length,
});
},
[saveAttachment, pngData]
);
const onStartColorChange = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setShowColors(true);
}, []);
const onCopyLink = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
if (link) {
drop(window.navigator.clipboard.writeText(link));
showToast({ toastType: ToastType.CopiedUsernameLink });
}
},
[link, showToast]
);
const onCopyUsername = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
drop(window.navigator.clipboard.writeText(username));
showToast({ toastType: ToastType.CopiedUsername });
},
[username, showToast]
);
// Color change sub modal
const onUsernameLinkColorChange = useCallback((newColor: number) => {
setColorId(newColor);
}, []);
const onUsernameLinkColorSave = useCallback(() => {
setUsernameLinkColor(colorId);
setShowColors(false);
}, [setUsernameLinkColor, colorId]);
const onUsernameLinkColorCancel = useCallback(() => {
setShowColors(false);
setColorId(initialColorId);
}, [initialColorId]);
// Reset sub modal
const onClickReset = useCallback(() => {
setConfirmReset(true);
}, []);
const onCancelReset = useCallback(() => {
setConfirmReset(false);
}, []);
const onConfirmReset = useCallback(() => {
setConfirmReset(false);
resetUsernameLink();
}, [resetUsernameLink]);
const info = (
<>
<div className={classnames(`${CLASS}__actions`)}>
<button
className={`${CLASS}__actions__save`}
type="button"
disabled={!link}
onClick={onSave}
>
<i />
{i18n('icu:UsernameLinkModalBody__save')}
</button>
<button
className={`${CLASS}__actions__color`}
type="button"
onClick={onStartColorChange}
>
<i />
{i18n('icu:UsernameLinkModalBody__color')}
</button>
</div>
<div className={classnames(`${CLASS}__link`)}>
<button
className={classnames(`${CLASS}__link__icon`)}
type="button"
disabled={!link}
onClick={onCopyLink}
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
/>
<div className={classnames(`${CLASS}__link__text`)}>{link}</div>
</div>
<div className={classnames(`${CLASS}__help`)}>
{i18n('icu:UsernameLinkModalBody__help')}
</div>
<button
className={classnames(`${CLASS}__reset`)}
type="button"
onClick={onClickReset}
>
{i18n('icu:UsernameLinkModalBody__reset')}
</button>
</>
);
return (
<div className={`${CLASS}__container`}>
<div className={CLASS}>
<div
className={classnames(`${CLASS}__card`, {
[`${CLASS}__card--shadow`]: isWhiteBackground,
})}
ref={onCardRef}
>
<div className={`${CLASS}__card__qr`}>
{usernameLinkState === UsernameLinkState.Ready && link ? (
<>
<Blotches
className={`${CLASS}__card__qr__blotches`}
link={link}
color={fgColor}
/>
<div className={`${CLASS}__card__qr__logo`} />
</>
) : (
<Spinner
moduleClassName={`${CLASS}__card__qr__spinner`}
svgSize="small"
/>
)}
</div>
<div className={`${CLASS}__card__username`}>
{!showColors && (
<button
className={classnames(`${CLASS}__card__username__copy`)}
type="button"
onClick={onCopyUsername}
aria-label={i18n('icu:UsernameLinkModalBody__copy')}
/>
)}
<div className={`${CLASS}__card__username__text`}>{username}</div>
</div>
</div>
{confirmReset && (
<ConfirmationDialog
i18n={i18n}
dialogName="UsernameLinkModal__confirm-reset"
onClose={onCancelReset}
actions={[
{
action: onConfirmReset,
style: 'negative',
text: i18n('icu:UsernameLinkModalBody__reset'),
},
]}
>
{i18n('icu:UsernameLinkModalBody__reset__confirm')}
</ConfirmationDialog>
)}
{showColors ? (
<UsernameLinkColors
i18n={i18n}
value={colorId}
onChange={onUsernameLinkColorChange}
onSave={onUsernameLinkColorSave}
onCancel={onUsernameLinkColorCancel}
/>
) : (
info
)}
</div>
</div>
);
}

View file

@ -419,6 +419,20 @@ export function toAccountRecord(
accountRecord.storyViewReceiptsEnabled = Proto.OptionalBool.UNSET;
}
// Username link
{
const color = window.storage.get('usernameLinkColor');
const linkData = window.storage.get('usernameLink');
if (linkData?.entropy.length && linkData?.serverId.length) {
accountRecord.usernameLink = {
color,
entropy: linkData.entropy,
serverId: linkData.serverId,
};
}
}
applyUnknownFields(accountRecord, conversation);
return accountRecord;
@ -1171,6 +1185,7 @@ export async function mergeAccountRecord(
storiesDisabled,
storyViewReceiptsEnabled,
username,
usernameLink,
} = accountRecord;
const updatedConversations = new Array<ConversationModel>();
@ -1425,6 +1440,22 @@ export async function mergeAccountRecord(
break;
}
if (usernameLink?.entropy?.length && usernameLink?.serverId?.length) {
await Promise.all([
usernameLink.color &&
window.storage.put('usernameLinkColor', usernameLink.color),
window.storage.put('usernameLink', {
entropy: usernameLink.entropy,
serverId: usernameLink.serverId,
}),
]);
} else {
await Promise.all([
window.storage.remove('usernameLinkColor'),
window.storage.remove('usernameLink'),
]);
}
const ourID = window.ConversationController.getOurConversationId();
if (!ourID) {

View file

@ -11,6 +11,8 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { strictAssert } from '../util/assert';
import { sleep } from '../util/sleep';
import { getMinNickname, getMaxNickname } from '../util/Username';
import { bytesToUuid } from '../Crypto';
import { uuidToBytes } from '../util/uuidToBytes';
import type { UsernameReservationType } from '../types/Username';
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
import * as Errors from '../types/errors';
@ -18,6 +20,8 @@ import * as log from '../logging/log';
import MessageSender from '../textsecure/SendMessage';
import { HTTPError } from '../textsecure/Errors';
import { findRetryAfterTimeFromError } from '../jobs/helpers/findRetryAfterTimeFromError';
import * as Bytes from '../Bytes';
import { storageServiceUploadJob } from './storage';
export type WriteUsernameOptionsType = Readonly<
| {
@ -186,6 +190,9 @@ export async function confirmUsername(
});
await updateUsernameAndSyncProfile(username);
// TODO: DESKTOP-5687
await resetLink(username);
} catch (error) {
if (error instanceof HTTPError) {
if (error.code === 413 || error.code === 429) {
@ -221,6 +228,67 @@ export async function deleteUsername(
throw new Error('Username has changed on another device');
}
await window.storage.remove('usernameLink');
await server.deleteUsername(abortSignal);
await updateUsernameAndSyncProfile(undefined);
}
export async function resetLink(username: string): Promise<void> {
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
const me = window.ConversationController.getOurConversationOrThrow();
if (me.get('username') !== username) {
throw new Error('Username has changed on another device');
}
const link = usernames.createUsernameLink(username);
await window.storage.remove('usernameLink');
const { usernameLinkHandle: serverIdString } =
await server.replaceUsernameLink({
encryptedUsername: link.encryptedUsername,
});
await window.storage.put('usernameLink', {
entropy: link.entropy,
serverId: uuidToBytes(serverIdString),
});
me.captureChange('usernameLink');
storageServiceUploadJob();
}
const USERNAME_LINK_ENTROPY_SIZE = 32;
export async function resolveUsernameByLinkBase64(
base64: string
): Promise<string> {
const { server } = window.textsecure;
if (!server) {
throw new Error('server interface is not available!');
}
const content = Bytes.fromBase64(base64);
const entropy = content.slice(0, USERNAME_LINK_ENTROPY_SIZE);
const serverIdBytes = content.slice(USERNAME_LINK_ENTROPY_SIZE);
const serverId = bytesToUuid(serverIdBytes);
strictAssert(serverId, 'Failed to re-encode server id as uuid');
strictAssert(window.textsecure.server, 'WebAPI must be available');
const { usernameLinkEncryptedValue } = await server.resolveUsernameLink(
serverId
);
const link = new usernames.UsernameLink(
Buffer.from(entropy),
Buffer.from(usernameLinkEncryptedValue)
);
return link.decryptUsername();
}

View file

@ -379,6 +379,13 @@ const ITEM_SPECS: Partial<Record<ItemKeyType, ObjectMappingSpecType>> = {
senderCertificate: ['value.serialized'],
senderCertificateNoE164: ['value.serialized'],
subscriberId: ['value'],
usernameLink: {
key: 'value',
valueSpec: {
isMap: true,
valueSpec: ['entropy', 'serverId'],
},
},
};
async function createOrUpdateItem<K extends ItemKeyType>(
data: ItemType<K>

View file

@ -13,8 +13,6 @@ import { drop } from '../../util/drop';
import type {
ConversationColorType,
CustomColorType,
CustomColorsItemType,
DefaultConversationColorType,
} from '../../types/Colors';
import { ConversationColors } from '../../types/Colors';
import { reloadSelectedConversation } from '../../shims/reloadSelectedConversation';
@ -24,25 +22,26 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
// State
export type ItemsStateType = ReadonlyDeep<{
universalExpireTimer?: number;
export type ItemsStateType = ReadonlyDeep<
{
[key: string]: unknown;
[key: string]: unknown;
remoteConfig?: RemoteConfigType;
serverTimeSkew?: number;
// This property should always be set and this is ensured in background.ts
defaultConversationColor?: DefaultConversationColorType;
customColors?: CustomColorsItemType;
preferredLeftPaneWidth?: number;
preferredReactionEmoji?: Array<string>;
areWeASubscriber?: boolean;
}>;
remoteConfig?: RemoteConfigType;
serverTimeSkew?: number;
} & Partial<
Pick<
StorageAccessType,
| 'universalExpireTimer'
| 'defaultConversationColor'
| 'customColors'
| 'preferredLeftPaneWidth'
| 'preferredReactionEmoji'
| 'areWeASubscriber'
| 'usernameLinkColor'
| 'usernameLink'
>
>
>;
// Actions

View file

@ -10,6 +10,7 @@ import {
ConfirmUsernameResult,
} from '../../types/Username';
import * as usernameServices from '../../services/username';
import { storageServiceUploadJob } from '../../services/storage';
import type { ReserveUsernameResultType } from '../../services/username';
import { missingCaseError } from '../../util/missingCaseError';
import { sleep } from '../../util/sleep';
@ -19,6 +20,7 @@ import type { PromiseAction } from '../util';
import { getMe } from '../selectors/conversations';
import {
UsernameEditState,
UsernameLinkState,
UsernameReservationState,
UsernameReservationError,
} from './usernameEnums';
@ -37,6 +39,9 @@ export type UsernameStateType = ReadonlyDeep<{
// ProfileEditor
editState: UsernameEditState;
// UsernameLinkModalBody
linkState: UsernameLinkState;
// EditUsernameModalBody
usernameReservation: UsernameReservationStateType;
}>;
@ -50,6 +55,7 @@ const SET_USERNAME_RESERVATION_ERROR = 'username/SET_RESERVATION_ERROR';
const RESERVE_USERNAME = 'username/RESERVE_USERNAME';
const CONFIRM_USERNAME = 'username/CONFIRM_USERNAME';
const DELETE_USERNAME = 'username/DELETE_USERNAME';
const RESET_USERNAME_LINK = 'username/RESET_USERNAME_LINK';
type SetUsernameEditStateActionType = ReadonlyDeep<{
type: typeof SET_USERNAME_EDIT_STATE;
@ -86,6 +92,9 @@ type ConfirmUsernameActionType = ReadonlyDeep<
type DeleteUsernameActionType = ReadonlyDeep<
PromiseAction<typeof DELETE_USERNAME, void>
>;
type ResetUsernameLinkActionType = ReadonlyDeep<
PromiseAction<typeof RESET_USERNAME_LINK, void>
>;
export type UsernameActionType = ReadonlyDeep<
| SetUsernameEditStateActionType
@ -95,6 +104,7 @@ export type UsernameActionType = ReadonlyDeep<
| ReserveUsernameActionType
| ConfirmUsernameActionType
| DeleteUsernameActionType
| ResetUsernameLinkActionType
>;
export const actions = {
@ -105,6 +115,10 @@ export const actions = {
reserveUsername,
confirmUsername,
deleteUsername,
markCompletedUsernameOnboarding,
resetUsernameLink,
setUsernameLinkColor,
markCompletedUsernameLinkOnboarding,
};
export function setUsernameEditState(
@ -255,11 +269,72 @@ export function deleteUsername({
};
}
export type ResetUsernameLinkOptionsType = ReadonlyDeep<{
doResetLink?: typeof usernameServices.resetLink;
}>;
export function resetUsernameLink({
doResetLink = usernameServices.resetLink,
}: ResetUsernameLinkOptionsType = {}): ThunkAction<
void,
RootStateType,
unknown,
ResetUsernameLinkActionType
> {
return dispatch => {
const me = window.ConversationController.getOurConversationOrThrow();
const username = me.get('username');
assertDev(username, 'Username is required for resetting link');
dispatch({
type: RESET_USERNAME_LINK,
payload: doResetLink(username),
});
};
}
function markCompletedUsernameOnboarding(): ThunkAction<
void,
RootStateType,
unknown,
never
> {
return async () => {
await window.storage.put('hasCompletedUsernameOnboarding', true);
const me = window.ConversationController.getOurConversationOrThrow();
me.captureChange('usernameOnboarding');
storageServiceUploadJob();
};
}
function markCompletedUsernameLinkOnboarding(): ThunkAction<
void,
RootStateType,
unknown,
never
> {
return async () => {
await window.storage.put('hasCompletedUsernameLinkOnboarding', true);
};
}
function setUsernameLinkColor(
color: number
): ThunkAction<void, RootStateType, unknown, never> {
return async () => {
await window.storage.put('usernameLinkColor', color);
const me = window.ConversationController.getOurConversationOrThrow();
me.captureChange('usernameLinkColor');
storageServiceUploadJob();
};
}
// Reducers
export function getEmptyState(): UsernameStateType {
return {
editState: UsernameEditState.Editing,
linkState: UsernameLinkState.Ready,
usernameReservation: {
state: UsernameReservationState.Closed,
},
@ -476,5 +551,26 @@ export function reducer(
return state;
}
if (action.type === 'username/RESET_USERNAME_LINK_PENDING') {
return {
...state,
linkState: UsernameLinkState.Updating,
};
}
if (action.type === 'username/RESET_USERNAME_LINK_FULFILLED') {
return {
...state,
linkState: UsernameLinkState.Ready,
};
}
if (action.type === 'username/RESET_USERNAME_LINK_REJECTED') {
return {
...state,
linkState: UsernameLinkState.Ready,
};
}
return state;
}

View file

@ -11,6 +11,15 @@ export enum UsernameEditState {
Deleting = 'Deleting',
}
//
// UsernameLinkModalBody
//
export enum UsernameLinkState {
Ready = 'Ready',
Updating = 'Updating',
}
//
// EditUsernameModalBody
//

View file

@ -19,6 +19,8 @@ import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
import { isBeta } from '../../util/version';
import { DurationInSeconds } from '../../util/durations';
import { generateUsernameLink } from '../../util/sgnlHref';
import * as Bytes from '../../Bytes';
import { getUserNumber, getUserACI } from './user';
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
@ -86,12 +88,41 @@ export const getHasCompletedUsernameOnboarding = createSelector(
Boolean(state.hasCompletedUsernameOnboarding)
);
export const getHasCompletedUsernameLinkOnboarding = createSelector(
getItems,
(state: ItemsStateType): boolean =>
Boolean(state.hasCompletedUsernameLinkOnboarding)
);
export const getHasCompletedSafetyNumberOnboarding = createSelector(
getItems,
(state: ItemsStateType): boolean =>
Boolean(state.hasCompletedSafetyNumberOnboarding)
);
export const getUsernameLinkColor = createSelector(
getItems,
(state: ItemsStateType): number | undefined => state.usernameLinkColor
);
export const getUsernameLink = createSelector(
getItems,
({ usernameLink }: ItemsStateType): string | undefined => {
if (!usernameLink) {
return undefined;
}
const { entropy, serverId } = usernameLink;
if (!entropy.length || !serverId.length) {
return undefined;
}
const content = Bytes.concatenate([entropy, serverId]);
return generateUsernameLink(Bytes.toBase64(content));
}
);
export const isInternalUser = createSelector(
getRemoteConfig,
(remoteConfig: ConfigMapType): boolean => {

View file

@ -11,6 +11,7 @@ import type {
} from '../ducks/username';
import type {
UsernameEditState,
UsernameLinkState,
UsernameReservationState,
UsernameReservationError,
} from '../ducks/usernameEnums';
@ -23,6 +24,11 @@ export const getUsernameEditState = createSelector(
(state: UsernameStateType): UsernameEditState => state.editState
);
export const getUsernameLinkState = createSelector(
getUsernameState,
(state: UsernameStateType): UsernameLinkState => state.linkState
);
export const getUsernameReservation = createSelector(
getUsernameState,
(state: UsernameStateType): UsernameReservationStateType =>

View file

@ -8,7 +8,6 @@ import { mapDispatchToProps } from '../actions';
import type { PropsDataType as ProfileEditorModalPropsType } from '../../components/ProfileEditorModal';
import { ProfileEditorModal } from '../../components/ProfileEditorModal';
import type { PropsDataType } from '../../components/ProfileEditor';
import { storageServiceUploadJob } from '../../services/storage';
import { SmartEditUsernameModalBody } from './EditUsernameModalBody';
import type { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
@ -16,10 +15,16 @@ import {
getEmojiSkinTone,
getUsernamesEnabled,
getHasCompletedUsernameOnboarding,
getHasCompletedUsernameLinkOnboarding,
getUsernameLinkColor,
getUsernameLink,
} from '../selectors/items';
import { getMe } from '../selectors/conversations';
import { selectRecentEmojis } from '../selectors/emojis';
import { getUsernameEditState } from '../selectors/username';
import {
getUsernameEditState,
getUsernameLinkState,
} from '../selectors/username';
function renderEditUsernameModalBody(props: {
onClose: () => void;
@ -27,12 +32,6 @@ function renderEditUsernameModalBody(props: {
return <SmartEditUsernameModalBody {...props} />;
}
async function markCompletedUsernameOnboarding(): Promise<void> {
await window.storage.put('hasCompletedUsernameOnboarding', true);
storageServiceUploadJob();
}
function mapStateToProps(
state: StateType
): Omit<PropsDataType, 'onEditStateChange' | 'onProfileChanged'> &
@ -53,7 +52,12 @@ function mapStateToProps(
const isUsernameFlagEnabled = getUsernamesEnabled(state);
const hasCompletedUsernameOnboarding =
getHasCompletedUsernameOnboarding(state);
const hasCompletedUsernameLinkOnboarding =
getHasCompletedUsernameLinkOnboarding(state);
const usernameEditState = getUsernameEditState(state);
const usernameLinkState = getUsernameLinkState(state);
const usernameLinkColor = getUsernameLinkColor(state);
const usernameLink = getUsernameLink(state);
return {
aboutEmoji,
@ -64,15 +68,18 @@ function mapStateToProps(
familyName,
firstName: String(firstName),
hasCompletedUsernameOnboarding,
hasCompletedUsernameLinkOnboarding,
hasError: state.globalModals.profileEditorHasError,
i18n: getIntl(state),
isUsernameFlagEnabled,
markCompletedUsernameOnboarding,
recentEmojis,
skinTone,
userAvatarData,
username,
usernameEditState,
usernameLinkState,
usernameLinkColor,
usernameLink,
renderEditUsernameModalBody,
};

View file

@ -0,0 +1,136 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import path from 'path';
import { mkdir } from 'fs/promises';
import { pathExists, writeFile, readFile } from 'fs-extra';
import {
_generateImageBlob,
COLOR_MAP,
} from '../../components/UsernameLinkModalBody';
import { SignalService as Proto } from '../../protobuf';
const ColorEnum = Proto.AccountRecord.UsernameLink.Color;
async function getImageData(blob: Blob): Promise<ImageData> {
const url = URL.createObjectURL(blob);
try {
const img = new Image();
await new Promise(resolve => {
img.addEventListener('load', resolve);
img.src = url;
});
const canvas = new OffscreenCanvas(img.width, img.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get 2d context');
}
context.drawImage(img, 0, 0);
return context.getImageData(0, 0, img.width, img.height);
} finally {
URL.revokeObjectURL(url);
}
}
const TEST_COLORS: ReadonlyArray<[string, number]> = [
['white', ColorEnum.WHITE],
['blue', ColorEnum.BLUE],
];
describe('<UsernameLinkModalBody>', () => {
before(async () => {
// We need to load the font first, otherwise the first test render will use
// default font (not Inter)
const f = new FontFace(
'Inter',
'url(../fonts/inter-v3.19/Inter-SemiBold.woff2)',
{
weight: '600',
}
);
await f.load();
document.fonts.add(f);
});
for (const [colorName, colorId] of TEST_COLORS) {
it(`should generate correct ${colorName} QR code image`, async () => {
const scheme = COLOR_MAP.get(colorId);
if (!scheme) {
throw new Error(`Missing color scheme for: ${colorId}`);
}
const { bg: bgColor, fg: fgColor } = scheme;
const generatedBlob = await _generateImageBlob({
link:
'https://signal.me#eu/' +
'E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe',
username: 'signal.12',
colorId,
bgColor,
fgColor,
// Just because we run from `test/` folder, and not `/`
logoUrl: '../images/signal-qr-logo.svg',
// Force pixel ratio since test runner might not be on Retina
devicePixelRatio: 2,
});
// Create fixture if not present
const fileName = `username-link-${colorName}-${process.platform}.png`;
const fixture = path.join(
__dirname,
'..',
'..',
'..',
'fixtures',
fileName
);
if (!(await pathExists(fixture))) {
await writeFile(
fixture,
Buffer.from(await generatedBlob.arrayBuffer())
);
return;
}
// Otherwise compare against existing fixture
const expectedData = new Blob([await readFile(fixture)], {
type: 'image/png',
});
const expected = await getImageData(expectedData);
const actual = await getImageData(generatedBlob);
try {
assert.strictEqual(actual.width, expected.width, 'Wrong image width');
assert.strictEqual(
actual.height,
expected.height,
'Wrong image height'
);
assert.deepEqual(actual.data, expected.data, 'Wrong image data');
} catch (error) {
const { ARTIFACTS_DIR } = process.env;
if (ARTIFACTS_DIR) {
await mkdir(ARTIFACTS_DIR, { recursive: true });
await writeFile(
path.join(ARTIFACTS_DIR, fileName),
Buffer.from(await generatedBlob.arrayBuffer())
);
}
throw error;
}
});
}
});

14
ts/test-mock/helpers.ts Normal file
View file

@ -0,0 +1,14 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function bufferToUuid(buffer: Buffer): string {
const hex = buffer.toString('hex');
return [
hex.substring(0, 8),
hex.substring(8, 12),
hex.substring(12, 16),
hex.substring(16, 20),
hex.substring(20),
].join('-');
}

View file

@ -4,13 +4,16 @@
import { assert } from 'chai';
import { Proto, StorageState } from '@signalapp/mock-server';
import type { PrimaryDevice } from '@signalapp/mock-server';
import { usernames } from '@signalapp/libsignal-client';
import createDebug from 'debug';
import * as durations from '../../util/durations';
import { uuidToBytes } from '../../util/uuidToBytes';
import { generateUsernameLink } from '../../util/sgnlHref';
import { MY_STORY_ID } from '../../types/Stories';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
import { bufferToUuid } from '../helpers';
export const debug = createDebug('mock:test:username');
@ -206,6 +209,32 @@ describe('pnp/username', function needsName() {
assert.strictEqual(removed.length, 1, 'only one record must be removed');
assert.strictEqual(added[0]?.account?.username, username);
const usernameLink = added[0]?.account?.usernameLink;
if (!usernameLink) {
throw new Error('No username link in AccountRecord');
}
if (!usernameLink.entropy) {
throw new Error('No username link entropy in AccountRecord');
}
if (!usernameLink.serverId) {
throw new Error('No username link serverId in AccountRecord');
}
const linkUuid = bufferToUuid(Buffer.from(usernameLink.serverId));
const encryptedLinkBase64 = await server.lookupByUsernameLink(linkUuid);
if (!encryptedLinkBase64) {
throw new Error('Could not find link on the sever');
}
const encryptedLink = Buffer.from(encryptedLinkBase64, 'base64');
const link = new usernames.UsernameLink(
Buffer.from(usernameLink.entropy),
encryptedLink
);
const linkUsername = link.decryptUsername();
assert.strictEqual(linkUsername, username);
state = newState;
}
@ -233,7 +262,17 @@ describe('pnp/username', function needsName() {
assert.strictEqual(added.length, 1, 'only one record must be added');
assert.strictEqual(removed.length, 1, 'only one record must be removed');
assert.strictEqual(added[0]?.account?.username, '');
assert.strictEqual(added[0]?.account?.username, '', 'clears username');
assert.strictEqual(
added[0]?.account?.usernameLink?.entropy?.length ?? 0,
0,
'clears usernameLink.entropy'
);
assert.strictEqual(
added[0]?.account?.usernameLink?.serverId?.length ?? 0,
0,
'clears usernameLink.serverId'
);
state = newState;
}
@ -272,4 +311,56 @@ describe('pnp/username', function needsName() {
assert.strictEqual(source, desktop);
}
});
it('looks up contacts by username link', async () => {
const { desktop, phone, server } = bootstrap;
debug('creating a contact with username link');
const carl = await server.createPrimaryDevice({
profileName: 'Devin',
});
await server.setUsername(carl.device.uuid, CARL_USERNAME);
const { entropy, serverId } = await server.setUsernameLink(
carl.device.uuid,
CARL_USERNAME
);
const linkUrl = generateUsernameLink(
Buffer.concat([entropy, uuidToBytes(serverId)]).toString('base64')
);
debug('sending link to Note to Self');
await phone.sendText(desktop, linkUrl, {
withProfileKey: true,
});
const window = await app.getWindow();
debug('opening note to self');
const leftPane = window.locator('.left-pane-wrapper');
await leftPane.locator(`[data-testid="${desktop.uuid}"]`).click();
debug('clicking link');
await window.locator('.module-message__text a').click({
noWaitAfter: true,
});
debug('waiting for conversation to open');
await window
.locator(`.module-conversation-hero >> "${CARL_USERNAME}"`)
.waitFor();
debug('sending a message');
{
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello Carl');
await compositionInput.press('Enter');
const { body, source } = await carl.waitForMessage();
assert.strictEqual(body, 'Hello Carl');
assert.strictEqual(source, desktop);
}
});
});

View file

@ -12,7 +12,7 @@ import {
parseSgnlHref,
parseCaptchaHref,
parseE164FromSignalDotMeHash,
parseUsernameFromSignalDotMeHash,
parseUsernameBase64FromSignalDotMeHash,
parseSignalHttpsLink,
generateUsernameLink,
rewriteSignalHrefsIfNecessary,
@ -375,21 +375,19 @@ describe('sgnlHref', () => {
});
});
describe('parseUsernameFromSignalDotMeHash', () => {
describe('parseUsernameBase64FromSignalDotMeHash', () => {
it('returns undefined for invalid inputs', () => {
['', ' u/+18885551234', 'z/18885551234'].forEach(hash => {
assert.isUndefined(parseUsernameFromSignalDotMeHash(hash));
['', ' eu/+18885551234', 'z/18885551234'].forEach(hash => {
assert.isUndefined(parseUsernameBase64FromSignalDotMeHash(hash));
});
});
it('returns the username for valid inputs', () => {
assert.strictEqual(
parseUsernameFromSignalDotMeHash('u/signal.03'),
'signal.03'
);
assert.strictEqual(
parseUsernameFromSignalDotMeHash('u/signal%2F03'),
'signal/03'
parseUsernameBase64FromSignalDotMeHash(
'eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
),
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
);
});
});
@ -397,22 +395,20 @@ describe('sgnlHref', () => {
describe('generateUsernameLink', () => {
it('generates regular link', () => {
assert.strictEqual(
generateUsernameLink('signal.03'),
'https://signal.me/#u/signal.03'
);
});
it('generates encoded link', () => {
assert.strictEqual(
generateUsernameLink('signal/03'),
'https://signal.me/#u/signal%2F03'
generateUsernameLink(
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe'
),
'https://signal.me#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
);
});
it('generates short link', () => {
assert.strictEqual(
generateUsernameLink('signal/03', { short: true }),
'signal.me/#u/signal%2F03'
generateUsernameLink(
'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe',
{ short: true }
),
'signal.me#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe'
);
});
});

View file

@ -56,10 +56,10 @@ export class Storage implements StorageInterface {
defaultValue: V
): V;
public get<K extends keyof Access, V extends Access[K]>(
public get<K extends keyof Access>(
key: K,
defaultValue?: V
): V | undefined {
defaultValue?: Access[K]
): Access[K] | undefined {
if (!this.ready) {
log.warn('Called storage.get before storage is ready. key:', key);
}
@ -69,7 +69,7 @@ export class Storage implements StorageInterface {
return defaultValue;
}
return item as V;
return item;
}
public async put<K extends keyof Access>(

View file

@ -520,6 +520,7 @@ const URL_CALLS = {
username: 'v1/accounts/username_hash',
reserveUsername: 'v1/accounts/username_hash/reserve',
confirmUsername: 'v1/accounts/username_hash/confirm',
usernameLink: 'v1/accounts/username_link',
whoami: 'v1/accounts/whoami',
};
@ -823,6 +824,10 @@ export type ReserveUsernameOptionsType = Readonly<{
abortSignal?: AbortSignal;
}>;
export type ReplaceUsernameLinkOptionsType = Readonly<{
encryptedUsername: Uint8Array;
}>;
export type ConfirmUsernameOptionsType = Readonly<{
hash: Uint8Array;
proof: Uint8Array;
@ -838,6 +843,20 @@ export type ReserveUsernameResultType = z.infer<
typeof reserveUsernameResultZod
>;
const replaceUsernameLinkResultZod = z.object({
usernameLinkHandle: z.string(),
});
export type ReplaceUsernameLinkResultType = z.infer<
typeof replaceUsernameLinkResultZod
>;
const resolveUsernameLinkResultZod = z.object({
usernameLinkEncryptedValue: z.string().transform(x => Bytes.fromBase64(x)),
});
export type ResolveUsernameLinkResultType = z.infer<
typeof resolveUsernameLinkResultZod
>;
export type ConfirmCodeOptionsType = Readonly<{
number: string;
code: string;
@ -1002,6 +1021,13 @@ export type WebAPIType = {
options: ReserveUsernameOptionsType
) => Promise<ReserveUsernameResultType>;
confirmUsername(options: ConfirmUsernameOptionsType): Promise<void>;
replaceUsernameLink: (
options: ReplaceUsernameLinkOptionsType
) => Promise<ReplaceUsernameLinkResultType>;
deleteUsernameLink: () => Promise<void>;
resolveUsernameLink: (
serverId: string
) => Promise<ResolveUsernameLinkResultType>;
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
registerKeys: (genKeys: UploadKeysType, uuidKind: UUIDKind) => Promise<void>;
registerSupportForUnauthenticatedDelivery: () => Promise<void>;
@ -1284,6 +1310,7 @@ export function initialize({
confirmUsername,
createGroup,
deleteUsername,
deleteUsernameLink,
downloadOnboardingStories,
fetchLinkPreviewImage,
fetchLinkPreviewMetadata,
@ -1336,6 +1363,8 @@ export function initialize({
registerKeys,
registerRequestHandler,
registerSupportForUnauthenticatedDelivery,
resolveUsernameLink,
replaceUsernameLink,
reportMessage,
requestVerificationSMS,
requestVerificationVoice,
@ -1900,6 +1929,43 @@ export function initialize({
});
}
async function replaceUsernameLink({
encryptedUsername,
}: ReplaceUsernameLinkOptionsType): Promise<ReplaceUsernameLinkResultType> {
return replaceUsernameLinkResultZod.parse(
await _ajax({
call: 'usernameLink',
httpType: 'PUT',
responseType: 'json',
jsonData: {
usernameLinkEncryptedValue: Bytes.toBase64(encryptedUsername),
},
})
);
}
async function deleteUsernameLink(): Promise<void> {
await _ajax({
call: 'usernameLink',
httpType: 'DELETE',
});
}
async function resolveUsernameLink(
serverId: string
): Promise<ResolveUsernameLinkResultType> {
return resolveUsernameLinkResultZod.parse(
await _ajax({
httpType: 'GET',
call: 'usernameLink',
urlParameters: `/${encodeURIComponent(serverId)}`,
responseType: 'json',
unauthenticated: true,
accessKey: undefined,
})
);
}
async function reportMessage({
senderUuid,
serverGuid,

View file

@ -74,6 +74,7 @@ export type StorageAccessType = {
hasRegisterSupportForUnauthenticatedDelivery: boolean;
hasSetMyStoriesPrivacy: boolean;
hasCompletedUsernameOnboarding: boolean;
hasCompletedUsernameLinkOnboarding: boolean;
hasCompletedSafetyNumberOnboarding: boolean;
hasViewedOnboardingStory: boolean;
hasStoriesDisabled: boolean;
@ -158,6 +159,11 @@ export type StorageAccessType = {
subscriberCurrencyCode: string;
displayBadgesOnProfile: boolean;
keepMutedChatsArchived: boolean;
usernameLinkColor: number;
usernameLink: {
entropy: Uint8Array;
serverId: Uint8Array;
};
// Deprecated
'challenge:retry-message-ids': never;

View file

@ -1,8 +1,6 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { usernames } from '@signalapp/libsignal-client';
export type UsernameReservationType = Readonly<{
username: string;
previousUsername: string | undefined;
@ -27,7 +25,7 @@ export enum ConfirmUsernameResult {
export function getUsernameFromSearch(searchTerm: string): string | undefined {
try {
usernames.hash(searchTerm);
window.SignalContext.usernames.hash(searchTerm);
return searchTerm;
} catch {
return undefined;

View file

@ -21,6 +21,7 @@ import { parseSystemTraySetting } from '../types/SystemTraySetting';
import type { ConversationType } from '../state/ducks/conversations';
import type { AuthorizeArtCreatorDataType } from '../state/ducks/globalModals';
import { calling } from '../services/calling';
import { resolveUsernameByLinkBase64 } from '../services/username';
import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations';
import { getCustomColors } from '../state/selectors/items';
import { themeChanged } from '../shims/themeChanged';
@ -36,7 +37,7 @@ import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled';
import * as Registration from './registration';
import {
parseE164FromSignalDotMeHash,
parseUsernameFromSignalDotMeHash,
parseUsernameBase64FromSignalDotMeHash,
} from './sgnlHref';
import { lookupConversationWithoutUuid } from './lookupConversationWithoutUuid';
import * as log from '../logging/log';
@ -538,11 +539,13 @@ export function createIPCEvents(
return;
}
const maybeUsername = parseUsernameFromSignalDotMeHash(hash);
if (maybeUsername) {
const maybeUsernameBase64 = parseUsernameBase64FromSignalDotMeHash(hash);
if (maybeUsernameBase64) {
const username = await resolveUsernameByLinkBase64(maybeUsernameBase64);
const convoId = await lookupConversationWithoutUuid({
type: 'username',
username: maybeUsername,
username,
showUserNotFoundModal,
setIsFetchingUUID: noop,
});

View file

@ -4,10 +4,10 @@
import type { LoggerType } from '../types/Logging';
import { maybeParseUrl } from './url';
import { isValidE164 } from './isValidE164';
import { fromWebSafeBase64, toWebSafeBase64 } from './webSafeBase64';
const SIGNAL_HOSTS = new Set(['signal.group', 'signal.art', 'signal.me']);
const SIGNAL_DOT_ME_E164_PREFIX = 'p/';
const SIGNAL_DOT_ME_USERNAME_PREFIX = 'u/';
function parseUrl(value: string | URL, logger: LoggerType): undefined | URL {
if (value instanceof URL) {
@ -147,14 +147,15 @@ export function parseE164FromSignalDotMeHash(hash: string): undefined | string {
return isValidE164(maybeE164, true) ? maybeE164 : undefined;
}
export function parseUsernameFromSignalDotMeHash(
export function parseUsernameBase64FromSignalDotMeHash(
hash: string
): undefined | string {
if (!hash.startsWith(SIGNAL_DOT_ME_USERNAME_PREFIX)) {
const match = hash.match(/^eu\/([a-zA-Z0-9_-]{64})$/);
if (!match) {
return;
}
return decodeURIComponent(hash.slice(SIGNAL_DOT_ME_USERNAME_PREFIX.length));
return fromWebSafeBase64(match[1]);
}
/**
@ -184,10 +185,10 @@ export type GenerateUsernameLinkOptionsType = Readonly<{
}>;
export function generateUsernameLink(
username: string,
base64: string,
{ short = false }: GenerateUsernameLinkOptionsType = {}
): string {
const shortVersion = `signal.me/#u/${encodeURIComponent(username)}`;
const shortVersion = `signal.me#eu/${toWebSafeBase64(base64)}`;
if (short) {
return shortVersion;
}

View file

@ -3,6 +3,7 @@
import { ipcRenderer } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { usernames } from '@signalapp/libsignal-client';
import type { MenuOptionsType, MenuActionType } from '../types/menu';
import type { IPCEventsValuesType } from '../util/createIPCEvents';
@ -61,6 +62,7 @@ export type MinimalSignalContextType = {
export type SignalContextType = {
bytes: Bytes;
crypto: Crypto;
usernames: typeof usernames;
i18n: LocalizerType;
log: LoggerType;
renderWindow?: () => void;
@ -72,6 +74,7 @@ export const SignalContext: SignalContextType = {
...MinimalSignalContext,
bytes: new Bytes(),
crypto: new Crypto(),
usernames,
i18n,
log: window.SignalContext.log,
setIsCallActive(isCallActive: boolean): void {

View file

@ -2276,20 +2276,20 @@
bindings "^1.5.0"
tar "^6.1.0"
"@signalapp/libsignal-client@0.27.0", "@signalapp/libsignal-client@^0.27.0":
version "0.27.0"
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.27.0.tgz#012e254d42e4dcd752979419c048af65a3f1eed1"
integrity sha512-XinrJ9R2veJM/u3CAaL/YN5Yid+ASfsSceQiL/Qr1vKCsMori0bWG6AzOBnDUx/Bnm6dcDBc15t8w31WkXOTVw==
"@signalapp/libsignal-client@0.28.0", "@signalapp/libsignal-client@^0.28.0":
version "0.28.0"
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.28.0.tgz#b1553a4b56fc01afe5e9b2785abd5c680f46ebc4"
integrity sha512-Vl3vt9hBdPW2/cwuf8+ZMwxmlAlnuBSgsKebRPfDOboLWDRlQRq+tstlwfBFU0e/2ixgY95Wulu46I1cl6H40g==
dependencies:
node-gyp-build "^4.2.3"
uuid "^8.3.0"
"@signalapp/mock-server@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-3.1.0.tgz#6f499cf1a396626901760b93e888bb5020983075"
integrity sha512-u6zz9PWV7NLP+RIz2hg4zl0UY34Ufj2pjQsPmIY//oVnu3PfIiyuKMIzPU7jPMSYq29uFbUQPypLJYOYP2dOiA==
"@signalapp/mock-server@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-3.2.0.tgz#9371dc2002a1a8aa25ac815944a443cdc0a1b7c5"
integrity sha512-4rpAAH5tV8eoIikb6FozMmrCKr+pqaP9JNyfZQ5YLPanAiTE9iER7WJex0HJ9PhscgswbzWIPFuRyhm/qPocVQ==
dependencies:
"@signalapp/libsignal-client" "^0.27.0"
"@signalapp/libsignal-client" "^0.28.0"
debug "^4.3.2"
long "^4.0.0"
micro "^9.3.4"
@ -6594,6 +6594,11 @@ chalk@^3.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
changedpi@1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/changedpi/-/changedpi-1.0.4.tgz#91b915c7e3fbbcd75559249f553902b96f3e4c7e"
integrity sha512-9r6MNQrbg+cFURvEy10wo9Q35PD5GVj2GvXCbUYv8mU0Uf/NbkR7KlzMrjT4Ycd8a2nxApFJXQX2lTOPRFyG2g==
character-entities-legacy@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.1.tgz#f40779df1a101872bb510a3d295e1fccf147202f"