Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94e2d0be43 | |||
| ebd39c7a10 | |||
| c5005958f0 | |||
| ee3d1ff40d | |||
| 61f88ae2b0 | |||
| a5d4cfda9b | |||
| 4505c2bb1e | |||
| c3b500db9c | |||
| d93587aeef | |||
| 2d62a4942a | |||
| 72bb3ccfac | |||
| 911a5a5638 | |||
| cb81f46d8a | |||
| 35dd618d83 | |||
| 7ee34b6a12 | |||
| a5f37eb37f | |||
| 85481ef897 | |||
| 0dbf8f1b18 | |||
| b1843533eb | |||
| 8aeb364014 | |||
| d7f7cb3128 | |||
| 9f9f877ce1 | |||
| bfdf41a4c3 | |||
| 078ba4cd35 | |||
| 7a97c3c345 | |||
| 4e26c97594 | |||
| 3bae37460d | |||
| b33f70fcac | |||
| 1dc38a8fd9 | |||
| b3bae78e30 | |||
| 41c4b4af7b | |||
| f97f1302cf | |||
| 50335206bf | |||
| d9fc2775c4 | |||
| 1161050e1c | |||
| 3637676778 | |||
| c3ec1e6613 | |||
| 48f15b7361 | |||
| 7bef0eca6a | |||
| 3bcce36418 | |||
| 6c25ca8181 | |||
| 16206df6b2 | |||
| 804a393ff8 | |||
| e0c614bfb7 | |||
| 564a654cc2 | |||
| 4d367d6805 | |||
| 4868d4864b | |||
| 6f6a34cc99 | |||
| bd9c1626e6 | |||
| a07542a607 | |||
| 4b47380cdf | |||
| f5e793d8c3 | |||
| 0648f02f66 | |||
| f404f52939 | |||
| 76eba7b881 | |||
| bb8a016f02 | |||
| 66181679a6 | |||
| 9668102ceb | |||
| dab793193e | |||
| 250e5eb3cd | |||
| 44694abbe6 | |||
| bfdbf43119 | |||
| 439940532f | |||
| f9914d6ff6 | |||
| 763edc8fb3 | |||
| 47273cccb1 |
+4
-1
@@ -35,6 +35,7 @@
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@estrattonbailey/tiptap-suggestion": "^2.2.0-rc.3",
|
||||
"@expo/html-elements": "^0.4.2",
|
||||
"@expo/webpack-config": "^19.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
@@ -51,7 +52,7 @@
|
||||
"@react-native-community/datetimepicker": "7.2.0",
|
||||
"@react-native-menu/menu": "^0.8.0",
|
||||
"@react-native-picker/picker": "2.4.10",
|
||||
"@react-navigation/bottom-tabs": "^6.5.7",
|
||||
"@react-navigation/bottom-tabs": "^6.5.9",
|
||||
"@react-navigation/drawer": "^6.6.2",
|
||||
"@react-navigation/native": "^6.1.6",
|
||||
"@react-navigation/native-stack": "^6.9.12",
|
||||
@@ -102,6 +103,7 @@
|
||||
"expo-system-ui": "~2.4.0",
|
||||
"expo-updates": "~0.18.12",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"fuse.js": "^6.6.2",
|
||||
"graphemer": "^1.4.0",
|
||||
"history": "^5.3.0",
|
||||
"js-sha256": "^0.9.0",
|
||||
@@ -121,6 +123,7 @@
|
||||
"mobx-utils": "^6.0.6",
|
||||
"normalize-url": "^8.0.0",
|
||||
"patch-package": "^6.5.1",
|
||||
"pind": "^0.5.0",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"psl": "^1.9.0",
|
||||
"react": "18.2.0",
|
||||
|
||||
@@ -83,6 +83,7 @@ interface PostOpts {
|
||||
knownHandles?: Set<string>
|
||||
onStateChange?: (state: string) => void
|
||||
langs?: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
@@ -264,6 +265,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
|
||||
embed,
|
||||
langs,
|
||||
labels,
|
||||
tags: opts.tags,
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to create post: ${e.toString()}`)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function sanitize(tagString: string) {
|
||||
return tagString
|
||||
.trim()
|
||||
.replace(LEADING_HASH_REGEX, '')
|
||||
.replace(ENDING_PUNCTUATION_REGEX, '')
|
||||
}
|
||||
@@ -32,3 +32,7 @@ export function toHashCode(str: string, seed = 0): number {
|
||||
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
|
||||
}
|
||||
|
||||
export function uniq(tags: string[]) {
|
||||
return Array.from(new Set(tags))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {ImageSizesCache} from './cache/image-sizes'
|
||||
import {MutedThreads} from './muted-threads'
|
||||
import {Reminders} from './ui/reminders'
|
||||
import {reset as resetNavigation} from '../../Navigation'
|
||||
import {RecentTagsModel} from './ui/tags-autocomplete'
|
||||
|
||||
// TEMPORARY (APP-700)
|
||||
// remove after backend testing finishes
|
||||
@@ -55,6 +56,7 @@ export class RootStoreModel {
|
||||
imageSizes = new ImageSizesCache()
|
||||
mutedThreads = new MutedThreads()
|
||||
reminders = new Reminders(this)
|
||||
recentTags = new RecentTagsModel()
|
||||
|
||||
constructor(agent: BskyAgent) {
|
||||
this.agent = agent
|
||||
@@ -80,6 +82,7 @@ export class RootStoreModel {
|
||||
invitedUsers: this.invitedUsers.serialize(),
|
||||
mutedThreads: this.mutedThreads.serialize(),
|
||||
reminders: this.reminders.serialize(),
|
||||
recentTags: this.recentTags.serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +115,9 @@ export class RootStoreModel {
|
||||
if (hasProp(v, 'mutedThreads')) {
|
||||
this.mutedThreads.hydrate(v.mutedThreads)
|
||||
}
|
||||
if (hasProp(v, 'recentTags')) {
|
||||
this.recentTags.hydrate(v.recentTags)
|
||||
}
|
||||
if (hasProp(v, 'reminders')) {
|
||||
this.reminders.hydrate(v.reminders)
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@ export interface ComposerOpts {
|
||||
onPost?: () => void
|
||||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
outlineTags?: string[]
|
||||
}
|
||||
|
||||
export class ShellUiModel {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import Fuse from 'fuse.js'
|
||||
import {isObj, hasProp, isStrArray} from 'lib/type-guards'
|
||||
|
||||
/**
|
||||
* Used only to persist recent tags across app restarts.
|
||||
*
|
||||
* TODO may want an LRU?
|
||||
*/
|
||||
export class RecentTagsModel {
|
||||
_tags: string[] = []
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true})
|
||||
}
|
||||
|
||||
get tags() {
|
||||
return this._tags
|
||||
}
|
||||
|
||||
add(tag: string) {
|
||||
this._tags = Array.from(new Set([tag, ...this._tags]))
|
||||
}
|
||||
|
||||
remove(tag: string) {
|
||||
this._tags = this._tags.filter(t => t !== tag)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {_tags: this._tags}
|
||||
}
|
||||
|
||||
hydrate(v: unknown) {
|
||||
if (isObj(v) && hasProp(v, '_tags') && isStrArray(v._tags)) {
|
||||
this._tags = Array.from(new Set(v._tags))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TagsAutocompleteModel {
|
||||
lock = new AwaitLock()
|
||||
isActive = false
|
||||
query = ''
|
||||
searchedTags: string[] = []
|
||||
profileTags: string[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
setActive(isActive: boolean) {
|
||||
this.isActive = isActive
|
||||
}
|
||||
|
||||
commitRecentTag(tag: string) {
|
||||
this.rootStore.recentTags.add(tag)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.query = ''
|
||||
this.searchedTags = []
|
||||
}
|
||||
|
||||
get suggestions() {
|
||||
if (!this.isActive) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = Array.from(
|
||||
// de-duplicates via Set
|
||||
new Set([
|
||||
// sample up to 3 recent tags
|
||||
...this.rootStore.recentTags.tags.slice(0, 3),
|
||||
// sample up to 3 of your profile tags
|
||||
...this.profileTags.slice(0, 3),
|
||||
// and all searched tags
|
||||
...this.searchedTags,
|
||||
]),
|
||||
)
|
||||
|
||||
// no query, return default suggestions
|
||||
if (!this.query) {
|
||||
return items.slice(0, 9)
|
||||
}
|
||||
|
||||
// Fuse allows weighting values too, if we ever need it
|
||||
const fuse = new Fuse(items)
|
||||
// search amongst mixed set of tags
|
||||
const results = fuse.search(this.query).map(r => r.item)
|
||||
return results.slice(0, 9)
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
this.query = query.trim()
|
||||
|
||||
await this.lock.acquireAsync()
|
||||
|
||||
try {
|
||||
await this._search()
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO hook up to search type-ahead
|
||||
async _search() {
|
||||
runInAction(() => {
|
||||
this.searchedTags = []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, Pressable} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text, CustomTextProps} from 'view/com/util/text/Text'
|
||||
import {TextLink} from 'view/com/util/Link'
|
||||
|
||||
export function Tag({
|
||||
value,
|
||||
textSize,
|
||||
}: {
|
||||
value: string
|
||||
textSize?: CustomTextProps['type']
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const type = textSize || 'xs-medium'
|
||||
|
||||
return (
|
||||
<TextLink
|
||||
type={type}
|
||||
text={`#${value}`}
|
||||
accessible
|
||||
href={`/search?q=${value}`}
|
||||
style={[pal.textLight]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TagButton({
|
||||
value,
|
||||
icon = 'x',
|
||||
onClick,
|
||||
removeTag,
|
||||
}: {
|
||||
value: string
|
||||
icon?: React.ComponentProps<typeof FontAwesomeIcon>['icon']
|
||||
onClick?: (tag: string) => void
|
||||
removeTag?: (tag: string) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const [hovered, setHovered] = React.useState(false)
|
||||
const [focused, setFocused] = React.useState(false)
|
||||
|
||||
const hoverIn = React.useCallback(() => {
|
||||
setHovered(true)
|
||||
}, [setHovered])
|
||||
|
||||
const hoverOut = React.useCallback(() => {
|
||||
setHovered(false)
|
||||
}, [setHovered])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isWeb) return
|
||||
|
||||
function listener(e: KeyboardEvent) {
|
||||
if (e.key === 'Backspace') {
|
||||
if (focused) {
|
||||
removeTag?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', listener)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', listener)
|
||||
}
|
||||
}, [value, focused, removeTag])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onClick?.(value)}
|
||||
onPointerEnter={hoverIn}
|
||||
onPointerLeave={hoverOut}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
style={state => [
|
||||
pal.viewLight,
|
||||
styles.tagButton,
|
||||
{
|
||||
outline: 0,
|
||||
opacity: state.pressed || state.focused ? 0.6 : 1,
|
||||
paddingRight: 10,
|
||||
},
|
||||
]}>
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
#{value}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
style={
|
||||
{
|
||||
opacity: hovered ? 1 : 0.5,
|
||||
color: pal.textLight.color,
|
||||
marginTop: 1,
|
||||
} as FontAwesomeIconStyle
|
||||
}
|
||||
size={10}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
editableTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
tagButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexShrink: 1,
|
||||
paddingVertical: 6,
|
||||
paddingTop: 5,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
})
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {RichText} from '@atproto/api'
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||
import {ExternalEmbed} from './ExternalEmbed'
|
||||
import {Text} from '../util/text/Text'
|
||||
@@ -49,6 +50,7 @@ import {LabelsBtn} from './labels/LabelsBtn'
|
||||
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
||||
import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
|
||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
||||
import {TagInput} from './TagInput'
|
||||
|
||||
type Props = ComposerOpts
|
||||
export const ComposePost = observer(function ComposePost({
|
||||
@@ -56,6 +58,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
onPost,
|
||||
quote: initQuote,
|
||||
mention: initMention,
|
||||
outlineTags,
|
||||
}: Props) {
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
@@ -87,6 +90,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||
const gallery = useMemo(() => new GalleryModel(store), [store])
|
||||
const [tags, setTags] = useState<string[]>(outlineTags || [])
|
||||
const onClose = useCallback(() => {
|
||||
store.shell.closeComposer()
|
||||
}, [store])
|
||||
@@ -95,6 +99,10 @@ export const ComposePost = observer(function ComposePost({
|
||||
() => new UserAutocompleteModel(store),
|
||||
[store],
|
||||
)
|
||||
const tagsAutocompleteModel = useMemo<TagsAutocompleteModel>(
|
||||
() => new TagsAutocompleteModel(store),
|
||||
[store],
|
||||
)
|
||||
|
||||
const insets = useSafeAreaInsets()
|
||||
const viewStyles = useMemo(
|
||||
@@ -183,6 +191,13 @@ export const ComposePost = observer(function ComposePost({
|
||||
[gallery, track],
|
||||
)
|
||||
|
||||
const onChangeTags = useCallback(
|
||||
(tags: string[]) => {
|
||||
setTags(tags)
|
||||
},
|
||||
[setTags],
|
||||
)
|
||||
|
||||
const onPressPublish = async () => {
|
||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||
return
|
||||
@@ -211,6 +226,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
onStateChange: setProcessingState,
|
||||
knownHandles: autocompleteView.knownHandles,
|
||||
langs: store.preferences.postLanguages,
|
||||
tags,
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (extLink) {
|
||||
@@ -228,6 +244,18 @@ export const ComposePost = observer(function ComposePost({
|
||||
imageCount: gallery.size,
|
||||
})
|
||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||
|
||||
// save outline tags
|
||||
tags.forEach(tag => tagsAutocompleteModel.commitRecentTag(tag))
|
||||
|
||||
// save inline tags
|
||||
for (const facet of richtext.facets || []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isTag(feature)) {
|
||||
tagsAutocompleteModel.commitRecentTag(feature.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!replyTo) {
|
||||
store.me.mainFeed.onPostCreated()
|
||||
@@ -419,6 +447,18 @@ export const ComposePost = observer(function ComposePost({
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 10,
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 15,
|
||||
},
|
||||
]}>
|
||||
<TagInput initialTags={tags} onChangeTags={onChangeTags} />
|
||||
</View>
|
||||
|
||||
<View style={[pal.border, styles.bottomBar]}>
|
||||
{canSelectImages ? (
|
||||
<>
|
||||
@@ -521,8 +561,7 @@ const styles = StyleSheet.create({
|
||||
bottomBar: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 10,
|
||||
paddingLeft: 15,
|
||||
paddingRight: 20,
|
||||
paddingRight: 15,
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import {View, StyleSheet, Pressable} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
|
||||
export function TagInputEntryButton({
|
||||
tags,
|
||||
onRequestOpen,
|
||||
}: {
|
||||
tags: string[]
|
||||
onRequestOpen: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={state => [
|
||||
styles.selectedTags,
|
||||
{
|
||||
outline: 0,
|
||||
opacity: state.pressed || state.focused ? 0.6 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={onRequestOpen}>
|
||||
<View style={[pal.viewLight, styles.button]}>
|
||||
{tags.length ? (
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
Add +
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon="tags"
|
||||
size={12}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
Click to add tags to your post
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{tags.map(tag => (
|
||||
<View key={tag} style={[pal.viewLight, styles.button]}>
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
#{tag}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
selectedTags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexShrink: 1,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
NativeSyntheticEvent,
|
||||
TextInputKeyPressEventData,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import BottomSheet, {BottomSheetBackdrop} from '@gorhom/bottom-sheet'
|
||||
|
||||
import {Portal} from 'view/com/util/Portal'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {TagButton} from 'view/com/Tag'
|
||||
import * as Sheet from 'view/com/sheets/Base'
|
||||
import {useStores} from 'state/index'
|
||||
import {ActivityIndicator} from 'react-native'
|
||||
import {TagInputEntryButton} from './TagInputEntryButton'
|
||||
import {uniq} from 'lib/strings/helpers'
|
||||
import {sanitizeHashtag, sanitizeHashtagOnChange} from './util'
|
||||
|
||||
export function TagInput({
|
||||
max = 8,
|
||||
initialTags = [],
|
||||
onChangeTags,
|
||||
}: {
|
||||
max?: number
|
||||
initialTags?: string[]
|
||||
onChangeTags: (tags: string[]) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const model = React.useMemo(() => new TagsAutocompleteModel(store), [store])
|
||||
const pal = usePalette('default')
|
||||
const input = React.useRef<TextInput>(null)
|
||||
|
||||
const [value, setValue] = React.useState('')
|
||||
const [tags, setTags] = React.useState<string[]>(initialTags)
|
||||
const [suggestions, setSuggestions] = React.useState<string[]>([])
|
||||
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
|
||||
|
||||
const sheet = React.useRef<BottomSheet>(null)
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setValue('')
|
||||
model.setActive(false)
|
||||
model.clear()
|
||||
setSuggestions([])
|
||||
}, [model, setValue, setSuggestions])
|
||||
|
||||
const addTags = React.useCallback(
|
||||
(_tags: string[]) => {
|
||||
setTags(_tags)
|
||||
onChangeTags(_tags)
|
||||
},
|
||||
[onChangeTags, setTags],
|
||||
)
|
||||
|
||||
const removeTag = React.useCallback(
|
||||
(tag: string) => {
|
||||
addTags(tags.filter(t => t !== tag))
|
||||
},
|
||||
[tags, addTags],
|
||||
)
|
||||
|
||||
const addTagAndReset = React.useCallback(
|
||||
(value: string) => {
|
||||
const tag = sanitizeHashtag(value)
|
||||
|
||||
// enforce max hashtag length
|
||||
if (tag.length > 0 && tag.length <= 64) {
|
||||
addTags(uniq([...tags, tag]).slice(0, max))
|
||||
}
|
||||
|
||||
setValue('')
|
||||
},
|
||||
[max, tags, setValue, addTags],
|
||||
)
|
||||
|
||||
const onSubmitEditing = React.useCallback(() => {
|
||||
addTagAndReset(value)
|
||||
}, [value, addTagAndReset])
|
||||
|
||||
const onKeyPress = React.useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const {key} = e.nativeEvent
|
||||
|
||||
if (key === 'Backspace' && value === '') {
|
||||
addTags(tags.slice(0, -1))
|
||||
} else if (key === ' ') {
|
||||
addTagAndReset(value)
|
||||
setTimeout(() => {
|
||||
setValue('')
|
||||
}, 1)
|
||||
}
|
||||
},
|
||||
[value, tags, addTags, addTagAndReset],
|
||||
)
|
||||
|
||||
const onChangeText = React.useCallback(
|
||||
async (value: string) => {
|
||||
const tag = sanitizeHashtagOnChange(value)
|
||||
|
||||
setValue(tag)
|
||||
|
||||
if (tag.length > 0) {
|
||||
model.setActive(true)
|
||||
await model.search(tag)
|
||||
|
||||
setSuggestions(model.suggestions)
|
||||
} else {
|
||||
model.clear()
|
||||
|
||||
setSuggestions(model.suggestions)
|
||||
}
|
||||
},
|
||||
[model, setValue],
|
||||
)
|
||||
|
||||
const openSheet = React.useCallback(() => {
|
||||
sheet.current?.snapToIndex(0)
|
||||
input.current?.focus()
|
||||
}, [sheet])
|
||||
|
||||
const onSheetChange = React.useCallback(
|
||||
async (index: number) => {
|
||||
if (index > -1) {
|
||||
model.setActive(true)
|
||||
await model.search('') // get default results
|
||||
setSuggestions(model.suggestions)
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
},
|
||||
[model, setSuggestions, setIsInitialLoad],
|
||||
)
|
||||
|
||||
const onCloseSheet = React.useCallback(() => {
|
||||
reset()
|
||||
setIsInitialLoad(true)
|
||||
input.current?.blur()
|
||||
}, [reset, setIsInitialLoad])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TagInputEntryButton onRequestOpen={openSheet} tags={tags} />
|
||||
|
||||
<Portal>
|
||||
<BottomSheet
|
||||
ref={sheet}
|
||||
index={-1}
|
||||
snapPoints={['90%']}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
android_keyboardInputMode="adjustResize"
|
||||
backdropComponent={props => (
|
||||
<BottomSheetBackdrop
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
handleIndicatorStyle={{backgroundColor: pal.text.color}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onChange={onSheetChange}
|
||||
onClose={onCloseSheet}>
|
||||
<Sheet.Outer>
|
||||
<Sheet.Handle />
|
||||
|
||||
<Sheet.Content>
|
||||
<View style={styles.outer}>
|
||||
{tags.length < 1 && (
|
||||
<FontAwesomeIcon
|
||||
icon="tags"
|
||||
size={14}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tags.map(tag => (
|
||||
<TagButton key={tag} value={tag} onClick={removeTag} />
|
||||
))}
|
||||
|
||||
<TextInput
|
||||
ref={input}
|
||||
blurOnSubmit={false}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
placeholder="Add tags..."
|
||||
value={value}
|
||||
style={[styles.input, {}]}
|
||||
onChangeText={onChangeText}
|
||||
onKeyPress={onKeyPress}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
accessibilityHint="Add tags to your post"
|
||||
accessibilityLabel="Add tags to your post"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{marginHorizontal: -20}}>
|
||||
<ScrollView horizontal>
|
||||
<View style={styles.suggestions}>
|
||||
{isInitialLoad && <ActivityIndicator />}
|
||||
|
||||
{suggestions
|
||||
.filter(s => !tags.find(t => t === s))
|
||||
.map(suggestion => {
|
||||
return (
|
||||
<TagButton
|
||||
key={suggestion}
|
||||
icon="plus"
|
||||
value={suggestion}
|
||||
onClick={addTagAndReset}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Sheet.Content>
|
||||
</Sheet.Outer>
|
||||
</BottomSheet>
|
||||
</Portal>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
selectedTags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
outer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
input: {
|
||||
flexGrow: 1,
|
||||
minWidth: 100,
|
||||
fontSize: 15,
|
||||
lineHeight: Platform.select({
|
||||
web: 20,
|
||||
native: 18,
|
||||
}),
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
suggestions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingLeft: 20,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexShrink: 1,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
TextInput,
|
||||
View,
|
||||
StyleSheet,
|
||||
NativeSyntheticEvent,
|
||||
TextInputKeyPressEventData,
|
||||
Platform,
|
||||
Pressable,
|
||||
} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Pin} from 'pind'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {TagButton} from 'view/com/Tag'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import {TagInputEntryButton} from './TagInputEntryButton'
|
||||
import {TextInputFocusEventData} from 'react-native'
|
||||
import {uniq} from 'lib/strings/helpers'
|
||||
import {sanitizeHashtag, sanitizeHashtagOnChange} from './util'
|
||||
|
||||
export function TagInput({
|
||||
max = 8,
|
||||
initialTags = [],
|
||||
onChangeTags,
|
||||
}: {
|
||||
max?: number
|
||||
initialTags?: string[]
|
||||
onChangeTags: (tags: string[]) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const dropdown = React.useRef<HTMLDivElement>(null)
|
||||
const input = React.useRef<HTMLInputElement>(null)
|
||||
const inputWidth = input.current
|
||||
? input.current.getBoundingClientRect().width
|
||||
: 200
|
||||
const model = React.useMemo(() => new TagsAutocompleteModel(store), [store])
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const [value, setValue] = React.useState('')
|
||||
const [tags, setTags] = React.useState<string[]>(initialTags)
|
||||
const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false)
|
||||
const [dropdownItems, setDropdownItems] = React.useState<
|
||||
{value: string; label: string}[]
|
||||
>([])
|
||||
const [selectedItemIndex, setSelectedItemIndex] = React.useState(0)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const openTagInput = React.useCallback(async () => {
|
||||
setOpen(true)
|
||||
}, [setOpen])
|
||||
|
||||
const closeDropdownAndReset = React.useCallback(() => {
|
||||
setDropdownIsOpen(false)
|
||||
model.setActive(false)
|
||||
setSelectedItemIndex(0)
|
||||
setDropdownItems([])
|
||||
}, [model, setDropdownIsOpen, setSelectedItemIndex, setDropdownItems])
|
||||
|
||||
const addTags = React.useCallback(
|
||||
(_tags: string[]) => {
|
||||
setTags(_tags)
|
||||
onChangeTags(_tags)
|
||||
|
||||
if (!_tags.length && document.activeElement !== input.current)
|
||||
setOpen(false)
|
||||
},
|
||||
[onChangeTags, setTags, setOpen],
|
||||
)
|
||||
|
||||
const removeTag = React.useCallback(
|
||||
(tag: string) => {
|
||||
addTags(tags.filter(t => t !== tag))
|
||||
},
|
||||
[tags, addTags],
|
||||
)
|
||||
|
||||
const addTagAndReset = React.useCallback(
|
||||
(value: string) => {
|
||||
const tag = sanitizeHashtag(value)
|
||||
|
||||
// enforce max hashtag length
|
||||
if (tag.length > 0 && tag.length <= 64) {
|
||||
addTags(uniq([...tags, tag]).slice(0, max))
|
||||
}
|
||||
|
||||
setValue('')
|
||||
input.current?.focus()
|
||||
closeDropdownAndReset()
|
||||
},
|
||||
[max, tags, closeDropdownAndReset, setValue, addTags],
|
||||
)
|
||||
|
||||
const onSubmitEditing = React.useCallback(() => {
|
||||
const item = dropdownItems[selectedItemIndex]
|
||||
addTagAndReset(item?.value || value)
|
||||
}, [value, dropdownItems, selectedItemIndex, addTagAndReset])
|
||||
|
||||
const onKeyPress = React.useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const {key} = e.nativeEvent
|
||||
|
||||
if (key === 'Backspace' && value === '') {
|
||||
addTags(tags.slice(0, -1))
|
||||
closeDropdownAndReset()
|
||||
} else if (key === ' ') {
|
||||
e.preventDefault() // prevents an additional space on web
|
||||
addTagAndReset(value)
|
||||
}
|
||||
|
||||
if (dropdownIsOpen) {
|
||||
if (key === 'Escape') {
|
||||
closeDropdownAndReset()
|
||||
} else if (key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedItemIndex(
|
||||
(selectedItemIndex + dropdownItems.length - 1) %
|
||||
dropdownItems.length,
|
||||
)
|
||||
} else if (key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length)
|
||||
} else if (
|
||||
isWeb &&
|
||||
key === 'Tab' &&
|
||||
// @ts-ignore web only
|
||||
!e.nativeEvent.shiftKey
|
||||
) {
|
||||
e.preventDefault()
|
||||
onSubmitEditing()
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
value,
|
||||
tags,
|
||||
dropdownIsOpen,
|
||||
selectedItemIndex,
|
||||
dropdownItems.length,
|
||||
closeDropdownAndReset,
|
||||
setSelectedItemIndex,
|
||||
addTags,
|
||||
addTagAndReset,
|
||||
onSubmitEditing,
|
||||
],
|
||||
)
|
||||
|
||||
const onChangeText = React.useCallback(
|
||||
async (value: string) => {
|
||||
const tag = sanitizeHashtagOnChange(value)
|
||||
|
||||
setValue(tag)
|
||||
|
||||
if (tag.length > 0) {
|
||||
model.setActive(true)
|
||||
await model.search(tag)
|
||||
|
||||
setDropdownItems(
|
||||
model.suggestions.map(item => ({
|
||||
value: item,
|
||||
label: item,
|
||||
})),
|
||||
)
|
||||
|
||||
setDropdownIsOpen(true)
|
||||
} else {
|
||||
closeDropdownAndReset()
|
||||
}
|
||||
},
|
||||
[model, setValue, setDropdownIsOpen, closeDropdownAndReset],
|
||||
)
|
||||
|
||||
const onFocus = React.useCallback(async () => {
|
||||
model.setActive(true)
|
||||
await model.search('')
|
||||
|
||||
setDropdownItems(
|
||||
model.suggestions.map(item => ({
|
||||
value: item,
|
||||
label: item,
|
||||
})),
|
||||
)
|
||||
|
||||
setDropdownIsOpen(true)
|
||||
}, [model, setDropdownIsOpen])
|
||||
|
||||
const onBlur = React.useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputFocusEventData>) => {
|
||||
// @ts-ignore
|
||||
const target = e.nativeEvent.relatedTarget as HTMLElement | undefined
|
||||
|
||||
if (
|
||||
!tags.length &&
|
||||
(!target || !target.id.includes('tag_autocomplete_option'))
|
||||
) {
|
||||
setOpen(false)
|
||||
setValue('')
|
||||
}
|
||||
},
|
||||
[tags, setOpen, setValue],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// outside click
|
||||
function onClick(e: MouseEvent) {
|
||||
const drop = dropdown.current
|
||||
const control = input.current
|
||||
|
||||
if (
|
||||
!drop ||
|
||||
!control ||
|
||||
e.target === drop ||
|
||||
e.target === control ||
|
||||
drop.contains(e.target as Node) ||
|
||||
control.contains(e.target as Node)
|
||||
)
|
||||
return
|
||||
|
||||
closeDropdownAndReset()
|
||||
}
|
||||
|
||||
document.addEventListener('click', onClick)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick)
|
||||
}
|
||||
}, [closeDropdownAndReset])
|
||||
|
||||
return (
|
||||
<View ref={containerRef as any}>
|
||||
{!open && (
|
||||
<TagInputEntryButton tags={tags} onRequestOpen={openTagInput} />
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<View>
|
||||
<View style={styles.outer}>
|
||||
{!tags.length && (
|
||||
<FontAwesomeIcon
|
||||
icon="tags"
|
||||
size={14}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tags.map(tag => (
|
||||
<TagButton
|
||||
key={tag}
|
||||
value={tag}
|
||||
onClick={removeTag}
|
||||
removeTag={removeTag}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tags.length >= max ? null : (
|
||||
<TextInput
|
||||
ref={input as any}
|
||||
autoFocus={true}
|
||||
id="tags-autocomplete-input"
|
||||
role={'listbox' as any}
|
||||
aria-controls="tags-autocomplete-dropdown"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={dropdownIsOpen}
|
||||
value={value}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyPress={onKeyPress}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
onChangeText={onChangeText}
|
||||
blurOnSubmit={false}
|
||||
style={[styles.input, pal.textLight]}
|
||||
placeholder="Enter a tag and press enter"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
accessible={true}
|
||||
accessibilityLabel="Add tags to your post"
|
||||
accessibilityHint={`Type a tag and press enter to add it. You can add up to ${max} tag.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Pin
|
||||
pinned={Boolean(dropdownIsOpen && dropdownItems.length)}
|
||||
to={input}
|
||||
at="bottomLeft"
|
||||
from="topLeft"
|
||||
style={{width: inputWidth}}>
|
||||
<View
|
||||
ref={dropdown as any}
|
||||
style={[pal.view, pal.borderDark, styles.dropdown]}
|
||||
role={'listbox' as any}
|
||||
id="tags-autocomplete-dropdown">
|
||||
{dropdownItems.map((item, index) => {
|
||||
const isFirst = index === 0
|
||||
const isLast = index === dropdownItems.length - 1
|
||||
return (
|
||||
<Pressable
|
||||
id={`tag_autocomplete_option_${item.value}`}
|
||||
accessibilityRole="button"
|
||||
key={item.value}
|
||||
onPress={() => addTagAndReset(item.value)}
|
||||
style={state => [
|
||||
pal.border,
|
||||
styles.dropdownItem,
|
||||
{
|
||||
backgroundColor: state.hovered
|
||||
? pal.viewLight.backgroundColor
|
||||
: undefined,
|
||||
},
|
||||
selectedItemIndex === index ? pal.viewLight : undefined,
|
||||
isFirst
|
||||
? styles.firstResult
|
||||
: isLast
|
||||
? styles.lastResult
|
||||
: undefined,
|
||||
]}>
|
||||
<Text type="md" style={pal.textLight} numberOfLines={1}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</Pin>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
input: {
|
||||
flexGrow: 1,
|
||||
minWidth: 100,
|
||||
fontSize: 15,
|
||||
lineHeight: Platform.select({
|
||||
web: 20,
|
||||
native: 18,
|
||||
}),
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
dropdown: {
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
padding: 4,
|
||||
},
|
||||
dropdownItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 4,
|
||||
},
|
||||
firstResult: {
|
||||
borderTopLeftRadius: 2,
|
||||
borderTopRightRadius: 2,
|
||||
},
|
||||
lastResult: {
|
||||
borderBottomLeftRadius: 2,
|
||||
borderBottomRightRadius: 2,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
HASHTAG_INVALID_CHARACTER_REGEX,
|
||||
TRAILING_PUNCTUATION_REGEX,
|
||||
LEADING_PUNCTUATION_REGEX,
|
||||
LEADING_NUMBER_REGEX,
|
||||
} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* Trims leading numbers, all invalid characters, and any trailing punctuation.
|
||||
*/
|
||||
export function sanitizeHashtag(hashtag: string) {
|
||||
return hashtag
|
||||
.replace(LEADING_PUNCTUATION_REGEX, '')
|
||||
.replace(LEADING_NUMBER_REGEX, '')
|
||||
.replace(HASHTAG_INVALID_CHARACTER_REGEX, '')
|
||||
.replace(TRAILING_PUNCTUATION_REGEX, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims leading numbers and all invalid charactes, but ignores trailing
|
||||
* punctuation in case the user intends to use `_` or `-`.
|
||||
*/
|
||||
export function sanitizeHashtagOnChange(hashtag: string) {
|
||||
return hashtag
|
||||
.replace(LEADING_PUNCTUATION_REGEX, '')
|
||||
.replace(LEADING_NUMBER_REGEX, '')
|
||||
.replace(HASHTAG_INVALID_CHARACTER_REGEX, '')
|
||||
}
|
||||
@@ -19,7 +19,13 @@ import PasteInput, {
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {Autocomplete} from './mobile/Autocomplete'
|
||||
import {
|
||||
TagsAutocomplete,
|
||||
getHashtagAt,
|
||||
insertTagAt,
|
||||
} from './mobile/TagsAutocomplete'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
||||
@@ -28,6 +34,7 @@ import {useTheme} from 'lib/ThemeContext'
|
||||
import {isUriImage} from 'lib/media/util'
|
||||
import {downloadAndResize} from 'lib/media/manip'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
@@ -65,10 +72,15 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
}: TextInputProps,
|
||||
ref,
|
||||
) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const textInput = useRef<PasteInputRef>(null)
|
||||
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||
const theme = useTheme()
|
||||
const tagsAutocompleteModel = React.useMemo(
|
||||
() => new TagsAutocompleteModel(store),
|
||||
[store],
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => textInput.current?.focus(),
|
||||
@@ -94,17 +106,29 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
|
||||
const prefix = getMentionAt(
|
||||
const mentionPrefix = getMentionAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (prefix) {
|
||||
|
||||
if (mentionPrefix) {
|
||||
autocompleteView.setActive(true)
|
||||
autocompleteView.setPrefix(prefix.value)
|
||||
autocompleteView.setPrefix(mentionPrefix.value)
|
||||
} else {
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
|
||||
const hashtagPrefix = getHashtagAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (hashtagPrefix) {
|
||||
tagsAutocompleteModel.setActive(true)
|
||||
tagsAutocompleteModel.search(hashtagPrefix.value || '')
|
||||
} else {
|
||||
tagsAutocompleteModel.setActive(false)
|
||||
}
|
||||
|
||||
const set: Set<string> = new Set()
|
||||
|
||||
if (newRt.facets) {
|
||||
@@ -143,6 +167,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
suggestedLinks,
|
||||
onSuggestedLinksChanged,
|
||||
onPhotoPasted,
|
||||
tagsAutocompleteModel,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -184,16 +209,25 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
[onChangeText, richtext, autocompleteView],
|
||||
)
|
||||
|
||||
const onSelectTag = useCallback(
|
||||
(tag: string) => {
|
||||
onChangeText(
|
||||
insertTagAt(richtext.text, textInputSelection.current?.start || 0, tag),
|
||||
)
|
||||
tagsAutocompleteModel.setActive(false)
|
||||
},
|
||||
[onChangeText, richtext, tagsAutocompleteModel],
|
||||
)
|
||||
|
||||
const textDecorated = useMemo(() => {
|
||||
let i = 0
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => {
|
||||
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
||||
return (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
segment.facet && !isTag ? pal.link : pal.text,
|
||||
segment.facet ? pal.link : pal.text,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
@@ -224,6 +258,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
view={autocompleteView}
|
||||
onSelect={onSelectAutocompleteItem}
|
||||
/>
|
||||
<TagsAutocomplete model={tagsAutocompleteModel} onSelect={onSelectTag} />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,12 +12,15 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
||||
import {Text} from '@tiptap/extension-text'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
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'
|
||||
import {generateJSON} from '@tiptap/html'
|
||||
import {Tags, createTagsAutocomplete} from './web/Tags'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
@@ -52,11 +55,24 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
TextInputProps,
|
||||
ref,
|
||||
) {
|
||||
const store = useStores()
|
||||
const tagsAutocompleteModel = React.useMemo(
|
||||
() => new TagsAutocompleteModel(store),
|
||||
[store],
|
||||
)
|
||||
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
|
||||
const extensions = React.useMemo(
|
||||
() => [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
Tags.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'inline-tag',
|
||||
},
|
||||
suggestion: createTagsAutocomplete({
|
||||
autocompleteModel: tagsAutocompleteModel,
|
||||
}),
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
@@ -71,7 +87,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
History,
|
||||
Hardbreak,
|
||||
],
|
||||
[autocompleteView, placeholder],
|
||||
[autocompleteView, placeholder, tagsAutocompleteModel],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -183,6 +199,8 @@ function editorJsonToText(json: JSONContent): string {
|
||||
text += json.text || ''
|
||||
} else if (json.type === 'mention') {
|
||||
text += `@${json.attrs?.id || ''}`
|
||||
} else if (json.type === 'tag') {
|
||||
text += `#${json.attrs?.id || ''}`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {LEADING_HASH_REGEX, HASHTAG_REGEX} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* Loops over matches in the text to find the hashtag under the cursor.
|
||||
*/
|
||||
export function getHashtagAt(text: string, position: number) {
|
||||
for (const match of Array.from(text.matchAll(HASHTAG_REGEX))) {
|
||||
const {index} = match
|
||||
const [matchedString, tag] = match
|
||||
|
||||
if (tag.length > 66 || index === undefined) continue
|
||||
|
||||
const from = index + matchedString.indexOf(tag)
|
||||
const to = from + tag.length
|
||||
|
||||
if (position >= from && position <= to) {
|
||||
return {value: tag, index: from}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* show autocomplete after a single # is typed
|
||||
* AND the cursor is next to the #
|
||||
*/
|
||||
for (const match of Array.from(
|
||||
text.matchAll(new RegExp(LEADING_HASH_REGEX, 'g')),
|
||||
)) {
|
||||
const {index} = match
|
||||
if (index === undefined) continue
|
||||
if (position >= index && position <= index + 1) {
|
||||
return {value: '', index}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function insertTagAt(text: string, position: number, tag: string) {
|
||||
const target = getHashtagAt(text, position)
|
||||
if (target) {
|
||||
return `${text.slice(0, target.index)}#${tag} ${text.slice(
|
||||
target.index + target.value.length + 1, // add 1 to include the "#"
|
||||
)}`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export const TagsAutocomplete = observer(function AutocompleteImpl({
|
||||
model,
|
||||
onSelect,
|
||||
}: {
|
||||
model: TagsAutocompleteModel
|
||||
onSelect: (item: string) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const positionInterp = useAnimatedValue(0)
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(positionInterp, {
|
||||
toValue: model.isActive ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}, [positionInterp, model.isActive])
|
||||
|
||||
const topAnimStyle = {
|
||||
transform: [
|
||||
{
|
||||
translateY: positionInterp.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [200, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (!model.suggestions.length) return null
|
||||
|
||||
return (
|
||||
<Animated.View style={topAnimStyle}>
|
||||
{model.isActive ? (
|
||||
<View style={[pal.view, styles.container, pal.border]}>
|
||||
{model.suggestions.slice(0, 5).map(item => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID="autocompleteButton"
|
||||
key={item}
|
||||
style={[pal.border, styles.item]}
|
||||
onPress={() => onSelect(item)}
|
||||
accessibilityLabel={`Select #${item}`}
|
||||
accessibilityHint="">
|
||||
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||
#{item}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginLeft: -50, // Composer avatar width
|
||||
top: 10,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
item: {
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 6,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import {describe, it, expect} from '@jest/globals'
|
||||
|
||||
import {findSuggestionMatch, parsePunctuationFromTag} from '../utils'
|
||||
|
||||
describe('findSuggestionMatch', () => {
|
||||
it(`finds tag`, () => {
|
||||
const match = findSuggestionMatch({
|
||||
text: 'a #tag',
|
||||
cursorPosition: 6,
|
||||
})
|
||||
|
||||
expect(match).toEqual({
|
||||
range: {
|
||||
from: 2,
|
||||
to: 6,
|
||||
},
|
||||
query: 'tag',
|
||||
text: ' #tag',
|
||||
})
|
||||
})
|
||||
|
||||
it(`validates tag length`, () => {
|
||||
expect(
|
||||
findSuggestionMatch({
|
||||
text: '#xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
cursorPosition: 65,
|
||||
}),
|
||||
).toEqual({
|
||||
range: {
|
||||
from: 0,
|
||||
to: 65,
|
||||
},
|
||||
query: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
text: '#xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
})
|
||||
|
||||
expect(
|
||||
findSuggestionMatch({
|
||||
text: '#xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxo',
|
||||
cursorPosition: 66,
|
||||
}),
|
||||
).toEqual(null)
|
||||
})
|
||||
|
||||
it(`reports tag with trailing punctuation`, () => {
|
||||
const match = findSuggestionMatch({
|
||||
text: '#tag!!!',
|
||||
cursorPosition: 7,
|
||||
})
|
||||
|
||||
expect(match).toEqual({
|
||||
range: {
|
||||
from: 0,
|
||||
to: 7,
|
||||
},
|
||||
query: 'tag!!!',
|
||||
text: '#tag!!!',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePunctuationFromTag', () => {
|
||||
it(`parses with punctuation`, () => {
|
||||
expect(parsePunctuationFromTag('tag!')).toEqual({
|
||||
tag: 'tag',
|
||||
punctuation: '!',
|
||||
})
|
||||
expect(parsePunctuationFromTag('tag!!!')).toEqual({
|
||||
tag: 'tag',
|
||||
punctuation: '!!!',
|
||||
})
|
||||
})
|
||||
|
||||
it(`parses without punctuation`, () => {
|
||||
expect(parsePunctuationFromTag('tag')).toEqual({
|
||||
tag: 'tag',
|
||||
punctuation: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export {Tags} from './plugin'
|
||||
export {createTagsAutocomplete} from './view'
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* This is basically a fork of the Mention plugin from Tiptap.
|
||||
*
|
||||
* @see https://github.com/ueberdosis/tiptap/blob/025dfff1d9e4796edf3a451f7f53d06a07b95d69/packages/extension-mention/src/mention.ts
|
||||
*/
|
||||
|
||||
import {mergeAttributes, Node} from '@tiptap/core'
|
||||
import {Node as ProseMirrorNode} from '@tiptap/pm/model'
|
||||
import {PluginKey} from '@tiptap/pm/state'
|
||||
import Suggestion, {SuggestionOptions} from '@estrattonbailey/tiptap-suggestion'
|
||||
|
||||
import {findSuggestionMatch} from './utils'
|
||||
|
||||
export type TagOptions = {
|
||||
HTMLAttributes: Record<string, any>
|
||||
renderLabel: (props: {options: TagOptions; node: ProseMirrorNode}) => string
|
||||
suggestion: Omit<SuggestionOptions, 'editor'>
|
||||
}
|
||||
|
||||
export const TagsPluginKey = new PluginKey('tags')
|
||||
|
||||
export const Tags = Node.create<TagOptions>({
|
||||
name: 'tag',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderLabel({node}) {
|
||||
return `#${node.attrs.id}`
|
||||
},
|
||||
suggestion: {
|
||||
char: '#',
|
||||
allowSpaces: true,
|
||||
pluginKey: TagsPluginKey,
|
||||
command: ({editor, range, props}) => {
|
||||
const {tag, punctuation} = props
|
||||
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(' ')
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: {id: tag},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: `${punctuation || ''} `,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
|
||||
window.getSelection()?.collapseToEnd()
|
||||
},
|
||||
/**
|
||||
* This method and `findSuggestionMatch` below both have to return a
|
||||
* truthy value, otherwise the suggestiond plugin will call `onExit`
|
||||
* and we lose the ability to add a tag
|
||||
*/
|
||||
allow: ({state, range}) => {
|
||||
const $from = state.doc.resolve(range.from)
|
||||
const type = state.schema.nodes[this.name]
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type)
|
||||
return allow
|
||||
},
|
||||
findSuggestionMatch({$position}) {
|
||||
const text = $position.nodeBefore?.isText && $position.nodeBefore.text
|
||||
const cursorPosition = $position.pos
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
return findSuggestionMatch({text, cursorPosition})
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
group: 'inline',
|
||||
|
||||
inline: true,
|
||||
|
||||
atom: true,
|
||||
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.id) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'data-id': attributes.id,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-label'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.label) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'data-label': attributes.label,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(
|
||||
{'data-type': this.name},
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
renderText({node}) {
|
||||
return this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
})
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () =>
|
||||
this.editor.commands.command(({tr, state}) => {
|
||||
let isTag = false
|
||||
const {selection} = state
|
||||
const {empty, anchor} = selection
|
||||
|
||||
if (!empty) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||
if (node.type.name === this.name) {
|
||||
isTag = true
|
||||
tr.insertText(
|
||||
this.options.suggestion.char || '',
|
||||
pos,
|
||||
pos + node.nodeSize,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return isTag
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
HASHTAG_REGEX_WITH_TRAILING_PUNCTUATION,
|
||||
TRAILING_PUNCTUATION_REGEX,
|
||||
LEADING_HASH_REGEX,
|
||||
} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* This method eventually receives the `query` property from the result of
|
||||
* `findSuggestionMatch` below.
|
||||
*/
|
||||
export function parsePunctuationFromTag(value: string) {
|
||||
const reg = TRAILING_PUNCTUATION_REGEX
|
||||
const tag = value.replace(reg, '')
|
||||
const punctuation = value.match(reg)?.[0] || ''
|
||||
|
||||
return {tag, punctuation}
|
||||
}
|
||||
|
||||
/**
|
||||
* A result must be returned from this method in order for the suggestion
|
||||
* plugin to remain active and allow for the user to select a suggestion.
|
||||
*
|
||||
* That's why we use the loose regex form that includes trialing punctuation.
|
||||
* We strip that our later.
|
||||
*/
|
||||
export function findSuggestionMatch({
|
||||
text,
|
||||
cursorPosition,
|
||||
}: {
|
||||
text: string
|
||||
cursorPosition: number
|
||||
}) {
|
||||
const match = Array.from(
|
||||
text.matchAll(HASHTAG_REGEX_WITH_TRAILING_PUNCTUATION),
|
||||
).pop()
|
||||
|
||||
if (!match || match.input === undefined || match.index === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startIndex = cursorPosition - text.length
|
||||
let [matchedString, tagWithTrailingPunctuation] = match
|
||||
|
||||
const sanitized = tagWithTrailingPunctuation
|
||||
.replace(TRAILING_PUNCTUATION_REGEX, '')
|
||||
.replace(LEADING_HASH_REGEX, '')
|
||||
|
||||
// one of our hashtag spec rules
|
||||
if (sanitized.length > 64) return null
|
||||
|
||||
const from =
|
||||
startIndex + match.index + matchedString.indexOf(tagWithTrailingPunctuation)
|
||||
const to = from + tagWithTrailingPunctuation.length
|
||||
|
||||
if (from < cursorPosition && to >= cursorPosition) {
|
||||
return {
|
||||
range: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
/**
|
||||
* This is passed to the `items({ query })` method configured in
|
||||
* `createTagsAutocomplete`.
|
||||
*
|
||||
* We parse out the punctuation later.
|
||||
*/
|
||||
query: tagWithTrailingPunctuation.replace(LEADING_HASH_REGEX, ''),
|
||||
// raw text string
|
||||
text: matchedString,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, {forwardRef, useImperativeHandle, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {ReactRenderer} from '@tiptap/react'
|
||||
import tippy, {Instance as TippyInstance} from 'tippy.js'
|
||||
import {
|
||||
SuggestionOptions,
|
||||
SuggestionProps,
|
||||
SuggestionKeyDownProps,
|
||||
} from '@estrattonbailey/tiptap-suggestion'
|
||||
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
|
||||
import {parsePunctuationFromTag} from './utils'
|
||||
|
||||
type AutocompleteResult = string
|
||||
type ListProps = SuggestionProps<AutocompleteResult> & {
|
||||
autocompleteModel: TagsAutocompleteModel
|
||||
}
|
||||
type AutocompleteRef = {
|
||||
onKeyDown: (props: SuggestionKeyDownProps) => boolean
|
||||
}
|
||||
|
||||
export function createTagsAutocomplete({
|
||||
autocompleteModel,
|
||||
}: {
|
||||
autocompleteModel: TagsAutocompleteModel
|
||||
}): Omit<SuggestionOptions, 'editor'> {
|
||||
return {
|
||||
/**
|
||||
* This `query` param comes from the result of `findSuggestionMatch`
|
||||
*/
|
||||
async items({query}) {
|
||||
autocompleteModel.setActive(true)
|
||||
await autocompleteModel.search(query)
|
||||
return autocompleteModel.suggestions.slice(0, 8)
|
||||
},
|
||||
render() {
|
||||
let component: ReactRenderer<AutocompleteRef> | undefined
|
||||
let popup: TippyInstance[] | undefined
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
component = new ReactRenderer(Autocomplete, {
|
||||
props: {
|
||||
...props,
|
||||
autocompleteModel,
|
||||
},
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component?.updateProps(props)
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
// @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0]?.hide()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown(props) || false
|
||||
},
|
||||
onExit() {
|
||||
popup?.[0]?.destroy()
|
||||
component?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const Autocomplete = forwardRef<AutocompleteRef, ListProps>(
|
||||
function AutocompleteImpl(props, ref) {
|
||||
const {items, command, autocompleteModel} = props
|
||||
const pal = usePalette('default')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const commit = React.useCallback(
|
||||
(query: string) => {
|
||||
const {tag, punctuation} = parsePunctuationFromTag(query)
|
||||
/*
|
||||
* This values here are passed directly to the `command` method
|
||||
* configured in the `Tags` plugin.
|
||||
*
|
||||
* The type error is ignored because we parse the tag and punctuation
|
||||
* separately above. We could do this in `command` definition, but we
|
||||
* only want to `commitRecentTag` with the sanitized tag.
|
||||
*/
|
||||
// @ts-ignore
|
||||
command({tag, punctuation})
|
||||
},
|
||||
[command],
|
||||
)
|
||||
|
||||
const selectItem = React.useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index]
|
||||
if (item) commit(item)
|
||||
},
|
||||
[items, commit],
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({event}) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSelectedIndex(
|
||||
(selectedIndex + props.items.length - 1) % props.items.length,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (!props.items.length) {
|
||||
// no items, use whatever the user typed
|
||||
commit(autocompleteModel.query)
|
||||
} else {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === ' ') {
|
||||
commit(autocompleteModel.query)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
// hide entirely if no suggestions
|
||||
if (!items.length) return null
|
||||
|
||||
return (
|
||||
<div className="items">
|
||||
<View style={[pal.borderDark, pal.view, styles.container]}>
|
||||
{items.map((tag, index) => {
|
||||
const isSelected = selectedIndex === index
|
||||
const isFirst = index === 0
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={tag}
|
||||
style={state => [
|
||||
styles.resultContainer,
|
||||
{
|
||||
backgroundColor: state.hovered
|
||||
? pal.viewLight.backgroundColor
|
||||
: undefined,
|
||||
},
|
||||
isSelected ? pal.viewLight : undefined,
|
||||
isFirst
|
||||
? styles.firstResult
|
||||
: isLast
|
||||
? styles.lastResult
|
||||
: undefined,
|
||||
]}
|
||||
onPress={() => selectItem(index)}
|
||||
accessibilityRole="button">
|
||||
<Text type="md" style={pal.textLight} numberOfLines={1}>
|
||||
#{tag}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: 500,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
padding: 4,
|
||||
},
|
||||
resultContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 4,
|
||||
},
|
||||
firstResult: {
|
||||
borderTopLeftRadius: 2,
|
||||
borderTopRightRadius: 2,
|
||||
},
|
||||
lastResult: {
|
||||
borderBottomLeftRadius: 2,
|
||||
borderBottomRightRadius: 2,
|
||||
},
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import React, {useMemo} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Linking, StyleSheet, View} from 'react-native'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import {AtUri, AppBskyFeedDefs} from '@atproto/api'
|
||||
import {AtUri, AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
@@ -35,6 +35,7 @@ import {formatCount} from '../util/numeric/format'
|
||||
import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Tag} from 'view/com/Tag'
|
||||
|
||||
export const PostThreadItem = observer(function PostThreadItem({
|
||||
item,
|
||||
@@ -56,6 +57,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
const outlineTags = item.postRecord?.tags
|
||||
const itemHref = React.useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return makeProfileLink(item.post.author, 'post', urip.rkey)
|
||||
@@ -90,6 +92,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
|
||||
const onPressReply = React.useCallback(() => {
|
||||
store.shell.openComposer({
|
||||
outlineTags,
|
||||
replyTo: {
|
||||
uri: item.post.uri,
|
||||
cid: item.post.cid,
|
||||
@@ -102,7 +105,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
},
|
||||
onPost: onPostReply,
|
||||
})
|
||||
}, [store, item, record, onPostReply])
|
||||
}, [store, item, record, onPostReply, outlineTags])
|
||||
|
||||
const onPressToggleRepost = React.useCallback(() => {
|
||||
return item
|
||||
@@ -328,11 +331,33 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
</ContentHider>
|
||||
)}
|
||||
</ContentHider>
|
||||
|
||||
{AppBskyFeedPost.isRecord(item.post.record) &&
|
||||
item.post.record.tags?.length ? (
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
{
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
paddingBottom: 12,
|
||||
marginBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
]}>
|
||||
{item.post.record.tags.map(tag => (
|
||||
<Tag key={tag} value={tag} textSize="md-medium" />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ExpandedPostDetails
|
||||
post={item.post}
|
||||
translatorUrl={translatorUrl}
|
||||
needsTranslation={needsTranslation}
|
||||
/>
|
||||
|
||||
{hasEngagement ? (
|
||||
<View style={[styles.expandedInfo, pal.border]}>
|
||||
{item.post.repostCount ? (
|
||||
@@ -369,13 +394,14 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<View style={[s.pl10, s.pb5]}>
|
||||
<View style={[s.pb5]}>
|
||||
<PostCtrls
|
||||
big
|
||||
itemUri={itemUri}
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
itemOutlineTags={outlineTags}
|
||||
author={item.post.author}
|
||||
text={item.richText?.text || record.text}
|
||||
indexedAt={item.post.indexedAt}
|
||||
@@ -507,6 +533,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
itemOutlineTags={outlineTags}
|
||||
author={item.post.author}
|
||||
text={item.richText?.text || record.text}
|
||||
indexedAt={item.post.indexedAt}
|
||||
@@ -700,7 +727,7 @@ const useStyles = () => {
|
||||
},
|
||||
expandedInfo: {
|
||||
flexDirection: 'row',
|
||||
padding: 10,
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
marginTop: 5,
|
||||
@@ -725,5 +752,10 @@ const useStyles = () => {
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
},
|
||||
tag: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ const PostLoaded = observer(function PostLoadedImpl({
|
||||
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
const outlineTags = item.postRecord?.tags
|
||||
const itemUrip = new AtUri(item.post.uri)
|
||||
const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey)
|
||||
const itemTitle = `Post by ${item.post.author.handle}`
|
||||
@@ -122,6 +123,7 @@ const PostLoaded = observer(function PostLoadedImpl({
|
||||
|
||||
const onPressReply = React.useCallback(() => {
|
||||
store.shell.openComposer({
|
||||
outlineTags,
|
||||
replyTo: {
|
||||
uri: item.post.uri,
|
||||
cid: item.post.cid,
|
||||
@@ -133,7 +135,7 @@ const PostLoaded = observer(function PostLoadedImpl({
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [store, item, record])
|
||||
}, [store, item, record, outlineTags])
|
||||
|
||||
const onPressToggleRepost = React.useCallback(() => {
|
||||
return item
|
||||
|
||||
@@ -52,6 +52,7 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||
const record = item.postRecord
|
||||
const itemUri = item.post.uri
|
||||
const itemCid = item.post.cid
|
||||
const outlineTags = item.postRecord?.tags
|
||||
const itemHref = useMemo(() => {
|
||||
const urip = new AtUri(item.post.uri)
|
||||
return makeProfileLink(item.post.author, 'post', urip.rkey)
|
||||
@@ -72,6 +73,7 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||
const onPressReply = React.useCallback(() => {
|
||||
track('FeedItem:PostReply')
|
||||
store.shell.openComposer({
|
||||
outlineTags,
|
||||
replyTo: {
|
||||
uri: item.post.uri,
|
||||
cid: item.post.cid,
|
||||
@@ -83,7 +85,7 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [item, track, record, store])
|
||||
}, [item, track, record, store, outlineTags])
|
||||
|
||||
const onPressToggleRepost = React.useCallback(() => {
|
||||
track('FeedItem:PostRepost')
|
||||
@@ -332,6 +334,7 @@ export const FeedItem = observer(function FeedItemImpl({
|
||||
itemCid={itemCid}
|
||||
itemHref={itemHref}
|
||||
itemTitle={itemTitle}
|
||||
itemOutlineTags={outlineTags}
|
||||
author={item.post.author}
|
||||
text={item.richText?.text || record.text}
|
||||
indexedAt={item.post.indexedAt}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import {View, StyleSheet, Dimensions} from 'react-native'
|
||||
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function Outer(props: React.PropsWithChildren<{}>) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[pal.view, styles.background]} />
|
||||
|
||||
{props.children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content(props: React.PropsWithChildren<{}>) {
|
||||
return <View style={styles.content}>{props.children}</View>
|
||||
}
|
||||
|
||||
export function Handle() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.handle, {backgroundColor: pal.border.borderColor}]} />
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
background: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
zIndex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 20,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
overflow: 'hidden',
|
||||
zIndex: 2,
|
||||
},
|
||||
handle: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
alignSelf: 'center',
|
||||
width: 80,
|
||||
height: 6,
|
||||
borderRadius: 10,
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
|
||||
type Component = React.ReactElement
|
||||
|
||||
type ContextType = {
|
||||
outlet: Component | null
|
||||
append(id: string, component: Component): void
|
||||
remove(id: string): void
|
||||
}
|
||||
|
||||
type ComponentMap = {
|
||||
[id: string]: Component
|
||||
}
|
||||
|
||||
export const Context = React.createContext<ContextType>({
|
||||
outlet: null,
|
||||
append: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
|
||||
export function Provider(props: React.PropsWithChildren<{}>) {
|
||||
const map = React.useRef<ComponentMap>({})
|
||||
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
|
||||
|
||||
const append = React.useCallback<ContextType['append']>((id, component) => {
|
||||
if (map.current[id]) return
|
||||
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
const remove = React.useCallback<ContextType['remove']>(id => {
|
||||
delete map.current[id]
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Context.Provider value={{outlet, append, remove}}>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outlet() {
|
||||
const ctx = React.useContext(Context)
|
||||
return ctx.outlet
|
||||
}
|
||||
|
||||
export function Portal({children}: React.PropsWithChildren<{}>) {
|
||||
const {append, remove} = React.useContext(Context)
|
||||
const id = React.useId()
|
||||
React.useEffect(() => {
|
||||
append(id, children as Component)
|
||||
return () => remove(id)
|
||||
}, [id, children, append, remove])
|
||||
return null
|
||||
}
|
||||
@@ -22,6 +22,7 @@ interface PostCtrlsOpts {
|
||||
itemCid: string
|
||||
itemHref: string
|
||||
itemTitle: string
|
||||
itemOutlineTags?: string[]
|
||||
isAuthor: boolean
|
||||
author: {
|
||||
did: string
|
||||
@@ -70,6 +71,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
||||
const onQuote = useCallback(() => {
|
||||
store.shell.closeModal()
|
||||
store.shell.openComposer({
|
||||
outlineTags: opts.itemOutlineTags,
|
||||
quote: {
|
||||
uri: opts.itemUri,
|
||||
cid: opts.itemCid,
|
||||
@@ -86,6 +88,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
||||
opts.itemUri,
|
||||
opts.text,
|
||||
store.shell,
|
||||
opts.itemOutlineTags,
|
||||
])
|
||||
|
||||
const onPressToggleLikeWrapper = async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {lh} from 'lib/styles'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Tag} from 'view/com/Tag'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
@@ -69,6 +70,7 @@ export function RichText({
|
||||
for (const segment of richText.segments()) {
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
if (mention && AppBskyRichtextFacet.validateMention(mention).success) {
|
||||
els.push(
|
||||
<TextLink
|
||||
@@ -92,6 +94,8 @@ export function RichText({
|
||||
warnOnMismatchingLabel
|
||||
/>,
|
||||
)
|
||||
} else if (tag && AppBskyRichtextFacet.validateTag(tag).success) {
|
||||
els.push(<Tag key={key} value={tag.tag} textSize={type} />)
|
||||
} else {
|
||||
els.push(segment.text)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
|
||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
|
||||
import {faTags} from '@fortawesome/free-solid-svg-icons/faTags'
|
||||
|
||||
export function setup() {
|
||||
library.add(
|
||||
@@ -199,5 +200,6 @@ export function setup() {
|
||||
faX,
|
||||
faXmark,
|
||||
faChevronDown,
|
||||
faTags,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const PostThreadScreen = withAuthRequired(
|
||||
return
|
||||
}
|
||||
store.shell.openComposer({
|
||||
outlineTags: view.thread.postRecord?.tags,
|
||||
replyTo: {
|
||||
uri: view.thread.post.uri,
|
||||
cid: view.thread.post.cid,
|
||||
|
||||
@@ -13,6 +13,7 @@ export const Composer = observer(function ComposerImpl({
|
||||
onPost,
|
||||
quote,
|
||||
mention,
|
||||
outlineTags,
|
||||
}: {
|
||||
active: boolean
|
||||
winHeight: number
|
||||
@@ -20,6 +21,7 @@ export const Composer = observer(function ComposerImpl({
|
||||
onPost?: ComposerOpts['onPost']
|
||||
quote?: ComposerOpts['quote']
|
||||
mention?: ComposerOpts['mention']
|
||||
outlineTags?: ComposerOpts['outlineTags']
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const initInterp = useAnimatedValue(0)
|
||||
@@ -64,6 +66,7 @@ export const Composer = observer(function ComposerImpl({
|
||||
onPost={onPost}
|
||||
quote={quote}
|
||||
mention={mention}
|
||||
outlineTags={outlineTags}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ export const Composer = observer(function ComposerImpl({
|
||||
quote,
|
||||
onPost,
|
||||
mention,
|
||||
outlineTags,
|
||||
}: {
|
||||
active: boolean
|
||||
winHeight: number
|
||||
@@ -21,6 +22,7 @@ export const Composer = observer(function ComposerImpl({
|
||||
quote: ComposerOpts['quote']
|
||||
onPost?: ComposerOpts['onPost']
|
||||
mention?: ComposerOpts['mention']
|
||||
outlineTags?: ComposerOpts['outlineTags']
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -46,6 +48,7 @@ export const Composer = observer(function ComposerImpl({
|
||||
quote={quote}
|
||||
onPost={onPost}
|
||||
mention={mention}
|
||||
outlineTags={outlineTags}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Drawer} from 'react-native-drawer-layout'
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
import {Provider, Outlet} from 'view/com/util/Portal'
|
||||
import {useStores} from 'state/index'
|
||||
import {ModalsContainer} from 'view/com/modals/Modal'
|
||||
import {Lightbox} from 'view/com/lightbox/Lightbox'
|
||||
@@ -76,9 +77,11 @@ const ShellInner = observer(function ShellInnerImpl() {
|
||||
onPost={store.shell.composerOpts?.onPost}
|
||||
quote={store.shell.composerOpts?.quote}
|
||||
mention={store.shell.composerOpts?.mention}
|
||||
outlineTags={store.shell.composerOpts?.outlineTags}
|
||||
/>
|
||||
<ModalsContainer />
|
||||
<Lightbox />
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -88,12 +91,16 @@ export const Shell: React.FC = observer(function ShellImpl() {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<SafeAreaProvider style={pal.view}>
|
||||
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
|
||||
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
</View>
|
||||
<Provider>
|
||||
<View
|
||||
testID="mobileShellView"
|
||||
style={[styles.outerContainer, pal.view]}>
|
||||
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
</View>
|
||||
</Provider>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ const ShellInner = observer(function ShellInnerImpl() {
|
||||
quote={store.shell.composerOpts?.quote}
|
||||
onPost={store.shell.composerOpts?.onPost}
|
||||
mention={store.shell.composerOpts?.mention}
|
||||
outlineTags={store.shell.composerOpts?.outlineTags}
|
||||
/>
|
||||
{showBottomBar && <BottomBarWeb />}
|
||||
<ModalsContainer />
|
||||
|
||||
@@ -48,17 +48,23 @@
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #F3F3F8;
|
||||
--border: #f0e9e9;
|
||||
--blue: #0085ff;
|
||||
--teal: #9AD5CA;
|
||||
--yellow: #FED766;
|
||||
}
|
||||
html.colorMode--dark {
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #26272D;
|
||||
--border: #26272D;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html.colorMode--system {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #F3F3F8;
|
||||
--border: #f0e9e9;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -66,6 +72,7 @@
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #26272D;
|
||||
--border: #26272D;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +116,7 @@
|
||||
}
|
||||
.ProseMirror p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #8d8e96;
|
||||
@@ -133,10 +141,44 @@
|
||||
input:focus {
|
||||
outline: 0;
|
||||
}
|
||||
/* TODO make this more obvious */
|
||||
.tippy-content .items {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Hashtags in ProseMirror */
|
||||
.ProseMirror .inline-tag,
|
||||
.ProseMirror .mention {
|
||||
position: relative;
|
||||
}
|
||||
.ProseMirror .inline-tag,
|
||||
.ProseMirror .mention {
|
||||
border-radius: 4px;
|
||||
background-color: var(--backgroundLight);
|
||||
padding: 0 3px 2px;
|
||||
|
||||
/* This approach works, but we can't do rounded corners
|
||||
background-color: var(--backgroundLight);
|
||||
box-shadow: -3px 0 var(--backgroundLight), -3px 2px var(--backgroundLight), 2px 0 var(--backgroundLight), 2px 2px var(--backgroundLight);
|
||||
*/
|
||||
|
||||
/*
|
||||
This sorta works but not enough padding aroundd the next, looks bad.
|
||||
|
||||
border-radius: 4px;
|
||||
background-color: red;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
*/
|
||||
}
|
||||
.ProseMirror .inline-tag {
|
||||
color: var(--text);
|
||||
}
|
||||
.ProseMirror .mention {
|
||||
color: var(--text);
|
||||
background-color: var(--yellow);
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
|
||||
@@ -1812,6 +1812,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.47.0.tgz#5478fdf443ff8158f9de171c704ae45308696c7d"
|
||||
integrity sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==
|
||||
|
||||
"@estrattonbailey/tiptap-suggestion@^2.2.0-rc.3":
|
||||
version "2.2.0-rc.3"
|
||||
resolved "https://registry.yarnpkg.com/@estrattonbailey/tiptap-suggestion/-/tiptap-suggestion-2.2.0-rc.3.tgz#190edb6b9bb620977ef5f1b8c9467bce8900cd21"
|
||||
integrity sha512-RY16dkvtoCXLSgwW8E5j40LI+v2SUXzfbN63OPPDO7RrI/cXGyjpTz4pAGTOa2q4aU/2QTR/xPjBIpPHhxikLw==
|
||||
|
||||
"@expo/bunyan@4.0.0", "@expo/bunyan@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@expo/bunyan/-/bunyan-4.0.0.tgz#be0c1de943c7987a9fbd309ea0b1acd605890c7b"
|
||||
@@ -3481,12 +3486,12 @@
|
||||
invariant "^2.2.4"
|
||||
nullthrows "^1.1.1"
|
||||
|
||||
"@react-navigation/bottom-tabs@^6.5.7":
|
||||
version "6.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-6.5.8.tgz#9536c6e45154abc183c363d07c94991e10b14856"
|
||||
integrity sha512-0aa/jXea+LyBgR5NoRNWGKw0aFhjHwCkusigMRXIrCA4kINauDcAO0w0iFbZeKfaTCVAix5kK5UxDJJ2aJpevg==
|
||||
"@react-navigation/bottom-tabs@^6.5.9":
|
||||
version "6.5.9"
|
||||
resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-6.5.9.tgz#d132e787b58b23dedfeb19b586f9467613a0c6bf"
|
||||
integrity sha512-366D4pwq41qKqzsrEk3OPW9atTQJrfrLHPJhX3NLOinRIuDrxal1jJwN3vzNZoEg7gIEKaPYckuqUKg0zRFdog==
|
||||
dependencies:
|
||||
"@react-navigation/elements" "^1.3.18"
|
||||
"@react-navigation/elements" "^1.3.19"
|
||||
color "^4.2.3"
|
||||
warn-once "^0.1.0"
|
||||
|
||||
@@ -3516,6 +3521,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.18.tgz#d8364b40276f3efb9c229c39da3b8b465f18f0a2"
|
||||
integrity sha512-/0hwnJkrr415yP0Hf4PjUKgGyfshrvNUKFXN85Mrt1gY49hy9IwxZgrrxlh0THXkPeq8q4VWw44eHDfAcQf20Q==
|
||||
|
||||
"@react-navigation/elements@^1.3.19":
|
||||
version "1.3.19"
|
||||
resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.19.tgz#18a4a754836af19c20d987e8ffc4f8b0ee2cbece"
|
||||
integrity sha512-7hLvSYKPuDS070pza5gd43WDX7QgfuEmuTWNbCJhKdWlLudYmq3qzxGCBwCfO2dEI6+p8tla5wruaWiGKAbTYw==
|
||||
|
||||
"@react-navigation/native-stack@^6.9.12":
|
||||
version "6.9.13"
|
||||
resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-6.9.13.tgz#f308c398ee18fcd45de8ec7c04fe0641735feb31"
|
||||
@@ -10088,6 +10098,11 @@ funpermaproxy@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/funpermaproxy/-/funpermaproxy-1.1.0.tgz#39cb0b8bea908051e4608d8a414f1d87b55bf557"
|
||||
integrity sha512-2Sp1hWuO8m5fqeFDusyhKqYPT+7rGLw34N3qonDcdRP8+n7M7Gl/yKp/q7oCxnnJ6pWCectOmLFJpsMU/++KrQ==
|
||||
|
||||
fuse.js@^6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
|
||||
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
|
||||
|
||||
gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
@@ -14520,6 +14535,13 @@ pify@^4.0.1:
|
||||
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
|
||||
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
|
||||
|
||||
pind@^0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/pind/-/pind-0.5.0.tgz#9f04be810b0c91aac0bb0976a57ba1d76126d0c8"
|
||||
integrity sha512-xK7GlJl2AeQQaYbtxoGOsGVa0Ms8M4B3CPIIohC2gmeDBRrxtBSWyzPc6cgdG4AEEvQETt//bIBETPy8VFO+BA==
|
||||
dependencies:
|
||||
tackjs "^3.0.1"
|
||||
|
||||
pinkie-promise@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
|
||||
@@ -17616,6 +17638,11 @@ symbol-tree@^3.2.4:
|
||||
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
|
||||
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
|
||||
|
||||
tackjs@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/tackjs/-/tackjs-3.0.1.tgz#d69af48df5921320953d28131a8ae110ae11ea06"
|
||||
integrity sha512-a/0vc4RWqG/9mYeldQ9uhGGTicc4LCr0EPpjV0IIKB21QkmFfkN559wu/Z4rprOzWDnEjUzdwsqIIrQC1H+rpg==
|
||||
|
||||
tailwindcss@^3.0.2:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"
|
||||
|
||||
Reference in New Issue
Block a user