Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tags support to app #1524

Closed
wants to merge 71 commits into from
Closed
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
47273cc
first pass at a tag input in composer
estrattonbailey Sep 25, 2023
763edc8
add TagDecorator plugin
estrattonbailey Sep 25, 2023
f9914d6
send along tags with api request
estrattonbailey Sep 25, 2023
4399405
add tags view
estrattonbailey Sep 25, 2023
bfdbf43
link out tags
estrattonbailey Sep 25, 2023
44694ab
improve hash handling
estrattonbailey Sep 25, 2023
250e5eb
remove extra filter
estrattonbailey Sep 25, 2023
dab7931
made 8 default max
estrattonbailey Sep 25, 2023
9668102
fix helper text
estrattonbailey Sep 25, 2023
6618167
integrate into native text input
estrattonbailey Sep 25, 2023
bb8a016
bump api package to get new richtext
estrattonbailey Sep 25, 2023
76eba7b
bump api package
estrattonbailey Sep 26, 2023
f404f52
clean up handling
estrattonbailey Sep 26, 2023
0648f02
remove half-baked onBlur handling
estrattonbailey Sep 26, 2023
f5e793d
fix tag index parsing
estrattonbailey Sep 26, 2023
4b47380
fix node walking
estrattonbailey Sep 26, 2023
a07542a
WIP autocomplete
estrattonbailey Sep 27, 2023
bd9c162
some tag styles
estrattonbailey Sep 28, 2023
6f6a34c
clean up tags autocomplete desktop
estrattonbailey Sep 28, 2023
4868d48
stick tag input to bottom
estrattonbailey Sep 28, 2023
4d367d6
update graphemes
estrattonbailey Sep 28, 2023
564a654
mobile autocomplete
estrattonbailey Sep 28, 2023
e0c614b
split tags on spaces in TagInput
estrattonbailey Sep 28, 2023
804a393
move Tag, update styling
estrattonbailey Sep 28, 2023
16206df
exploration of tag styles
estrattonbailey Sep 28, 2023
6c25ca8
Merge remote-tracking branch 'origin/main' into eric/app-864-integrat…
estrattonbailey Oct 9, 2023
3bcce36
Merge remote-tracking branch 'origin' into eric/app-864-integrate-pos…
estrattonbailey Oct 9, 2023
7bef0ec
add tags autocomplete
estrattonbailey Oct 9, 2023
48f15b7
install fork of suggestion plugin
estrattonbailey Oct 9, 2023
c3ec1e6
tag style updates on web
estrattonbailey Oct 9, 2023
3637676
use consistent styling in the composer
estrattonbailey Oct 9, 2023
1161050
oops
estrattonbailey Oct 9, 2023
d9fc277
pretty good spot with styles
estrattonbailey Oct 9, 2023
5033520
generally consistent across web/ios
estrattonbailey Oct 9, 2023
f97f130
fix added space on commit
estrattonbailey Oct 10, 2023
41c4b4a
enforce 64 characters
estrattonbailey Oct 10, 2023
b3bae78
enforce length in TagInput
estrattonbailey Oct 10, 2023
1dc38a8
add a test
estrattonbailey Oct 10, 2023
b33f70f
remove unused TagDecorator
estrattonbailey Oct 10, 2023
3bae374
some comments
estrattonbailey Oct 11, 2023
4e26c97
consolidate tag components
estrattonbailey Oct 11, 2023
7a97c3c
Merge remote-tracking branch 'origin' into eric/app-864-integrate-pos…
estrattonbailey Oct 11, 2023
078ba4c
add tag highlighting back
estrattonbailey Oct 11, 2023
bfdf41a
cleaning
estrattonbailey Oct 11, 2023
9f9f877
add desktop TagInput autocomplete
estrattonbailey Oct 11, 2023
d7f7cb3
mobile tags input and autocomplete
estrattonbailey Oct 12, 2023
8aeb364
remove selected item code
estrattonbailey Oct 12, 2023
b184353
let's roll with custom bottom sheet for now
estrattonbailey Oct 12, 2023
0dbf8f1
use gorhom bottom sheet
estrattonbailey Oct 12, 2023
85481ef
Merge remote-tracking branch 'origin' into eric/app-864-integrate-pos…
estrattonbailey Oct 13, 2023
a5f37eb
move TagInput into dir, use button on desktop
estrattonbailey Oct 13, 2023
7ee34b6
swap in new button
estrattonbailey Oct 13, 2023
35dd618
use separate models, commit once
estrattonbailey Oct 13, 2023
cb81f46
Merge remote-tracking branch 'origin/main' into eric/app-864-integrat…
estrattonbailey Oct 18, 2023
911a5a5
strip trailing punctuation
estrattonbailey Oct 19, 2023
72bb3cc
handle focus on tag buttons
estrattonbailey Oct 19, 2023
2d62a49
centralize regex
estrattonbailey Oct 19, 2023
d93587a
don't backfill tags, close dropdown on tab
estrattonbailey Oct 19, 2023
c3b500d
remove comment
estrattonbailey Oct 19, 2023
4505c2b
remove unused EditableTag
estrattonbailey Oct 19, 2023
a5d4cfd
consolidate defs
estrattonbailey Oct 19, 2023
61f88ae
inherit outline tags of parent post
estrattonbailey Oct 21, 2023
ee3d1ff
Use new more restrictive regex
estrattonbailey Oct 23, 2023
c500595
Use api package regexes
estrattonbailey Oct 24, 2023
ebd39c7
clean up sanitization
estrattonbailey Oct 24, 2023
94e2d0b
Small tweaks
estrattonbailey Oct 24, 2023
cc3e992
remove unused file
estrattonbailey Oct 24, 2023
b78ec89
Use new api pkg APIs
estrattonbailey Oct 25, 2023
8314f90
Improve autocomplete model logic
estrattonbailey Oct 25, 2023
0ec579b
Tweak rendering of hashtags and expanded posts for clarity and inform…
pfrazee Oct 26, 2023
38fd222
Improve overflow spacing
pfrazee Oct 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()}`)
Expand Down
3 changes: 3 additions & 0 deletions src/lib/strings/hashtags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const TAG_REGEX = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/gi
export const ENDING_PUNCTUATION_REGEX = /\p{P}+$/gu
export const LEADING_HASH_REGEX = /^#/
6 changes: 6 additions & 0 deletions src/state/models/root-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -80,6 +82,7 @@ export class RootStoreModel {
invitedUsers: this.invitedUsers.serialize(),
mutedThreads: this.mutedThreads.serialize(),
reminders: this.reminders.serialize(),
recentTags: this.recentTags.serialize(),
}
}

Expand Down Expand Up @@ -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)
}
Expand Down
128 changes: 128 additions & 0 deletions src/state/models/ui/tags-autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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?
*/
estrattonbailey marked this conversation as resolved.
Show resolved Hide resolved
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[] = ['biology']

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,
]),
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sampling for the tag type-ahead (profile tags are still todo)


// 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)
// backfill again in case search has no results
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 = [
'bluesky',
'code',
'coding',
'dev',
'developer',
'development',
'devlife',
]
})
}
}
182 changes: 182 additions & 0 deletions src/view/com/Tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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 EditableTag({
value,
onRemove,
}: {
value: string
onRemove: (tag: string) => void
}) {
const pal = usePalette('default')
const [hovered, setHovered] = React.useState(false)

const hoverIn = React.useCallback(() => {
setHovered(true)
}, [setHovered])

const hoverOut = React.useCallback(() => {
setHovered(false)
}, [setHovered])

return (
<Pressable
accessibilityRole="button"
onPress={() => onRemove(value)}
onPointerEnter={hoverIn}
onPointerLeave={hoverOut}
style={state => [
pal.viewLight,
styles.editableTag,
{
opacity: state.pressed || state.focused ? 0.8 : 1,
outline: 0,
paddingRight: 6,
},
]}>
<Text type="md-medium" style={[pal.textLight]}>
#{value}
</Text>
<FontAwesomeIcon
icon="x"
style={
{
opacity: hovered ? 1 : 0.5,
color: pal.textLight.color,
marginTop: 1,
} as FontAwesomeIconStyle
}
size={10}
/>
</Pressable>
)
}

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,
},
})
Loading
Loading