diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 43 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/LinkDecorator.ts | 106 | ||||
-rw-r--r-- | src/view/com/modals/ContentFilteringSettings.tsx | 22 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 5 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 1 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 1 | ||||
-rw-r--r-- | src/view/com/util/Link.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/ViewSelector.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/PreferencesHomeFeed.tsx (renamed from src/view/com/modals/PreferencesHomeFeed.tsx) | 52 | ||||
-rw-r--r-- | src/view/screens/ProfileList.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Search.web.tsx | 20 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 8 | ||||
-rw-r--r-- | src/view/shell/desktop/LeftNav.tsx | 7 |
15 files changed, 216 insertions, 75 deletions
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index dfe1e26a1..395263af8 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,12 +1,11 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {RichText} from '@atproto/api' +import {RichText, AppBskyRichtextFacet} from '@atproto/api' import EventEmitter from 'eventemitter3' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import History from '@tiptap/extension-history' import Hardbreak from '@tiptap/extension-hard-break' -import {Link} from '@tiptap/extension-link' import {Mention} from '@tiptap/extension-mention' import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' @@ -17,6 +16,7 @@ import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' +import {LinkDecorator} from './web/LinkDecorator' export interface TextInputRef { focus: () => void @@ -74,11 +74,7 @@ export const TextInput = React.forwardRef( { extensions: [ Document, - Link.configure({ - protocols: ['http', 'https'], - autolink: true, - linkOnPaste: false, - }), + LinkDecorator, Mention.configure({ HTMLAttributes: { class: 'mention', @@ -128,9 +124,20 @@ export const TextInput = React.forwardRef( newRt.detectFacetsWithoutResolution() setRichText(newRt) - const newSuggestedLinks = new Set(editorJsonToLinks(json)) - if (!isEqual(newSuggestedLinks, suggestedLinks)) { - onSuggestedLinksChanged(newSuggestedLinks) + const set: Set<string> = new Set() + + if (newRt.facets) { + for (const facet of newRt.facets) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature)) { + set.add(feature.uri) + } + } + } + } + + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) } }, }, @@ -237,22 +244,6 @@ function textToEditorJson(text: string): JSONContent { } } -function editorJsonToLinks(json: JSONContent): string[] { - let links: string[] = [] - if (json.content?.length) { - for (const node of json.content) { - links = links.concat(editorJsonToLinks(node)) - } - } - - const link = json.marks?.find(m => m.type === 'link') - if (link?.attrs?.href) { - links.push(link.attrs.href) - } - - return links -} - 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 new file mode 100644 index 000000000..531e8d5a0 --- /dev/null +++ b/src/view/com/composer/text-input/web/LinkDecorator.ts @@ -0,0 +1,106 @@ +/** + * TipTap is a stateful rich-text editor, which is extremely useful + * when you _want_ it to be stateful formatting such as bold and italics. + * + * However we also use "stateless" behaviors, specifically for URLs + * where the text itself drives the formatting. + * + * This plugin uses a regex to detect URIs and then applies + * link decorations (a <span> with the "autolink") class. That avoids + * adding any stateful formatting to TipTap's document model. + * + * We then run the URI detection again when constructing the + * RichText object from TipTap's output and merge their features into + * the facet-set. + */ + +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' + +export const LinkDecorator = Mark.create({ + name: 'link-decorator', + priority: 1000, + keepOnSplit: false, + inclusive() { + return true + }, + addProseMirrorPlugins() { + return [linkDecorator()] + }, +}) + +function getDecorations(doc: ProsemirrorNode) { + const decorations: Decoration[] = [] + + findChildren(doc, node => node.type.name === 'paragraph').forEach( + paragraphNode => { + const textContent = paragraphNode.node.textContent + + // links + iterateUris(textContent, (from, to) => { + decorations.push( + Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { + class: 'autolink', + }), + ) + }) + }, + ) + + return DecorationSet.create(doc, decorations) +} + +function linkDecorator() { + const linkDecoratorPlugin: Plugin = new Plugin({ + key: new PluginKey('link-decorator'), + + state: { + init: (_, {doc}) => getDecorations(doc), + apply: (transaction, decorationSet) => { + if (transaction.docChanged) { + return getDecorations(transaction.doc) + } + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return linkDecoratorPlugin.getState(state) + }, + }, + }) + return linkDecoratorPlugin +} + +function iterateUris(str: string, cb: (from: number, to: number) => void) { + let match + const re = + /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + while ((match = re.exec(str))) { + let uri = match[2] + if (!uri.startsWith('http')) { + const domain = match.groups?.domain + if (!domain || !isValidDomain(domain)) { + continue + } + uri = `https://${uri}` + } + let from = str.indexOf(match[2], match.index) + let to = from + match[2].length + 1 + // strip ending puncuation + if (/[.,;!?]$/.test(uri)) { + uri = uri.slice(0, -1) + to-- + } + if (/[)]$/.test(uri) && !uri.includes('(')) { + uri = uri.slice(0, -1) + to-- + } + cb(from, to) + } +} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 5215c9cb4..f39351feb 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -48,15 +48,17 @@ export const Component = observer(({}: {}) => { <ScrollView style={styles.scrollContainer}> <View style={s.mb10}> {isIOS ? ( - <Text type="md" style={pal.textLight}> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="https://bsky.app" - text="bsky.app" - /> - . - </Text> + store.preferences.adultContentEnabled ? null : ( + <Text type="md" style={pal.textLight}> + Adult content can only be enabled via the Web at{' '} + <TextLink + style={pal.link} + href="https://bsky.app" + text="bsky.app" + /> + . + </Text> + ) ) : ( <ToggleButton type="default-light" @@ -188,7 +190,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) { /> <SelectableBtn current={current} - value="show" + value="ignore" label="Show" right onChange={onChange} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index dd45262be..4a5a7c504 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -28,7 +28,6 @@ import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -130,9 +129,6 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'post-languages-settings') { snapPoints = PostLanguagesSettingsModal.snapPoints element = <PostLanguagesSettingsModal.Component /> - } else if (activeModal?.name === 'preferences-home-feed') { - snapPoints = PreferencesHomeFeed.snapPoints - element = <PreferencesHomeFeed.Component /> } else if (activeModal?.name === 'moderation-details') { snapPoints = ModerationDetailsModal.snapPoints element = <ModerationDetailsModal.Component {...activeModal} /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3aeddeb6b..5cfdd6bb3 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -27,7 +27,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as ModerationDetailsModal from './ModerationDetails' -import * as PreferencesHomeFeed from './PreferencesHomeFeed' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <AltTextImageModal.Component {...modal} /> } else if (modal.name === 'edit-image') { element = <EditImageModal.Component {...modal} /> - } else if (modal.name === 'preferences-home-feed') { - element = <PreferencesHomeFeed.Component /> } else if (modal.name === 'moderation-details') { element = <ModerationDetailsModal.Component {...modal} /> } else { diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 088be6a90..8b556cea3 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -367,6 +367,7 @@ export const PostThreadItem = observer(function PostThreadItem({ pal.border, pal.view, item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, + styles.cursor, ]} moderation={item.moderation.content}> <PostSandboxWarning /> @@ -616,4 +617,8 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 'auto', }, + cursor: { + // @ts-ignore web only + cursor: 'pointer', + }, }) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 94dfe6e8b..661b3a899 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -304,6 +304,7 @@ const styles = StyleSheet.create({ paddingBottom: 5, paddingLeft: 10, borderTopWidth: 1, + cursor: 'pointer', }, layout: { flexDirection: 'row', diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index e1212f32c..c46411f0f 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -343,6 +343,7 @@ const styles = StyleSheet.create({ borderTopWidth: 1, paddingLeft: 10, paddingRight: 15, + cursor: 'pointer', }, outerSmallTop: { borderTopWidth: 0, diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index ead85d0b5..321b6ab63 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -259,15 +259,21 @@ function onPressInner( e?: Event, ) { let shouldHandle = false + const isLeftClick = + // @ts-ignore Web only -prf + Platform.OS === 'web' && (e.button == null || e.button === 0) + // @ts-ignore Web only -prf + const isMiddleClick = Platform.OS === 'web' && e.button === 1 + const isMetaKey = + // @ts-ignore Web only -prf + Platform.OS === 'web' && (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) + const newTab = isMetaKey || isMiddleClick if (Platform.OS !== 'web' || !e) { shouldHandle = e ? !e.defaultPrevented : true } else if ( !e.defaultPrevented && // onPress prevented default - // @ts-ignore Web only -prf - !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys - // @ts-ignore Web only -prf - (e.button == null || e.button === 0) && // ignore everything but left clicks + (isLeftClick || isMiddleClick) && // ignore everything but left and middle clicks // @ts-ignore Web only -prf [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc. ) { @@ -277,7 +283,7 @@ function onPressInner( if (shouldHandle) { href = convertBskyAppUrlIfNeeded(href) - if (href.startsWith('http') || href.startsWith('mailto')) { + if (newTab || href.startsWith('http') || href.startsWith('mailto')) { Linking.openURL(href) } else { store.shell.closeModal() // close any active modals diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index cd3299284..8d2a30506 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -168,6 +168,7 @@ export function Selector({ backgroundColor: pal.colors.background, }}> <ScrollView + testID="selector" horizontal showsHorizontalScrollIndicator={false} style={{position: 'absolute'}}> diff --git a/src/view/com/modals/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 15f7625b5..b04f274f7 100644 --- a/src/view/com/modals/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -1,16 +1,16 @@ import React, {useState} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {Slider} from '@miblanchard/react-native-slider' -import {Text} from '../util/text/Text' +import {Text} from '../com/util/text/Text' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isDesktopWeb} from 'platform/detection' import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {ScrollView} from 'view/com/modals/util' - -export const snapPoints = ['90%'] +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' function RepliesThresholdInput({enabled}: {enabled: boolean}) { const store = useStores() @@ -43,18 +43,25 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { ) } -export const Component = observer(function Component() { +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'PreferencesHomeFeed' +> +export const PreferencesHomeFeed = observer(({navigation}: Props) => { const pal = usePalette('default') const store = useStores() return ( - <View - testID="preferencesHomeFeedModal" - style={[pal.view, styles.container]}> + <CenteredView + testID="preferencesHomeFeedScreen" + style={[ + pal.view, + pal.border, + styles.container, + isDesktopWeb && styles.desktopContainer, + ]}> + <ViewHeader title="Home Feed Preferences" showOnDesktop /> <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - Home Feed Preferences - </Text> <Text type="xl" style={[pal.textLight, styles.description]}> Fine-tune the content you see on your home screen. </Text> @@ -119,27 +126,33 @@ export const Component = observer(function Component() { <TouchableOpacity testID="confirmBtn" onPress={() => { - store.shell.closeModal() + navigation.canGoBack() + ? navigation.goBack() + : navigation.navigate('Settings') }} - style={[styles.btn]} + style={[styles.btn, isDesktopWeb && styles.btnDesktop]} accessibilityRole="button" accessibilityLabel="Confirm" accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}>Done</Text> </TouchableOpacity> </View> - </View> + </CenteredView> ) }) const styles = StyleSheet.create({ container: { flex: 1, - paddingBottom: isDesktopWeb ? 0 : 60, + paddingBottom: isDesktopWeb ? 40 : 90, + }, + desktopContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, }, titleSection: { - padding: 20, paddingBottom: 30, + paddingTop: isDesktopWeb ? 20 : 0, }, title: { textAlign: 'center', @@ -165,9 +178,12 @@ const styles = StyleSheet.create({ padding: 14, backgroundColor: colors.blue3, }, + btnDesktop: { + marginHorizontal: 'auto', + paddingHorizontal: 80, + }, btnContainer: { paddingTop: 20, - paddingHorizontal: 20, borderTopWidth: isDesktopWeb ? 0 : 1, }, dimmed: { diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 651fac21f..3c50fdde0 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -74,7 +74,7 @@ export const ProfileListScreen = withAuthRequired( store.shell.openModal({ name: 'confirm', title: 'Delete List', - message: 'Are you sure', + message: 'Are you sure?', async onPressConfirm() { await list.delete() if (navigation.canGoBack()) { diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index 85e8c212e..3218b4579 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {View, StyleSheet} from 'react-native' import {SearchUIModel} from 'state/models/ui/search' import {FoafsModel} from 'state/models/discovery/foafs' import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' @@ -47,13 +48,28 @@ export const SearchScreen = withAuthRequired( const {isDesktop} = useWebMediaQueries() if (searchUIModel) { - return <SearchResults model={searchUIModel} /> + return ( + <View style={styles.scrollContainer}> + <SearchResults model={searchUIModel} /> + </View> + ) } if (!isDesktop) { - return <Mobile.SearchScreen navigation={navigation} route={route} /> + return ( + <View style={styles.scrollContainer}> + <Mobile.SearchScreen navigation={navigation} route={route} /> + </View> + ) } return <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> }), ) + +const styles = StyleSheet.create({ + scrollContainer: { + height: '100%', + overflowY: 'auto', + }, +}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 4a2c1c16a..481d77086 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -175,10 +175,8 @@ export const SettingsScreen = withAuthRequired( }, []) const openPreferencesModal = React.useCallback(() => { - store.shell.openModal({ - name: 'preferences-home-feed', - }) - }, [store]) + navigation.navigate('PreferencesHomeFeed') + }, [navigation]) const onPressAppPasswords = React.useCallback(() => { navigation.navigate('AppPasswords') @@ -391,7 +389,7 @@ export const SettingsScreen = withAuthRequired( Advanced </Text> <TouchableOpacity - testID="preferencesHomeFeedModalButton" + testID="preferencesHomeFeedButton" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} onPress={openPreferencesModal} accessibilityRole="button" diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index b37befba6..eec55ee46 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -14,6 +14,7 @@ import { import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {Link} from 'view/com/util/Link' +import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' @@ -40,10 +41,14 @@ import {makeProfileLink} from 'lib/routes/links' const ProfileCard = observer(() => { const store = useStores() - return ( + return store.me.handle ? ( <Link href={makeProfileLink(store.me)} style={styles.profileCard} asAnchor> <UserAvatar avatar={store.me.avatar} size={64} /> </Link> + ) : ( + <View style={styles.profileCard}> + <LoadingPlaceholder width={64} height={64} style={{borderRadius: 64}} /> + </View> ) }) |