Compare commits

...

66 Commits

Author SHA1 Message Date
Eric Bailey 94e2d0be43 Small tweaks 2023-10-24 11:40:20 -05:00
Eric Bailey ebd39c7a10 clean up sanitization 2023-10-24 11:33:22 -05:00
Eric Bailey c5005958f0 Use api package regexes 2023-10-24 09:49:13 -05:00
Eric Bailey ee3d1ff40d Use new more restrictive regex 2023-10-23 16:42:29 -05:00
Eric Bailey 61f88ae2b0 inherit outline tags of parent post 2023-10-21 12:14:37 -04:00
Eric Bailey a5d4cfda9b consolidate defs 2023-10-19 16:51:29 -04:00
Eric Bailey 4505c2bb1e remove unused EditableTag 2023-10-19 16:47:02 -04:00
Eric Bailey c3b500db9c remove comment 2023-10-19 16:46:07 -04:00
Eric Bailey d93587aeef don't backfill tags, close dropdown on tab 2023-10-19 16:45:22 -04:00
Eric Bailey 2d62a4942a centralize regex 2023-10-19 16:28:12 -04:00
Eric Bailey 72bb3ccfac handle focus on tag buttons 2023-10-19 16:16:03 -04:00
Eric Bailey 911a5a5638 strip trailing punctuation 2023-10-19 14:07:36 -04:00
Eric Bailey cb81f46d8a Merge remote-tracking branch 'origin/main' into eric/app-864-integrate-post-tags-into-app
* origin/main:
  Fix four gallery images for Firefox (#1710)
  Performance optimization  (#1676)
  Scope drawer drag workaround to feed pagers (#1694)
  Toggle lightbox controls on tap (#1687)
  Fix profile preview jump (#1693)
2023-10-18 15:16:03 -04:00
Eric Bailey 35dd618d83 use separate models, commit once 2023-10-13 14:52:58 -05:00
Eric Bailey 7ee34b6a12 swap in new button 2023-10-13 14:44:41 -05:00
Eric Bailey a5f37eb37f move TagInput into dir, use button on desktop 2023-10-13 14:38:48 -05:00
Eric Bailey 85481ef897 Merge remote-tracking branch 'origin' into eric/app-864-integrate-post-tags-into-app
* origin:
  Disable events on hidden bars (#1686)
  Fix profile layout shift (#1690)
  Don't re-render bars when showing/hiding them (#1691)
  Fix crash when scrolling down on the web (#1684)
  Make shell hide/show animation smoother (#1683)
  Fix layout shift for multi-image posts (#1673)
  bskyweb: add rate limiting to reduce DoSability
  use new zeed-dom version (#1671)
  1.53
2023-10-13 09:38:53 -05:00
Eric Bailey 0dbf8f1b18 use gorhom bottom sheet 2023-10-12 17:52:17 -05:00
Eric Bailey b1843533eb let's roll with custom bottom sheet for now 2023-10-12 17:41:54 -05:00
Eric Bailey 8aeb364014 remove selected item code 2023-10-12 15:36:07 -05:00
Eric Bailey d7f7cb3128 mobile tags input and autocomplete 2023-10-12 15:04:22 -05:00
Eric Bailey 9f9f877ce1 add desktop TagInput autocomplete 2023-10-11 15:09:37 -05:00
Eric Bailey bfdf41a4c3 cleaning 2023-10-10 20:08:37 -05:00
Eric Bailey 078ba4cd35 add tag highlighting back 2023-10-10 19:59:16 -05:00
Eric Bailey 7a97c3c345 Merge remote-tracking branch 'origin' into eric/app-864-integrate-post-tags-into-app
* origin: (21 commits)
  resolve fork of zeed-dom (#1663)
  Port remaining lightbox code to Reanimated (#1669)
  Update testrunner to use new dev-env [WIP] (#1575)
  Fix MobX crash for Android lightbox (#1668)
  Change lightbox to use Pager (#1666)
  make empty feed required (#1667)
  Only warn on links to bsky.app if it represents itself as another url (#1662)
  Fix keyboard double pad issue in email change & verify modals (#1664)
  Don't highlight tags in composer yet (#1665)
  Fix: fetch follows on desktop search for typeahead (#1660)
  Remove duplicate modal container (#1661)
  Drive-by lightbox refactors (#1659)
  Only prompt users once to verify email (according to local storage) close #1657 (#1658)
  Revert "Fix invite codes flash on desktop, use loading placeholder (#1591)" (#1656)
  Refactor iOS lightbox to Reanimated (#1645)
  Bump package.json to 1.52
  Remove unnecessary opacity logic (#1646)
  Fix typo in image.ts (#1638)
  fix typo README.md (#1631)
  Typo fix in README.md: "small about" -> "small amount" (#1639)
  ...
2023-10-10 19:30:55 -05:00
Eric Bailey 4e26c97594 consolidate tag components 2023-10-10 19:12:17 -05:00
Eric Bailey 3bae37460d some comments 2023-10-10 19:04:32 -05:00
Eric Bailey b33f70fcac remove unused TagDecorator 2023-10-10 18:59:16 -05:00
Eric Bailey 1dc38a8fd9 add a test 2023-10-10 18:49:15 -05:00
Eric Bailey b3bae78e30 enforce length in TagInput 2023-10-10 15:47:38 -05:00
Eric Bailey 41c4b4af7b enforce 64 characters 2023-10-10 15:19:39 -05:00
Eric Bailey f97f1302cf fix added space on commit 2023-10-10 12:44:22 -05:00
Eric Bailey 50335206bf generally consistent across web/ios 2023-10-09 17:53:23 -05:00
Eric Bailey d9fc2775c4 pretty good spot with styles 2023-10-09 15:38:40 -05:00
Eric Bailey 1161050e1c oops 2023-10-09 15:27:15 -05:00
Eric Bailey 3637676778 use consistent styling in the composer 2023-10-09 15:11:43 -05:00
Eric Bailey c3ec1e6613 tag style updates on web 2023-10-09 14:58:57 -05:00
Eric Bailey 48f15b7361 install fork of suggestion plugin 2023-10-09 14:58:47 -05:00
Eric Bailey 7bef0eca6a add tags autocomplete 2023-10-09 13:36:01 -05:00
Eric Bailey 3bcce36418 Merge remote-tracking branch 'origin' into eric/app-864-integrate-post-tags-into-app
* origin:
  Fix bug that prevents unfollowing (#1633)
2023-10-09 11:02:56 -05:00
Eric Bailey 6c25ca8181 Merge remote-tracking branch 'origin/main' into eric/app-864-integrate-post-tags-into-app
* origin/main: (40 commits)
  1.52
  README: tweaks to high-level context (#1625)
  Fix stuck lightbox header after double tap (#1627)
  Fix: add padding to the spinner bottom while loading threads (#1626)
  Rewrite Android lightbox (#1624)
  Dont trim before posting (close #1621) (#1622)
  Only listen to back button on android (#1623)
  Improve typeahead search with inclusion of followed users (temporary solution) (#1612)
  Slightly smaller highlighted post text (#1608)
  Pull upstream bugfixes to bottom-sheet (#1606)
  Fix animations and gestures getting reset on state updates in the lightbox (#1618)
  Remove unused lightbox options (#1616)
  Profile UI tweaks (#1607)
  Fix invite codes flash on desktop, use loading placeholder (#1591)
  Update to react-native@0.72.5 (#1599)
  Fixed a typo on the onboarding recommended screen (#1604)
  Onboarding & feed fixes (#1602)
  Improve time to content in the search page (#1603)
  Fix a potential reference error in bottombarweb (#1600)
  Fix: only use scroll-positioning control on thread when looking at replies (#1587)
  ...
2023-10-09 11:02:43 -05:00
Eric Bailey 16206df6b2 exploration of tag styles 2023-09-28 11:42:23 -05:00
Eric Bailey 804a393ff8 move Tag, update styling 2023-09-28 11:00:50 -05:00
Eric Bailey e0c614bfb7 split tags on spaces in TagInput 2023-09-28 10:31:47 -05:00
Eric Bailey 564a654cc2 mobile autocomplete 2023-09-27 21:22:47 -05:00
Eric Bailey 4d367d6805 update graphemes 2023-09-27 20:51:44 -05:00
Eric Bailey 4868d4864b stick tag input to bottom 2023-09-27 20:43:54 -05:00
Eric Bailey 6f6a34cc99 clean up tags autocomplete desktop 2023-09-27 20:30:43 -05:00
Eric Bailey bd9c1626e6 some tag styles 2023-09-27 19:56:50 -05:00
Eric Bailey a07542a607 WIP autocomplete
(cherry picked from commit 3098ebb482)
2023-09-27 19:17:33 -05:00
Eric Bailey 4b47380cdf fix node walking 2023-09-27 15:51:27 -05:00
Eric Bailey f5e793d8c3 fix tag index parsing 2023-09-27 15:51:27 -05:00
Eric Bailey 0648f02f66 remove half-baked onBlur handling 2023-09-27 15:51:27 -05:00
Eric Bailey f404f52939 clean up handling 2023-09-27 15:51:27 -05:00
Eric Bailey 76eba7b881 bump api package 2023-09-27 15:51:27 -05:00
Eric Bailey bb8a016f02 bump api package to get new richtext 2023-09-27 15:51:27 -05:00
Eric Bailey 66181679a6 integrate into native text input 2023-09-27 15:51:27 -05:00
Eric Bailey 9668102ceb fix helper text 2023-09-27 15:51:27 -05:00
Eric Bailey dab793193e made 8 default max 2023-09-27 15:51:27 -05:00
Eric Bailey 250e5eb3cd remove extra filter 2023-09-27 15:51:27 -05:00
Eric Bailey 44694abbe6 improve hash handling 2023-09-27 15:51:27 -05:00
Eric Bailey bfdbf43119 link out tags 2023-09-27 15:51:27 -05:00
Eric Bailey 439940532f add tags view 2023-09-27 15:51:27 -05:00
Eric Bailey f9914d6ff6 send along tags with api request 2023-09-27 15:51:27 -05:00
Eric Bailey 763edc8fb3 add TagDecorator plugin 2023-09-27 15:51:25 -05:00
Eric Bailey 47273cccb1 first pass at a tag input in composer 2023-09-27 15:50:36 -05:00
36 changed files with 2096 additions and 27 deletions
+4 -1
View File
@@ -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",
+2
View File
@@ -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()}`)
+6
View File
@@ -0,0 +1,6 @@
export function sanitize(tagString: string) {
return tagString
.trim()
.replace(LEADING_HASH_REGEX, '')
.replace(ENDING_PUNCTUATION_REGEX, '')
}
+4
View File
@@ -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))
}
+6
View File
@@ -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)
}
+1
View File
@@ -251,6 +251,7 @@ export interface ComposerOpts {
onPost?: () => void
quote?: ComposerOptsQuote
mention?: string // handle of user to mention
outlineTags?: string[]
}
export class ShellUiModel {
+119
View File
@@ -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 = []
})
}
}
+131
View File
@@ -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,
},
})
+42 -3
View File
@@ -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,
},
})
+274
View File
@@ -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,
},
})
+28
View File
@@ -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, '')
}
+40 -5
View File
@@ -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,
},
})
+36 -4
View File
@@ -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,
},
})
}
+3 -1
View File
@@ -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
+4 -1
View File
@@ -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}
+54
View File
@@ -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,
},
})
+56
View File
@@ -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 () => {
+4
View File
@@ -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)
}
+2
View File
@@ -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,
)
}
+1
View File
@@ -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,
+3
View File
@@ -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>
)
+3
View File
@@ -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>
+13 -6
View File
@@ -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>
)
})
+1
View File
@@ -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 />
+42
View File
@@ -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;
+32 -5
View File
@@ -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"