From a4821aa08ebe747556f04e029b1243215043cca6 Mon Sep 17 00:00:00 2001 From: Eduard Kuzmenko Date: Tue, 16 Mar 2021 19:18:51 +0400 Subject: [PATCH] Group type tab --- src/components/avatar.ts | 2 +- src/components/button.ts | 2 +- src/components/buttonIcon.ts | 2 +- src/components/checkboxField.ts | 12 +- src/components/inputField.ts | 53 +++++-- src/components/misc.ts | 12 ++ src/components/popups/createPoll.ts | 5 +- src/components/privacySection.ts | 10 +- src/components/radioField.ts | 38 +++-- .../sidebarLeft/tabs/editProfile.ts | 103 +++--------- .../sidebarLeft/tabs/generalSettings.ts | 14 +- .../sidebarLeft/tabs/notifications.ts | 12 +- .../sidebarRight/tabs/editContact.ts | 6 +- src/components/sidebarRight/tabs/editGroup.ts | 16 +- src/components/sidebarRight/tabs/groupType.ts | 148 ++++++++++++++++++ src/components/usernameInputField.ts | 98 ++++++++++++ src/lib/appManagers/appChatsManager.ts | 43 ++++- src/lib/appManagers/appUsersManager.ts | 8 + src/lib/richtextprocessor.ts | 4 + src/scss/partials/_rightSidebar.scss | 12 ++ src/scss/style.scss | 5 + 21 files changed, 468 insertions(+), 137 deletions(-) create mode 100644 src/components/sidebarRight/tabs/groupType.ts create mode 100644 src/components/usernameInputField.ts diff --git a/src/components/avatar.ts b/src/components/avatar.ts index a39e3c7c..29c3b9ce 100644 --- a/src/components/avatar.ts +++ b/src/components/avatar.ts @@ -161,4 +161,4 @@ export default class AvatarElement extends HTMLElement { } } -customElements.define("avatar-element", AvatarElement); \ No newline at end of file +customElements.define("avatar-element", AvatarElement); diff --git a/src/components/button.ts b/src/components/button.ts index 3a3ba565..fe4064b5 100644 --- a/src/components/button.ts +++ b/src/components/button.ts @@ -27,4 +27,4 @@ const Button = (className: string, options: Partial<{noRipple: true, onlyMobile: return button; }; -export default Button; \ No newline at end of file +export default Button; diff --git a/src/components/buttonIcon.ts b/src/components/buttonIcon.ts index 64c2ea47..24671ff9 100644 --- a/src/components/buttonIcon.ts +++ b/src/components/buttonIcon.ts @@ -5,4 +5,4 @@ const ButtonIcon = (className: string, options: Partial<{noRipple: true, onlyMob return button; }; -export default ButtonIcon; \ No newline at end of file +export default ButtonIcon; diff --git a/src/components/checkboxField.ts b/src/components/checkboxField.ts index 55db9245..2b02d209 100644 --- a/src/components/checkboxField.ts +++ b/src/components/checkboxField.ts @@ -37,7 +37,7 @@ export default class CheckboxField { if(options.stateKey) { appStateManager.getState().then(state => { - this.value = getDeepProperty(state, options.stateKey); + this.checked = getDeepProperty(state, options.stateKey); input.addEventListener('change', () => { appStateManager.setByKey(options.stateKey, input.checked); @@ -83,18 +83,18 @@ export default class CheckboxField { } } - get value() { + get checked() { return this.input.checked; } - set value(value: boolean) { - this.setValueSilently(value); + set checked(checked: boolean) { + this.setValueSilently(checked); const event = new Event('change', {bubbles: true, cancelable: true}); this.input.dispatchEvent(event); } - public setValueSilently(value: boolean) { - this.input.checked = value; + public setValueSilently(checked: boolean) { + this.input.checked = checked; } } diff --git a/src/components/inputField.ts b/src/components/inputField.ts index 387aa0a9..3dc9d139 100644 --- a/src/components/inputField.ts +++ b/src/components/inputField.ts @@ -51,6 +51,22 @@ const checkAndSetRTL = (input: HTMLElement) => { input.style.direction = direction; }; +export enum InputState { + Neutral = 0, + Valid = 1, + Error = 2 +}; + +export type InputFieldOptions = { + placeholder?: string, + label?: string, + name?: string, + maxLength?: number, + showLengthOn?: number, + plainText?: true, + animate?: true +}; + class InputField { public container: HTMLElement; public input: HTMLElement; @@ -63,15 +79,7 @@ class InputField { protected wasInputFakeClientHeight: number; protected showScrollDebounced: () => void; - constructor(protected options: { - placeholder?: string, - label?: string, - name?: string, - maxLength?: number, - showLengthOn?: number, - plainText?: true, - animate?: true - } = {}) { + constructor(public options: InputFieldOptions = {}) { this.container = document.createElement('div'); this.container.classList.add('input-field'); @@ -211,14 +219,31 @@ class InputField { return !this.input.classList.contains('error') && this.value !== this.originalValue; } - public setOriginalValue(value: InputField['originalValue']) { + public setOriginalValue(value: InputField['originalValue'] = '', silent = false) { this.originalValue = value; - if(this.options.plainText) { - this.value = value; - } else { - this.value = RichTextProcessor.wrapDraftText(value); + if(!this.options.plainText) { + value = RichTextProcessor.wrapDraftText(value); } + + if(silent) { + this.setValueSilently(value, false); + } else { + this.value = value; + } + } + + public setState(state: InputState, label?: string) { + if(label) { + this.label.innerHTML = label; + } + + this.input.classList.toggle('error', !!(state & InputState.Error)); + this.input.classList.toggle('valid', !!(state & InputState.Valid)); + } + + public setError(label?: string) { + this.setState(InputState.Error, label); } } diff --git a/src/components/misc.ts b/src/components/misc.ts index 0309ae9e..8da4f621 100644 --- a/src/components/misc.ts +++ b/src/components/misc.ts @@ -79,6 +79,18 @@ export function putPreloader(elem: Element, returnDiv = false): HTMLElement { MOUNT_CLASS_TO && (MOUNT_CLASS_TO.putPreloader = putPreloader); +export function setButtonLoader(elem: HTMLButtonElement, icon = 'check') { + elem.classList.remove('tgico-' + icon); + elem.disabled = true; + putPreloader(elem); + + return () => { + elem.innerHTML = ''; + elem.classList.add('tgico-' + icon); + elem.removeAttribute('disabled'); + }; +} + let sortedCountries: Country[]; export function formatPhoneNumber(str: string) { str = str.replace(/\D/g, ''); diff --git a/src/components/popups/createPoll.ts b/src/components/popups/createPoll.ts index 0aaa9a51..c3e79b81 100644 --- a/src/components/popups/createPoll.ts +++ b/src/components/popups/createPoll.ts @@ -303,7 +303,10 @@ export default class PopupCreatePoll extends PopupElement { }); questionField.input.addEventListener('input', this.onInput); - const radioField = new RadioField('', 'question'); + const radioField = new RadioField({ + text: '', + name: 'question' + }); radioField.main.append(questionField.container); questionField.input.addEventListener('click', cancelEvent); radioField.label.classList.add('hidden-widget'); diff --git a/src/components/privacySection.ts b/src/components/privacySection.ts index 70c3c447..f482c610 100644 --- a/src/components/privacySection.ts +++ b/src/components/privacySection.ts @@ -62,7 +62,15 @@ export default class PrivacySection { const random = randomLong(); r.forEach(({type, text}) => { - this.radioRows.set(type, new Row({radioField: new RadioField(text, random, '' + type)})); + const row = new Row({ + radioField: new RadioField({ + text, + name: random, + value: '' + type + }) + }); + + this.radioRows.set(type, row); }); const form = RadioFormFromRows([...this.radioRows.values()], this.onRadioChange); diff --git a/src/components/radioField.ts b/src/components/radioField.ts index 891f0aa2..a4b5888f 100644 --- a/src/components/radioField.ts +++ b/src/components/radioField.ts @@ -6,24 +6,29 @@ export default class RadioField { public label: HTMLLabelElement; public main: HTMLElement; - constructor(text: string, name: string, value?: string, stateKey?: string) { + constructor(options: { + text?: string, + name: string, + value?: string, + stateKey?: string + }) { const label = this.label = document.createElement('label'); label.classList.add('radio-field'); const input = this.input = document.createElement('input'); input.type = 'radio'; - /* input.id = */input.name = 'input-radio-' + name; + /* input.id = */input.name = 'input-radio-' + options.name; - if(value) { - input.value = value; + if(options.value) { + input.value = options.value; - if(stateKey) { + if(options.stateKey) { appStateManager.getState().then(state => { - input.checked = getDeepProperty(state, stateKey) === value; + input.checked = getDeepProperty(state, options.stateKey) === options.value; }); input.addEventListener('change', () => { - appStateManager.setByKey(stateKey, value); + appStateManager.setByKey(options.stateKey, options.value); }); } } @@ -31,8 +36,8 @@ export default class RadioField { const main = this.main = document.createElement('div'); main.classList.add('radio-field-main'); - if(text) { - main.innerHTML = text; + if(options.text) { + main.innerHTML = options.text; /* const caption = document.createElement('div'); caption.classList.add('radio-field-main-caption'); caption.innerHTML = text; @@ -47,4 +52,19 @@ export default class RadioField { label.append(input, main); } + + get checked() { + return this.input.checked; + } + + set checked(checked: boolean) { + this.setValueSilently(checked); + + const event = new Event('change', {bubbles: true, cancelable: true}); + this.input.dispatchEvent(event); + } + + public setValueSilently(checked: boolean) { + this.input.checked = checked; + } }; diff --git a/src/components/sidebarLeft/tabs/editProfile.ts b/src/components/sidebarLeft/tabs/editProfile.ts index 41b1aa9e..ec5325c1 100644 --- a/src/components/sidebarLeft/tabs/editProfile.ts +++ b/src/components/sidebarLeft/tabs/editProfile.ts @@ -1,10 +1,10 @@ import appProfileManager from "../../../lib/appManagers/appProfileManager"; import appUsersManager from "../../../lib/appManagers/appUsersManager"; -import apiManager from "../../../lib/mtproto/mtprotoworker"; import InputField from "../../inputField"; import { SliderSuperTab } from "../../slider"; import { attachClickEvent } from "../../../helpers/dom"; import EditPeer from "../../editPeer"; +import { UsernameInputField } from "../../usernameInputField"; // TODO: аватарка не поменяется в этой вкладке после изменения почему-то (если поставить в другом клиенте, и потом тут проверить, для этого ещё вышел в чатлист) @@ -12,8 +12,7 @@ export default class AppEditProfileTab extends SliderSuperTab { private firstNameInputField: InputField; private lastNameInputField: InputField; private bioInputField: InputField; - private userNameInputField: InputField; - private userNameInput: HTMLElement; + private usernameInputField: InputField; private profileUrlContainer: HTMLDivElement; private profileUrlAnchor: HTMLAnchorElement; @@ -74,14 +73,22 @@ export default class AppEditProfileTab extends SliderSuperTab { const inputWrapper = document.createElement('div'); inputWrapper.classList.add('input-wrapper'); - this.userNameInputField = new InputField({ + this.usernameInputField = new UsernameInputField({ + peerId: 0, label: 'Username (optional)', name: 'username', - plainText: true + plainText: true, + listenerSetter: this.listenerSetter, + onChange: () => { + this.editPeer.handleChange(); + this.setProfileUrl(); + }, + availableText: 'Username is available', + takenText: 'Username is already taken', + invalidText: 'Username is invalid' }); - this.userNameInput = this.userNameInputField.input; - inputWrapper.append(this.userNameInputField.container); + inputWrapper.append(this.usernameInputField.container); const caption = document.createElement('div'); caption.classList.add('caption'); @@ -91,67 +98,10 @@ export default class AppEditProfileTab extends SliderSuperTab { this.profileUrlContainer = caption.querySelector('.profile-url-container'); this.profileUrlAnchor = this.profileUrlContainer.lastElementChild as HTMLAnchorElement; - inputFields.push(this.userNameInputField); + inputFields.push(this.usernameInputField); this.scrollable.append(h2, inputWrapper, caption); } - let userNameLabel = this.userNameInput.nextElementSibling as HTMLLabelElement; - - this.listenerSetter.add(this.userNameInput, 'input', () => { - let value = this.userNameInputField.value; - - //console.log('userNameInput:', value); - if(value === this.userNameInputField.originalValue || !value.length) { - this.userNameInput.classList.remove('valid', 'error'); - userNameLabel.innerText = 'Username (optional)'; - this.setProfileUrl(); - this.editPeer.handleChange(); - return; - } else if(!this.isUsernameValid(value)) { // does not check the last underscore - this.userNameInput.classList.add('error'); - this.userNameInput.classList.remove('valid'); - userNameLabel.innerText = 'Username is invalid'; - } else { - this.userNameInput.classList.remove('valid', 'error'); - } - - if(this.userNameInput.classList.contains('error')) { - this.setProfileUrl(); - this.editPeer.handleChange(); - return; - } - - apiManager.invokeApi('account.checkUsername', { - username: value - }).then(available => { - if(this.userNameInputField.value !== value) return; - - if(available) { - this.userNameInput.classList.add('valid'); - this.userNameInput.classList.remove('error'); - userNameLabel.innerText = 'Username is available'; - } else { - this.userNameInput.classList.add('error'); - this.userNameInput.classList.remove('valid'); - userNameLabel.innerText = 'Username is already taken'; - } - }, (err) => { - if(this.userNameInputField.value !== value) return; - - switch(err.type) { - case 'USERNAME_INVALID': { - this.userNameInput.classList.add('error'); - this.userNameInput.classList.remove('valid'); - userNameLabel.innerText = 'Username is invalid'; - break; - } - } - }).then(() => { - this.editPeer.handleChange(); - this.setProfileUrl(); - }); - }); - attachClickEvent(this.editPeer.nextBtn, () => { this.editPeer.nextBtn.disabled = true; @@ -169,8 +119,8 @@ export default class AppEditProfileTab extends SliderSuperTab { })); } - if(this.userNameInputField.isValid() && this.userNameInput.classList.contains('valid')) { - promises.push(appProfileManager.updateUsername(this.userNameInputField.value)); + if(this.usernameInputField.isValid() && this.usernameInputField.input.classList.contains('valid')) { + promises.push(appUsersManager.updateUsername(this.usernameInputField.value)); } Promise.race(promises).finally(() => { @@ -187,13 +137,10 @@ export default class AppEditProfileTab extends SliderSuperTab { const user = appUsersManager.getSelf(); - this.firstNameInputField.setOriginalValue(user.first_name); - this.lastNameInputField.setOriginalValue(user.last_name); - this.bioInputField.setOriginalValue(''); - this.userNameInputField.setOriginalValue(user.username ?? ''); - - this.userNameInput.classList.remove('valid', 'error'); - this.userNameInput.nextElementSibling.innerHTML = 'Username (optional)'; + this.firstNameInputField.setOriginalValue(user.first_name, true); + this.lastNameInputField.setOriginalValue(user.last_name, true); + this.bioInputField.setOriginalValue('', true); + this.usernameInputField.setOriginalValue(user.username, true); appProfileManager.getProfile(user.id, true).then(userFull => { if(userFull.about) { @@ -205,16 +152,12 @@ export default class AppEditProfileTab extends SliderSuperTab { this.editPeer.handleChange(); } - public isUsernameValid(username: string) { - return ((username.length >= 5 && username.length <= 32) || !username.length) && /^[a-zA-Z0-9_]*$/.test(username); - } - private setProfileUrl() { - if(this.userNameInput.classList.contains('error') || !this.userNameInputField.value.length) { + if(this.usernameInputField.input.classList.contains('error') || !this.usernameInputField.value.length) { this.profileUrlContainer.style.display = 'none'; } else { this.profileUrlContainer.style.display = ''; - let url = 'https://t.me/' + this.userNameInputField.value; + let url = 'https://t.me/' + this.usernameInputField.value; this.profileUrlAnchor.innerText = url; this.profileUrlAnchor.href = url; } diff --git a/src/components/sidebarLeft/tabs/generalSettings.ts b/src/components/sidebarLeft/tabs/generalSettings.ts index cd185138..c2ffe1ca 100644 --- a/src/components/sidebarLeft/tabs/generalSettings.ts +++ b/src/components/sidebarLeft/tabs/generalSettings.ts @@ -88,12 +88,22 @@ export default class AppGeneralSettingsTab extends SliderSuperTab { const form = document.createElement('form'); const enterRow = new Row({ - radioField: new RadioField('Send by Enter', 'send-shortcut', 'enter', 'settings.sendShortcut'), + radioField: new RadioField({ + text: 'Send by Enter', + name: 'send-shortcut', + value: 'enter', + stateKey: 'settings.sendShortcut' + }), subtitle: 'New line by Shift + Enter', }); const ctrlEnterRow = new Row({ - radioField: new RadioField(`Send by ${isApple ? '⌘' : 'Ctrl'} + Enter`, 'send-shortcut', 'ctrlEnter', 'settings.sendShortcut'), + radioField: new RadioField({ + text: `Send by ${isApple ? '⌘' : 'Ctrl'} + Enter`, + name: 'send-shortcut', + value: 'ctrlEnter', + stateKey: 'settings.sendShortcut' + }), subtitle: 'New line by Enter', }); diff --git a/src/components/sidebarLeft/tabs/notifications.ts b/src/components/sidebarLeft/tabs/notifications.ts index 47fe5509..cd9e288e 100644 --- a/src/components/sidebarLeft/tabs/notifications.ts +++ b/src/components/sidebarLeft/tabs/notifications.ts @@ -43,8 +43,8 @@ export default class AppNotificationsTab extends SliderSuperTabEventable { (ret instanceof Promise ? ret : Promise.resolve(ret)).then((notifySettings) => { const applySettings = () => { const muted = appNotificationsManager.isMuted(notifySettings); - enabledRow.checkboxField.value = !muted; - previewEnabledRow.checkboxField.value = notifySettings.show_previews; + enabledRow.checkboxField.checked = !muted; + previewEnabledRow.checkboxField.checked = notifySettings.show_previews; return muted; }; @@ -52,8 +52,8 @@ export default class AppNotificationsTab extends SliderSuperTabEventable { applySettings(); this.eventListener.addListener('destroy', () => { - const mute = !enabledRow.checkboxField.value; - const showPreviews = previewEnabledRow.checkboxField.value; + const mute = !enabledRow.checkboxField.checked; + const showPreviews = previewEnabledRow.checkboxField.checked; if(mute === appNotificationsManager.isMuted(notifySettings) && showPreviews === notifySettings.show_previews) { return; @@ -115,10 +115,10 @@ export default class AppNotificationsTab extends SliderSuperTabEventable { this.scrollable.append(section.container); appNotificationsManager.getContactSignUpNotification().then(enabled => { - contactsSignUpRow.checkboxField.value = enabled; + contactsSignUpRow.checkboxField.checked = enabled; this.eventListener.addListener('destroy', () => { - const _enabled = contactsSignUpRow.checkboxField.value; + const _enabled = contactsSignUpRow.checkboxField.checked; if(enabled !== _enabled) { appNotificationsManager.setContactSignUpNotification(!_enabled); } diff --git a/src/components/sidebarRight/tabs/editContact.ts b/src/components/sidebarRight/tabs/editContact.ts index 5d9bbd2e..acd17e3f 100644 --- a/src/components/sidebarRight/tabs/editContact.ts +++ b/src/components/sidebarRight/tabs/editContact.ts @@ -81,8 +81,8 @@ export default class AppEditContactTab extends SliderSuperTab { const peerId = appPeersManager.getPeerId(update.peer.peer); if(this.peerId === peerId) { const enabled = !appNotificationsManager.isMuted(update.notify_settings); - if(enabled !== notificationsCheckboxField.value) { - notificationsCheckboxField.value = enabled; + if(enabled !== notificationsCheckboxField.checked) { + notificationsCheckboxField.checked = enabled; } } }); @@ -92,7 +92,7 @@ export default class AppEditContactTab extends SliderSuperTab { }); const enabled = !appNotificationsManager.isPeerLocalMuted(this.peerId, false); - notificationsCheckboxField.value = enabled; + notificationsCheckboxField.checked = enabled; const profileNameDiv = document.createElement('div'); profileNameDiv.classList.add('profile-name'); diff --git a/src/components/sidebarRight/tabs/editGroup.ts b/src/components/sidebarRight/tabs/editGroup.ts index 52db7c9a..a1664313 100644 --- a/src/components/sidebarRight/tabs/editGroup.ts +++ b/src/components/sidebarRight/tabs/editGroup.ts @@ -11,6 +11,7 @@ import { attachClickEvent, toggleDisability } from "../../../helpers/dom"; import { ChatFull } from "../../../layer"; import PopupPeer from "../../popups/peer"; import { addCancelButton } from "../../popups"; +import AppGroupTypeTab from "./groupType"; export default class AppEditGroupTab extends SliderSuperTab { private groupNameInputField: InputField; @@ -41,8 +42,10 @@ export default class AppEditGroupTab extends SliderSuperTab { name: 'group-description', maxLength: 255 }); + + const chat = appChatsManager.getChat(-this.peerId); - this.groupNameInputField.setOriginalValue(appChatsManager.getChat(-this.peerId).title); + this.groupNameInputField.setOriginalValue(chat.title); this.descriptionInputField.setOriginalValue(chatFull.about); @@ -62,8 +65,13 @@ export default class AppEditGroupTab extends SliderSuperTab { if(appChatsManager.hasRights(-this.peerId, 'change_type')) { const groupTypeRow = new Row({ title: 'Group Type', - subtitle: 'Private', - clickable: true, + subtitle: chat.username ? 'Public' : 'Private', + clickable: () => { + const tab = new AppGroupTypeTab(this.slider); + tab.peerId = this.peerId; + tab.chatFull = chatFull; + tab.open(); + }, icon: 'lock' }); @@ -139,7 +147,7 @@ export default class AppEditGroupTab extends SliderSuperTab { }); if(appChatsManager.isChannel(-this.peerId) && !(chatFull as ChatFull.channelFull).pFlags.hidden_prehistory) { - showChatHistoryCheckboxField.value = true; + showChatHistoryCheckboxField.checked = true; } section.content.append(showChatHistoryCheckboxField.label); diff --git a/src/components/sidebarRight/tabs/groupType.ts b/src/components/sidebarRight/tabs/groupType.ts new file mode 100644 index 00000000..c2497313 --- /dev/null +++ b/src/components/sidebarRight/tabs/groupType.ts @@ -0,0 +1,148 @@ +import { copyTextToClipboard } from "../../../helpers/clipboard"; +import { attachClickEvent, toggleDisability } from "../../../helpers/dom"; +import { randomLong } from "../../../helpers/random"; +import { Chat, ChatFull, ExportedChatInvite } from "../../../layer"; +import appChatsManager from "../../../lib/appManagers/appChatsManager"; +import appProfileManager from "../../../lib/appManagers/appProfileManager"; +import Button from "../../button"; +import { setButtonLoader } from "../../misc"; +import PopupConfirmAction from "../../popups/confirmAction"; +import RadioField from "../../radioField"; +import Row, { RadioFormFromRows } from "../../row"; +import { SettingSection } from "../../sidebarLeft"; +import { SliderSuperTab } from "../../slider"; +import { toast } from "../../toast"; +import { UsernameInputField } from "../../usernameInputField"; + +export default class AppGroupTypeTab extends SliderSuperTab { + public peerId: number; + public chatFull: ChatFull; + + protected init() { + this.container.classList.add('edit-peer-container', 'group-type-container'); + this.title.innerHTML = 'Group Type'; + + const section = new SettingSection({ + name: 'Group Type' + }); + + const random = randomLong(); + const privateRow = new Row({ + radioField: new RadioField({ + text: 'Private Group', + name: random, + value: 'private' + }), + subtitle: 'Private groups can only be joined if you were invited or have an invite link.' + }); + const publicRow = new Row({ + radioField: new RadioField({ + text: 'Public Group', + name: random, + value: 'public' + }), + subtitle: 'Public groups can be found in search, history is available to everyone and anyone can join.' + }); + const form = RadioFormFromRows([privateRow, publicRow], (value) => { + const a = [privateSection, publicSection]; + if(value === 'public') a.reverse(); + + a[0].container.classList.remove('hide'); + a[1].container.classList.add('hide'); + + onChange(); + }); + + const chat: Chat = appChatsManager.getChat(-this.peerId); + + section.content.append(form); + + const privateSection = new SettingSection({}); + + //let revoked = false; + const linkRow = new Row({ + title: (this.chatFull.exported_invite as ExportedChatInvite.chatInviteExported).link, + subtitle: 'People can join your group by following this link. You can revoke the link at any time.', + clickable: () => { + copyTextToClipboard((this.chatFull.exported_invite as ExportedChatInvite.chatInviteExported).link); + toast('Invite link copied to clipboard.'); + } + }); + + const btnRevoke = Button('btn-primary btn-transparent danger', {icon: 'delete', text: 'Revoke Link'}); + + attachClickEvent(btnRevoke, () => { + new PopupConfirmAction('revoke-link', [{ + text: 'OK', + callback: () => { + toggleDisability([btnRevoke], true); + + appProfileManager.getChatInviteLink(-this.peerId, true).then(link => { + toggleDisability([btnRevoke], false); + linkRow.title.innerHTML = link; + //revoked = true; + //onChange(); + }); + } + }], { + title: 'Revoke Link?', + text: 'Your previous link will be deactivated and we\'ll generate a new invite link for you.' + }).show(); + }, {listenerSetter: this.listenerSetter}); + + privateSection.content.append(linkRow.container, btnRevoke); + + const publicSection = new SettingSection({ + caption: 'People can share this link with others and find your group using Telegram search.', + noDelimiter: true + }); + + const inputWrapper = document.createElement('div'); + inputWrapper.classList.add('input-wrapper'); + + const placeholder = 't.me/'; + + const onChange = () => { + const changed = (privateRow.radioField.checked && (originalValue !== placeholder/* || revoked */)) + || (linkInputField.isValid() && linkInputField.input.classList.contains('valid')); + applyBtn.classList.toggle('is-visible', changed); + }; + + const linkInputField = new UsernameInputField({ + label: 'Link', + name: 'group-public-link', + plainText: true, + listenerSetter: this.listenerSetter, + availableText: 'Link is available', + invalidText: 'Link is invalid', + takenText: 'Link is already taken', + onChange: onChange, + peerId: this.peerId, + head: placeholder + }); + + const originalValue = placeholder + ((chat as Chat.channel).username || ''); + + inputWrapper.append(linkInputField.container) + publicSection.content.append(inputWrapper); + + const applyBtn = Button('btn-circle btn-corner tgico-check is-visible'); + this.content.append(applyBtn); + + attachClickEvent(applyBtn, () => { + const unsetLoader = setButtonLoader(applyBtn); + const username = publicRow.radioField.checked ? linkInputField.getValue() : ''; + appChatsManager.migrateChat(-this.peerId).then(channelId => { + return appChatsManager.updateUsername(channelId, username); + }).then(() => { + unsetLoader(); + this.close(); + }); + }, {listenerSetter: this.listenerSetter}); + + (originalValue !== placeholder ? publicRow : privateRow).radioField.checked = true; + linkInputField.setOriginalValue(originalValue); + + this.scrollable.append(section.container, privateSection.container, publicSection.container); + } +} diff --git a/src/components/usernameInputField.ts b/src/components/usernameInputField.ts new file mode 100644 index 00000000..a2d0e8bc --- /dev/null +++ b/src/components/usernameInputField.ts @@ -0,0 +1,98 @@ +import ListenerSetter from "../helpers/listenerSetter"; +import { debounce } from "../helpers/schedulers"; +import appChatsManager from "../lib/appManagers/appChatsManager"; +import apiManager from "../lib/mtproto/mtprotoworker"; +import RichTextProcessor from "../lib/richtextprocessor"; +import InputField, { InputFieldOptions, InputState } from "./inputField"; + +export class UsernameInputField extends InputField { + private checkUsernamePromise: Promise; + private checkUsernameDebounced: (username: string) => void; + public options: InputFieldOptions & { + peerId: number, + listenerSetter: ListenerSetter, + onChange?: () => void, + invalidText: string, + takenText: string, + availableText: string, + head?: string + }; + + constructor(options: UsernameInputField['options']) { + super(options); + + this.checkUsernameDebounced = debounce(this.checkUsername.bind(this), 150, false, true); + + options.listenerSetter.add(this.input, 'input', () => { + const value = this.getValue(); + + //console.log('userNameInput:', value); + if(value === this.originalValue || !value.length) { + this.setState(InputState.Neutral, this.options.label); + this.options.onChange && this.options.onChange(); + return; + } else if(!RichTextProcessor.isUsernameValid(value)) { // does not check the last underscore + this.setError(this.options.invalidText); + } else { + this.setState(InputState.Neutral); + } + + if(this.input.classList.contains('error')) { + this.options.onChange && this.options.onChange(); + return; + } + + this.checkUsernameDebounced(value); + }); + } + + public getValue() { + let value = this.value; + if(this.options.head) { + value = value.slice(this.options.head.length); + this.setValueSilently(this.options.head + value); + } + + return value; + } + + private checkUsername(username: string) { + if(this.checkUsernamePromise) return; + + if(this.options.peerId) { + this.checkUsernamePromise = apiManager.invokeApi('channels.checkUsername', { + channel: appChatsManager.getChannelInput(-this.options.peerId), + username + }); + } else { + this.checkUsernamePromise = apiManager.invokeApi('account.checkUsername', {username}); + } + + this.checkUsernamePromise.then(available => { + if(this.getValue() !== username) return; + + if(available) { + this.setState(InputState.Valid, this.options.availableText); + } else { + this.setError(this.options.takenText); + } + }, (err) => { + if(this.getValue() !== username) return; + + switch(err.type) { + case 'USERNAME_INVALID': { + this.setError(this.options.invalidText); + break; + } + } + }).then(() => { + this.checkUsernamePromise = undefined; + this.options.onChange && this.options.onChange(); + + const value = this.getValue(); + if(value !== username && this.isValid() && RichTextProcessor.isUsernameValid(value)) { + this.checkUsername(value); + } + }); + }; +} diff --git a/src/lib/appManagers/appChatsManager.ts b/src/lib/appManagers/appChatsManager.ts index a1757392..74e381c1 100644 --- a/src/lib/appManagers/appChatsManager.ts +++ b/src/lib/appManagers/appChatsManager.ts @@ -1,7 +1,7 @@ import { MOUNT_CLASS_TO } from "../../config/debug"; import { numberThousandSplitter } from "../../helpers/number"; import { isObject, safeReplaceObject, copy } from "../../helpers/object"; -import { Chat, ChatAdminRights, ChatBannedRights, ChatFull, ChatParticipants, InputChannel, InputChatPhoto, InputFile, InputPeer, SendMessageAction, Updates } from "../../layer"; +import { Chat, ChatAdminRights, ChatBannedRights, ChatFull, ChatParticipants, InputChannel, InputChatPhoto, InputFile, InputPeer, SendMessageAction, Update, Updates } from "../../layer"; import apiManager from '../mtproto/mtprotoworker'; import { RichTextProcessor } from "../richtextprocessor"; import rootScope from "../rootScope"; @@ -276,11 +276,18 @@ export class AppChatsManager { public getChannelInput(id: number): InputChannel { if(id < 0) id = -id; - return { - _: 'inputChannel', - channel_id: id, - access_hash: this.getChat(id).access_hash/* || this.channelAccess[id] */ || 0 - }; + const chat: Chat = this.getChat(id); + if(chat._ === 'chatEmpty' || !(chat as Chat.channel).access_hash) { + return { + _: 'inputChannelEmpty' + }; + } else { + return { + _: 'inputChannel', + channel_id: id, + access_hash: (chat as Chat.channel).access_hash/* || this.channelAccess[id] */ || '0' + }; + } } public getChatInputPeer(id: number): InputPeer.inputPeerChat { @@ -519,10 +526,30 @@ export class AppChatsManager { }).then(this.onChatUpdated.bind(this, id)); } - public migrateChat(id: number) { + public migrateChat(id: number): Promise { + const chat: Chat = this.getChat(id); + if(chat._ === 'channel') return Promise.resolve(chat.id); return apiManager.invokeApi('messages.migrateChat', { chat_id: id - }).then(this.onChatUpdated.bind(this, id)); + }).then((updates) => { + this.onChatUpdated(id, updates); + const update: Update.updateChannel = (updates as Updates.updates).updates.find(u => u._ === 'updateChannel') as any; + return update.channel_id; + }); + } + + public updateUsername(id: number, username: string) { + return apiManager.invokeApi('channels.updateUsername', { + channel: this.getChannelInput(id), + username + }).then((bool) => { + if(bool) { + const chat: Chat.channel = this.getChat(id); + chat.username = username; + } + + return bool; + }); } public editPhoto(id: number, inputFile: InputFile) { diff --git a/src/lib/appManagers/appUsersManager.ts b/src/lib/appManagers/appUsersManager.ts index 664d6452..4c38ed03 100644 --- a/src/lib/appManagers/appUsersManager.ts +++ b/src/lib/appManagers/appUsersManager.ts @@ -737,6 +737,14 @@ export class AppUsersManager { } } + public updateUsername(username: string) { + return apiManager.invokeApi('account.updateUsername', { + username + }).then((user) => { + this.saveApiUser(user); + }); + } + public setUserStatus(userId: number, offline: boolean) { if(this.isBot(userId)) { return; diff --git a/src/lib/richtextprocessor.ts b/src/lib/richtextprocessor.ts index 89430732..c3273a14 100644 --- a/src/lib/richtextprocessor.ts +++ b/src/lib/richtextprocessor.ts @@ -741,6 +741,10 @@ namespace RichTextProcessor { return wrapEmojiText(first + last); } + + export function isUsernameValid(username: string) { + return ((username.length >= 5 && username.length <= 32) || !username.length) && /^[a-zA-Z0-9_]*$/.test(username); + } } MOUNT_CLASS_TO && (MOUNT_CLASS_TO.RichTextProcessor = RichTextProcessor); diff --git a/src/scss/partials/_rightSidebar.scss b/src/scss/partials/_rightSidebar.scss index b7405b32..5e555218 100644 --- a/src/scss/partials/_rightSidebar.scss +++ b/src/scss/partials/_rightSidebar.scss @@ -813,3 +813,15 @@ line-height: 1.3125; } } + +.group-type-container { + .sidebar-left-section-caption { + font-size: .875rem; + line-height: 1rem; + margin-top: .8125rem; + } + + .input-wrapper { + margin-top: .875rem; + } +} diff --git a/src/scss/style.scss b/src/scss/style.scss index 2bcbcd8e..2dfbe0da 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -1157,6 +1157,11 @@ middle-ellipsis-element { &-subtitle { color: var(--color-text-secondary) !important; font-size: .875rem !important; + + // * lol + line-height: 1rem; + margin-top: .1875rem; + margin-bottom: .125rem; } }