diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 96 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/LinkDecorator.ts | 13 | ||||
-rw-r--r-- | src/view/com/modals/ChangeEmail.tsx | 280 | ||||
-rw-r--r-- | src/view/com/modals/Confirm.tsx | 3 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 8 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/VerifyEmail.tsx | 296 | ||||
-rw-r--r-- | src/view/com/notifications/FeedItem.tsx | 55 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 3 | ||||
-rw-r--r-- | src/view/com/util/forms/Button.tsx | 14 | ||||
-rw-r--r-- | src/view/com/util/images/AutoSizedImage.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/RepostButton.web.tsx | 91 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/index.tsx | 9 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 5 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 83 | ||||
-rw-r--r-- | src/view/shell/desktop/Feeds.tsx | 5 | ||||
-rw-r--r-- | src/view/shell/desktop/LeftNav.tsx | 23 | ||||
-rw-r--r-- | src/view/shell/desktop/RightNav.tsx | 4 |
18 files changed, 853 insertions, 145 deletions
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 7eea904ab..31e372567 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -17,6 +17,7 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' +import {generateJSON} from '@tiptap/html' export interface TextInputRef { focus: () => void @@ -52,6 +53,26 @@ export const TextInput = React.forwardRef(function TextInputImpl( ref, ) { const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') + const extensions = React.useMemo( + () => [ + Document, + LinkDecorator, + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: createSuggestion({autocompleteView}), + }), + Paragraph, + Placeholder.configure({ + placeholder, + }), + Text, + History, + Hardbreak, + ], + [autocompleteView, placeholder], + ) React.useEffect(() => { textInputWebEmitter.addListener('publish', onPressPublish) @@ -68,23 +89,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( const editor = useEditor( { - extensions: [ - Document, - LinkDecorator, - Mention.configure({ - HTMLAttributes: { - class: 'mention', - }, - suggestion: createSuggestion({autocompleteView}), - }), - Paragraph, - Placeholder.configure({ - placeholder, - }), - Text, - History, - Hardbreak, - ], + extensions, editorProps: { attributes: { class: modeClass, @@ -107,7 +112,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, }, - content: textToEditorJson(richtext.text.toString()), + content: generateJSON(richtext.text.toString(), extensions), autofocus: 'end', editable: true, injectCSS: true, @@ -182,61 +187,6 @@ function editorJsonToText(json: JSONContent): string { return text } -function textToEditorJson(text: string): JSONContent { - if (text === '' || text.length === 0) { - return { - text: '', - } - } - - const lines = text.split('\n') - const docContent: JSONContent[] = [] - - for (const line of lines) { - if (line.trim() === '') { - continue // skip empty lines - } - - const paragraphContent: JSONContent[] = [] - let position = 0 - - while (position < line.length) { - if (line[position] === '@') { - // Handle mentions - let endPosition = position + 1 - while (endPosition < line.length && /\S/.test(line[endPosition])) { - endPosition++ - } - const mentionId = line.substring(position + 1, endPosition) - paragraphContent.push({ - type: 'mention', - attrs: {id: mentionId}, - }) - position = endPosition - } else { - // Handle regular text - let endPosition = line.indexOf('@', position) - if (endPosition === -1) endPosition = line.length - paragraphContent.push({ - type: 'text', - text: line.substring(position, endPosition), - }) - position = endPosition - } - } - - docContent.push({ - type: 'paragraph', - content: paragraphContent, - }) - } - - return { - type: 'doc', - content: docContent, - } -} - const styles = StyleSheet.create({ container: { flex: 1, diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts index 531e8d5a0..19945de08 100644 --- a/src/view/com/composer/text-input/web/LinkDecorator.ts +++ b/src/view/com/composer/text-input/web/LinkDecorator.ts @@ -16,7 +16,6 @@ import {Mark} from '@tiptap/core' import {Plugin, PluginKey} from '@tiptap/pm/state' -import {findChildren} from '@tiptap/core' import {Node as ProsemirrorNode} from '@tiptap/pm/model' import {Decoration, DecorationSet} from '@tiptap/pm/view' import {isValidDomain} from 'lib/strings/url-helpers' @@ -36,20 +35,20 @@ export const LinkDecorator = Mark.create({ function getDecorations(doc: ProsemirrorNode) { const decorations: Decoration[] = [] - findChildren(doc, node => node.type.name === 'paragraph').forEach( - paragraphNode => { - const textContent = paragraphNode.node.textContent + doc.descendants((node, pos) => { + if (node.isText && node.text) { + const textContent = node.textContent // links iterateUris(textContent, (from, to) => { decorations.push( - Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { + Decoration.inline(pos + from, pos + to, { class: 'autolink', }), ) }) - }, - ) + } + }) return DecorationSet.create(doc, decorations) } diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx new file mode 100644 index 000000000..c92dabdca --- /dev/null +++ b/src/view/com/modals/ChangeEmail.tsx @@ -0,0 +1,280 @@ +import React, {useState} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + SafeAreaView, + StyleSheet, + View, +} from 'react-native' +import {ScrollView, TextInput} from './util' +import {observer} from 'mobx-react-lite' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ErrorMessage} from '../util/error/ErrorMessage' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError} from 'lib/strings/errors' + +enum Stages { + InputEmail, + ConfirmCode, + Done, +} + +export const snapPoints = ['90%'] + +export const Component = observer(function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + const [stage, setStage] = useState<Stages>(Stages.InputEmail) + const [email, setEmail] = useState<string>( + store.session.currentSession?.email || '', + ) + const [confirmationCode, setConfirmationCode] = useState<string>('') + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [error, setError] = useState<string>('') + const {isMobile} = useWebMediaQueries() + + const onRequestChange = async () => { + if (email === store.session.currentSession?.email) { + setError('Enter your new email above') + return + } + setError('') + setIsProcessing(true) + try { + const res = await store.agent.com.atproto.server.requestEmailUpdate() + if (res.data.tokenRequired) { + setStage(Stages.ConfirmCode) + } else { + await store.agent.com.atproto.server.updateEmail({email: email.trim()}) + store.session.updateLocalAccountData({ + email: email.trim(), + emailConfirmed: false, + }) + Toast.show('Email updated') + setStage(Stages.Done) + } + } catch (e) { + let err = cleanError(String(e)) + // TEMP + // while rollout is occuring, we're giving a temporary error message + // you can remove this any time after Oct2023 + // -prf + if (err === 'email must be confirmed (temporary)') { + err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.` + } + setError(err) + } finally { + setIsProcessing(false) + } + } + + const onConfirm = async () => { + setError('') + setIsProcessing(true) + try { + await store.agent.com.atproto.server.updateEmail({ + email: email.trim(), + token: confirmationCode.trim(), + }) + store.session.updateLocalAccountData({ + email: email.trim(), + emailConfirmed: false, + }) + Toast.show('Email updated') + setStage(Stages.Done) + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onVerify = async () => { + store.shell.closeModal() + store.shell.openModal({name: 'verify-email'}) + } + + return ( + <KeyboardAvoidingView + behavior="padding" + style={[pal.view, styles.container]}> + <SafeAreaView style={s.flex1}> + <ScrollView + testID="changeEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.InputEmail ? 'Change Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} + {stage === Stages.Done ? 'Email Updated' : ''} + </Text> + </View> + + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.InputEmail ? ( + <>Enter your new email address below.</> + ) : stage === Stages.ConfirmCode ? ( + <> + An email has been sent to your previous address,{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + <> + Your email has been updated but not verified. As a next step, + please verify your new email. + </> + )} + </Text> + + {stage === Stages.InputEmail && ( + <TextInput + testID="emailInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="alice@mail.com" + placeholderTextColor={pal.colors.textLight} + value={email} + onChangeText={setEmail} + accessible={true} + accessibilityLabel="Email" + accessibilityHint="" + autoCapitalize="none" + autoComplete="email" + autoCorrect={false} + /> + )} + {stage === Stages.ConfirmCode && ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + )} + + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.InputEmail && ( + <Button + testID="requestChangeBtn" + type="primary" + onPress={onRequestChange} + accessibilityLabel="Request Change" + accessibilityHint="" + label="Request Change" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.ConfirmCode && ( + <Button + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm Change" + accessibilityHint="" + label="Confirm Change" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.Done && ( + <Button + testID="verifyBtn" + type="primary" + onPress={onVerify} + accessibilityLabel="Verify New Email" + accessibilityHint="" + label="Verify New Email" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel="Cancel" + accessibilityHint="" + label="Cancel" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> + </KeyboardAvoidingView> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isWeb ? 0 : 40, + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + error: { + borderRadius: 6, + marginTop: 10, + }, + emailContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 12, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, +}) diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 270177182..c1324b1cb 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -23,6 +23,7 @@ export function Component({ onPressCancel, confirmBtnText, confirmBtnStyle, + cancelBtnText, }: ConfirmModal) { const pal = usePalette('default') const store = useStores() @@ -84,7 +85,7 @@ export function Component({ accessibilityLabel="Cancel" accessibilityHint=""> <Text type="button-lg" style={pal.textLight}> - Cancel + {cancelBtnText ?? 'Cancel'} </Text> </TouchableOpacity> )} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index d79c77db3..8590a2698 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -30,6 +30,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' +import * as VerifyEmailModal from './VerifyEmail' +import * as ChangeEmailModal from './ChangeEmail' const DEFAULT_SNAPPOINTS = ['90%'] @@ -136,6 +138,12 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'birth-date-settings') { snapPoints = BirthDateSettingsModal.snapPoints element = <BirthDateSettingsModal.Component /> + } else if (activeModal?.name === 'verify-email') { + snapPoints = VerifyEmailModal.snapPoints + element = <VerifyEmailModal.Component {...activeModal} /> + } else if (activeModal?.name === 'change-email') { + snapPoints = ChangeEmailModal.snapPoints + element = <ChangeEmailModal.Component /> } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3e87e0e3c..7548fb806 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -28,6 +28,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as ModerationDetailsModal from './ModerationDetails' import * as BirthDateSettingsModal from './BirthDateSettings' +import * as VerifyEmailModal from './VerifyEmail' +import * as ChangeEmailModal from './ChangeEmail' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -110,6 +112,10 @@ function Modal({modal}: {modal: ModalIface}) { element = <ModerationDetailsModal.Component {...modal} /> } else if (modal.name === 'birth-date-settings') { element = <BirthDateSettingsModal.Component /> + } else if (modal.name === 'verify-email') { + element = <VerifyEmailModal.Component {...modal} /> + } else if (modal.name === 'change-email') { + element = <ChangeEmailModal.Component /> } else { return null } diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx new file mode 100644 index 000000000..1b4ddcda4 --- /dev/null +++ b/src/view/com/modals/VerifyEmail.tsx @@ -0,0 +1,296 @@ +import React, {useState} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + Pressable, + SafeAreaView, + StyleSheet, + View, +} from 'react-native' +import {ScrollView, TextInput} from './util' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ErrorMessage} from '../util/error/ErrorMessage' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError} from 'lib/strings/errors' + +export const snapPoints = ['90%'] + +enum Stages { + Reminder, + Email, + ConfirmCode, +} + +export const Component = observer(function Component({ + showReminder, +}: { + showReminder?: boolean +}) { + const pal = usePalette('default') + const store = useStores() + const [stage, setStage] = useState<Stages>( + showReminder ? Stages.Reminder : Stages.Email, + ) + const [confirmationCode, setConfirmationCode] = useState<string>('') + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [error, setError] = useState<string>('') + const {isMobile} = useWebMediaQueries() + + const onSendEmail = async () => { + setError('') + setIsProcessing(true) + try { + await store.agent.com.atproto.server.requestEmailConfirmation() + setStage(Stages.ConfirmCode) + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onConfirm = async () => { + setError('') + setIsProcessing(true) + try { + await store.agent.com.atproto.server.confirmEmail({ + email: (store.session.currentSession?.email || '').trim(), + token: confirmationCode.trim(), + }) + store.session.updateLocalAccountData({emailConfirmed: true}) + Toast.show('Email verified') + store.shell.closeModal() + } catch (e) { + setError(cleanError(String(e))) + } finally { + setIsProcessing(false) + } + } + + const onEmailIncorrect = () => { + store.shell.closeModal() + store.shell.openModal({name: 'change-email'}) + } + + return ( + <KeyboardAvoidingView + behavior="padding" + style={[pal.view, styles.container]}> + <SafeAreaView style={s.flex1}> + <ScrollView + testID="verifyEmailModal" + style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + {stage === Stages.Reminder ? 'Please Verify Your Email' : ''} + {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''} + {stage === Stages.Email ? 'Verify Your Email' : ''} + </Text> + </View> + + <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> + {stage === Stages.Reminder ? ( + <> + Your email has not yet been verified. This is an important + security step which we recommend. + </> + ) : stage === Stages.Email ? ( + <> + This is important in case you ever need to change your email or + reset your password. + </> + ) : stage === Stages.ConfirmCode ? ( + <> + An email has been sent to{' '} + {store.session.currentSession?.email || ''}. It includes a + confirmation code which you can enter below. + </> + ) : ( + '' + )} + </Text> + + {stage === Stages.Email ? ( + <> + <View style={styles.emailContainer}> + <FontAwesomeIcon + icon="envelope" + color={pal.colors.text} + size={16} + /> + <Text + type="xl-medium" + style={[pal.text, s.flex1, {minWidth: 0}]}> + {store.session.currentSession?.email || ''} + </Text> + </View> + <Pressable + accessibilityRole="link" + accessibilityLabel="Change my email" + accessibilityHint="" + onPress={onEmailIncorrect} + style={styles.changeEmailLink}> + <Text type="lg" style={pal.link}> + Change + </Text> + </Pressable> + </> + ) : stage === Stages.ConfirmCode ? ( + <TextInput + testID="confirmCodeInput" + style={[styles.textInput, pal.border, pal.text]} + placeholder="XXXXX-XXXXX" + placeholderTextColor={pal.colors.textLight} + value={confirmationCode} + onChangeText={setConfirmationCode} + accessible={true} + accessibilityLabel="Confirmation code" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + ) : undefined} + + {error ? ( + <ErrorMessage message={error} style={styles.error} /> + ) : undefined} + + <View style={[styles.btnContainer]}> + {isProcessing ? ( + <View style={styles.btn}> + <ActivityIndicator color="#fff" /> + </View> + ) : ( + <View style={{gap: 6}}> + {stage === Stages.Reminder && ( + <Button + testID="getStartedBtn" + type="primary" + onPress={() => setStage(Stages.Email)} + accessibilityLabel="Get Started" + accessibilityHint="" + label="Get Started" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + {stage === Stages.Email && ( + <> + <Button + testID="sendEmailBtn" + type="primary" + onPress={onSendEmail} + accessibilityLabel="Send Confirmation Email" + accessibilityHint="" + label="Send Confirmation Email" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} + labelStyle={[s.f18]} + /> + <Button + testID="haveCodeBtn" + type="default" + accessibilityLabel="I have a code" + accessibilityHint="" + label="I have a confirmation code" + labelContainerStyle={{ + justifyContent: 'center', + padding: 4, + }} + labelStyle={[s.f18]} + onPress={() => setStage(Stages.ConfirmCode)} + /> + </> + )} + {stage === Stages.ConfirmCode && ( + <Button + testID="confirmBtn" + type="primary" + onPress={onConfirm} + accessibilityLabel="Confirm" + accessibilityHint="" + label="Confirm" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + )} + <Button + testID="cancelBtn" + type="default" + onPress={() => store.shell.closeModal()} + accessibilityLabel={ + stage === Stages.Reminder ? 'Not right now' : 'Cancel' + } + accessibilityHint="" + label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'} + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + )} + </View> + </ScrollView> + </SafeAreaView> + </KeyboardAvoidingView> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: isWeb ? 0 : 40, + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + error: { + borderRadius: 6, + marginTop: 10, + }, + emailContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 14, + marginTop: 10, + }, + changeEmailLink: { + marginHorizontal: 12, + marginBottom: 12, + }, + textInput: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 16, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, +}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 00e56e1cc..c51335c72 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -22,7 +22,7 @@ import { import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' import {PostThreadModel} from 'state/models/content/post-thread' import {s, colors} from 'lib/styles' -import {ago} from 'lib/strings/time' +import {niceDate} from 'lib/strings/time' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {pluralize} from 'lib/strings/helpers' @@ -38,6 +38,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {formatCount} from '../util/numeric/format' import {makeProfileLink} from 'lib/routes/links' +import {TimeElapsed} from '../util/TimeElapsed' const MAX_AUTHORS = 5 @@ -88,7 +89,7 @@ export const FeedItem = observer(function FeedItemImpl({ }, [item]) const onToggleAuthorsExpanded = () => { - setAuthorsExpanded(!isAuthorsExpanded) + setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) } const authors: Author[] = useMemo(() => { @@ -179,7 +180,6 @@ export const FeedItem = observer(function FeedItemImpl({ } return ( - // eslint-disable-next-line react-native-a11y/no-nested-touchables <Link testID={`feedItem-by-${item.author.handle}`} style={[ @@ -211,9 +211,9 @@ export const FeedItem = observer(function FeedItemImpl({ )} </View> <View style={styles.layoutContent}> - <Pressable - onPress={authors.length > 1 ? onToggleAuthorsExpanded : undefined} - accessible={false}> + <ExpandListPressable + hasMultipleAuthors={authors.length > 1} + onToggleAuthorsExpanded={onToggleAuthorsExpanded}> <CondensedAuthorsList visible={!isAuthorsExpanded} authors={authors} @@ -239,9 +239,17 @@ export const FeedItem = observer(function FeedItemImpl({ </> ) : undefined} <Text style={[pal.text]}> {action}</Text> - <Text style={[pal.textLight]}> {ago(item.indexedAt)}</Text> + <TimeElapsed timestamp={item.indexedAt}> + {({timeElapsed}) => ( + <Text + style={[pal.textLight, styles.pointer]} + title={niceDate(item.indexedAt)}> + {' ' + timeElapsed} + </Text> + )} + </TimeElapsed> </Text> - </Pressable> + </ExpandListPressable> {item.isLike || item.isRepost || item.isQuote ? ( <AdditionalPostText additionalPost={item.additionalPost} /> ) : null} @@ -250,6 +258,29 @@ export const FeedItem = observer(function FeedItemImpl({ ) }) +function ExpandListPressable({ + hasMultipleAuthors, + children, + onToggleAuthorsExpanded, +}: { + hasMultipleAuthors: boolean + children: React.ReactNode + onToggleAuthorsExpanded: () => void +}) { + if (hasMultipleAuthors) { + return ( + <Pressable + onPress={onToggleAuthorsExpanded} + style={[styles.expandedAuthorsTrigger]} + accessible={false}> + {children} + </Pressable> + ) + } else { + return <>{children}</> + } +} + function CondensedAuthorsList({ visible, authors, @@ -419,6 +450,10 @@ const styles = StyleSheet.create({ overflowHidden: { overflow: 'hidden', }, + pointer: { + // @ts-ignore web only + cursor: 'pointer', + }, outer: { padding: 10, @@ -466,7 +501,9 @@ const styles = StyleSheet.create({ paddingTop: 4, paddingLeft: 36, }, - + expandedAuthorsTrigger: { + zIndex: 1, + }, expandedAuthorsCloseBtn: { flexDirection: 'row', alignItems: 'center', diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index f6b6e5339..1ceae80ae 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -178,7 +178,7 @@ export const FeedItem = observer(function FeedItemImpl({ )} </View> - <View style={{paddingTop: 12}}> + <View style={{paddingTop: 12, flexShrink: 1}}> {source ? ( <Link title={sanitizeDisplayName(source.displayName)} @@ -211,6 +211,7 @@ export const FeedItem = observer(function FeedItemImpl({ style={{ marginRight: 4, color: pal.colors.textLight, + minWidth: 16, }} /> <Text diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 076fa1baa..270d98317 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -42,6 +42,7 @@ export function Button({ type = 'primary', label, style, + labelContainerStyle, labelStyle, onPress, children, @@ -55,6 +56,7 @@ export function Button({ type?: ButtonType label?: string style?: StyleProp<ViewStyle> + labelContainerStyle?: StyleProp<ViewStyle> labelStyle?: StyleProp<TextStyle> onPress?: () => void | Promise<void> testID?: string @@ -173,7 +175,7 @@ export function Button({ } return ( - <View style={styles.labelContainer}> + <View style={[styles.labelContainer, labelContainerStyle]}> {label && withLoading && isLoading ? ( <ActivityIndicator size={12} color={typeLabelStyle.color} /> ) : null} @@ -182,7 +184,15 @@ export function Button({ </Text> </View> ) - }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle]) + }, [ + children, + label, + withLoading, + isLoading, + labelContainerStyle, + typeLabelStyle, + labelStyle, + ]) return ( <Pressable diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index da2f7ab45..035e29c25 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -11,6 +11,7 @@ const MAX_ASPECT_RATIO = 5 // 5/1 interface Props { alt?: string uri: string + dimensionsHint?: Dimensions onPress?: () => void onLongPress?: () => void onPressIn?: () => void @@ -21,6 +22,7 @@ interface Props { export function AutoSizedImage({ alt, uri, + dimensionsHint, onPress, onLongPress, onPressIn, @@ -29,7 +31,7 @@ export function AutoSizedImage({ }: Props) { const store = useStores() const [dim, setDim] = React.useState<Dimensions | undefined>( - store.imageSizes.get(uri), + dimensionsHint || store.imageSizes.get(uri), ) const [aspectRatio, setAspectRatio] = React.useState<number>( dim ? calc(dim) : 1, diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index eab6e2fef..57f544d41 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -1,17 +1,23 @@ -import React, {useMemo} from 'react' +import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {RepostIcon} from 'lib/icons' -import {DropdownButton} from '../forms/DropdownButton' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {Text} from '../text/Text' +import { + NativeDropdown, + DropdownItem as NativeDropdownItem, +} from '../forms/NativeDropdown' +import {EventStopper} from '../EventStopper' + interface Props { isReposted: boolean repostCount?: number big?: boolean onRepost: () => void onQuote: () => void + style?: StyleProp<ViewStyle> } export const RepostButton = ({ @@ -30,44 +36,55 @@ export const RepostButton = ({ [theme], ) - const items = useMemo( - () => [ - { - label: isReposted ? 'Undo repost' : 'Repost', - icon: 'retweet' as const, - onPress: onRepost, + const dropdownItems: NativeDropdownItem[] = [ + { + label: isReposted ? 'Undo repost' : 'Repost', + testID: 'repostDropdownRepostBtn', + icon: { + ios: {name: 'repeat'}, + android: '', + web: 'retweet', }, - {label: 'Quote post', icon: 'quote-left' as const, onPress: onQuote}, - ], - [isReposted, onRepost, onQuote], - ) + onPress: onRepost, + }, + { + label: 'Quote post', + testID: 'repostDropdownQuoteBtn', + icon: { + ios: {name: 'quote.bubble'}, + android: '', + web: 'quote-left', + }, + onPress: onQuote, + }, + ] return ( - <DropdownButton - type="bare" - items={items} - bottomOffset={4} - openToRight - rightOffset={-40}> - <View - style={[ - styles.control, - !big && styles.controlPad, - (isReposted - ? styles.reposted - : defaultControlColor) as StyleProp<ViewStyle>, - ]}> - <RepostIcon strokeWidth={2.4} size={big ? 24 : 20} /> - {typeof repostCount !== 'undefined' ? ( - <Text - testID="repostCount" - type={isReposted ? 'md-bold' : 'md'} - style={styles.repostCount}> - {repostCount ?? 0} - </Text> - ) : undefined} - </View> - </DropdownButton> + <EventStopper> + <NativeDropdown + items={dropdownItems} + accessibilityLabel="Repost or quote post" + accessibilityHint=""> + <View + style={[ + styles.control, + !big && styles.controlPad, + (isReposted + ? styles.reposted + : defaultControlColor) as StyleProp<ViewStyle>, + ]}> + <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} /> + {typeof repostCount !== 'undefined' ? ( + <Text + testID="repostCount" + type={isReposted ? 'md-bold' : 'md'} + style={styles.repostCount}> + {repostCount ?? 0} + </Text> + ) : undefined} + </View> + </NativeDropdown> + </EventStopper> ) } diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index ce6da4a1b..2d79eed8f 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -93,7 +93,11 @@ export function PostEmbeds({ const {images} = embed if (images.length > 0) { - const items = embed.images.map(img => ({uri: img.fullsize, alt: img.alt})) + const items = embed.images.map(img => ({ + uri: img.fullsize, + alt: img.alt, + aspectRatio: img.aspectRatio, + })) const openLightbox = (index: number) => { store.shell.openLightbox(new ImagesLightbox(items, index)) } @@ -104,12 +108,13 @@ export function PostEmbeds({ } if (images.length === 1) { - const {alt, thumb} = images[0] + const {alt, thumb, aspectRatio} = images[0] return ( <View style={[styles.imagesContainer, style]}> <AutoSizedImage alt={alt} uri={thumb} + dimensionsHint={aspectRatio} onPress={() => openLightbox(0)} onPressIn={() => onPressIn(0)} style={[ diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index efcb588f6..596bda57e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -91,7 +91,10 @@ export const ProfileScreen = withAuthRequired( const onPressCompose = React.useCallback(() => { track('ProfileScreen:PressCompose') const mention = - uiState.profile.handle === store.me.handle ? '' : uiState.profile.handle + uiState.profile.handle === store.me.handle || + uiState.profile.handle === 'handle.invalid' + ? undefined + : uiState.profile.handle store.shell.openComposer({mention}) }, [store, track, uiState]) const onSelectView = React.useCallback( diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 7b17b5347..66b2b8fbb 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -219,10 +219,25 @@ export const SettingsScreen = withAuthRequired( <View style={[styles.infoLine]}> <Text type="lg-medium" style={pal.text}> Email:{' '} - <Text type="lg" style={pal.text}> - {store.session.currentSession?.email} - </Text> </Text> + {!store.session.emailNeedsConfirmation && ( + <> + <FontAwesomeIcon + icon="check" + size={10} + style={{color: colors.green3, marginRight: 2}} + /> + </> + )} + <Text type="lg" style={pal.text}> + {store.session.currentSession?.email}{' '} + </Text> + <Link + onPress={() => store.shell.openModal({name: 'change-email'})}> + <Text type="lg" style={pal.link}> + Change + </Text> + </Link> </View> <View style={[styles.infoLine]}> <Text type="lg-medium" style={pal.text}> @@ -238,6 +253,7 @@ export const SettingsScreen = withAuthRequired( </Link> </View> <View style={styles.spacer20} /> + <EmailConfirmationNotice /> </> ) : null} <View style={[s.flexRow, styles.heading]}> @@ -665,6 +681,67 @@ function AccountDropdownBtn({handle}: {handle: string}) { ) } +const EmailConfirmationNotice = observer( + function EmailConfirmationNoticeImpl() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const {isMobile} = useWebMediaQueries() + + if (!store.session.emailNeedsConfirmation) { + return null + } + + return ( + <View style={{marginBottom: 20}}> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + Verify email + </Text> + <View + style={[ + { + paddingVertical: isMobile ? 12 : 0, + paddingHorizontal: 18, + }, + pal.view, + ]}> + <View style={{flexDirection: 'row', marginBottom: 8}}> + <Pressable + style={[ + palInverted.view, + { + flexDirection: 'row', + gap: 6, + borderRadius: 6, + paddingHorizontal: 12, + paddingVertical: 10, + alignItems: 'center', + }, + isMobile && {flex: 1}, + ]} + accessibilityRole="button" + accessibilityLabel="Verify my email" + accessibilityHint="" + onPress={() => store.shell.openModal({name: 'verify-email'})}> + <FontAwesomeIcon + icon="envelope" + color={palInverted.colors.text} + size={16} + /> + <Text type="button" style={palInverted.text}> + Verify My Email + </Text> + </Pressable> + </View> + <Text style={pal.textLight}> + Protect your account by verifying your email. + </Text> + </View> + </View> + ) + }, +) + const styles = StyleSheet.create({ dimmed: { opacity: 0.5, diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 4da1401c3..3f2063887 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -82,11 +82,12 @@ function FeedItem({ const styles = StyleSheet.create({ container: { - position: 'relative', + flex: 1, + overflowY: 'auto', width: 300, paddingHorizontal: 12, + paddingVertical: 18, borderTopWidth: 1, borderBottomWidth: 1, - paddingVertical: 18, }, }) diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 2f7a9ef82..b1065f03c 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -185,20 +185,33 @@ function ComposeBtn() { const {getState} = useNavigation() const {isTablet} = useWebMediaQueries() - const getProfileHandle = () => { + const getProfileHandle = async () => { const {routes} = getState() const currentRoute = routes[routes.length - 1] + if (currentRoute.name === 'Profile') { - const {name: handle} = + let handle: string | undefined = ( currentRoute.params as CommonNavigatorParams['Profile'] - if (handle === store.me.handle) return undefined + ).name + + if (handle.startsWith('did:')) { + const cached = await store.profiles.cache.get(handle) + const profile = cached ? cached.data : undefined + // if we can't resolve handle, set to undefined + handle = profile?.handle || undefined + } + + if (!handle || handle === store.me.handle || handle === 'handle.invalid') + return undefined + return handle } + return undefined } - const onPressCompose = () => - store.shell.openComposer({mention: getProfileHandle()}) + const onPressCompose = async () => + store.shell.openComposer({mention: await getProfileHandle()}) if (isTablet) { return null diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 12ca256d2..84d7d7854 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -123,8 +123,10 @@ const styles = StyleSheet.create({ position: 'absolute', top: 20, // @ts-ignore web only - left: 'calc(50vw + 310px)', + left: 'calc(50vw + 320px)', width: 304, + // @ts-ignore web only + maxHeight: '90vh', }, message: { |